一.双检锁单例模式
package thread.lock.double_check;
/**
* 双检锁单例模式
*/
public class Singleton {
/**
* 该类实例, volatile主要防止第29行指令重排序
*/
private volatile static Singleton instance;
/**
* 获取实例的方法
* @return
*/
public static Singleton getInstance() {
// 第一把锁, 如果实例为null, 则继续执行
if (instance == null) {
// 为该对象加锁
synchronized (Singleton.class) {
// 加完锁后, 再次判断实例是否为null, 主要解决第一次
// 判断完是否为null的之后, 加锁之前是否创建好了对象
if (instance == null) {
// 如果程序能够执行到这里, 按道理来讲, 该不会出现问题了
// 但是为了提高性能,编译器和处理器常常会对既定的代码
// 执行顺序进行指令重排序
// 为 instance 加上 volatile 可以防止指令重排序
instance = new Singleton();
}
}
}
return instance;
}
}
第29行只是一行创建对象的代码, 为什么会发生指令重排序呢, 我们先看下对象创建的过程
二. 对象的创建过程
1. 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化过,如果没有,那必须先执行相应的类加载过程。
2. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来,划分的方式一般为以下两种:
a.指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
b.空间列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞,虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
使用哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况,解决这个问题有两种方案:
a.对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
b.把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行,这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4. 虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息,这些信息存放在对象的对象头(ObjectHeader)之中。
5. 完成以上步骤后,从虚拟机视角,一个新的对象已经产生了,但从Java程序的视角来看,对象的init方法还没有执行,所有的字段都还为零,执行完init方法后,一个可用的对象才被完全创建出来。
总结起来就是三步:
1.在堆中开辟对象所需空间,分配内存地址
2.根据类加载的初始化顺序进行初始化
3.将内存地址返回给栈中的引用变量
第2和3步是可能发生指令重排序的, 即在第一步开辟出内存空间后, 立刻执行了第三步, 然后再进行类的初始化。如果A线程创建对象时的顺序是 1 -> 3 -> 2, 它在执行3的时候, instance已经不为null, 但是堆中的对象仍然为null, 这时如果有线程B也想创建对象, 则在A执行完3后, instance 因为不是null 了, 所以会直接返回一个没有初始化完的对象, 从而引起异常。所以需要加上 volatile 关键字防止指令重排序。
三.volatile如何解决指令重排序
有volatile修饰的变量,赋值后会多执行一个指令,如:"lock addl",这个操作相当于一个内存屏障(Memory Barrier 或 Memory Fence,指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个cup访问时,并不需要内存屏障;但如果有两个或更多CPU访问同一块内存,且其中有一个在观察另一个,就需要内存屏障来保证一致性:
a. volatile读操作时,在读操作后面插入两个内存屏障。
b. volatile写操作时,在写操作前面和后面分别插入内存屏障。