双重检查锁定(DoubleCheckLocking)实现单例模式
DCL存在问题的代码如下所示
public class Singleton {
private Singleton() {
}
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) { //1. 第一次检查,不为null表示存在实例,直接返回
synchronized (Singelton.class) { //2. 加锁,保证内部代码同步进行
if (instance == null) { //3. 第二次检查,不为null表示存在实例
instance = new Singleton(); //4. 创建单例对象,也是问题的根源
}
}
}
return instance;
}
}
问题根源
双重检查锁定看起来似乎很完美,但是这是一个错误的优化!问题主要出现在第4步:
instance = new Singleton();
这一行代码可以拆分为下面3行伪代码:
memory = allocate(); //1. 分配对象的内存空间
ctorInstance(memory); //2. 初始化对象
instance = memory; //3. 设置instance指向刚刚分配的内存地址
其中2,3步之间可能会存在指令重排的现象。2和3重排序之后就会出现下面的情况
memory = allocate(); //1. 分配对象的内存空间
instance = memory; //3. 设置instance指向刚刚分配的内存地址
ctorInstance(memory); //2. 初始化对象
根据《Java语言规范》所描述的,所有线程在执行Java程序时,必须要遵循intra-thread semantics。intra-thread semantics保证重排序不会改变在单线程内的程序执行结果。也就是说intra-thread semantics允许那些在单线程内,不会改变程序执行结果的重排序。
单线程情况下:
2和3在单线程下重排序被允许,但是在多线程情况下,就出现了问题:
这里B线程的在源代码中第1步时可能判断到instance并不是null,这时线程B就访问instance所引用的对象,但是这时候instance还未被线程A初始化,就会导致线程B访问到一个未被初始化的对象。
知道原因之后,我们才能着力解决这个问题,有两种方法能够实现线程安全的延迟初始化:
- 禁止指令重排(使用volatile关键字)
- 允许指令重排,但是不允许其他线程“看到”这个重排序(Holder模式)
博主在博客《一文读懂单例模式(Java)》中已经展示过代码了,这里主要详细讲解两种方法的解决问题的原理。
方法一:基于volatile关键字的解决方法
volatile解决这个问题,主要就是利用了volatile能够禁止指令重排(详见博客《深入理解Java中volatile关键字》)的特点进一步实现的。流程如下:
方法二:基于类初始化的解决方案
由于JVM在类的初始化阶段(Class被加载后,且被线程使用之前),会执行初始化。初始化期间,JVM会去获得一个锁,这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现线程安全的延迟初始化方案
public class Singleton {
private static class InstanceHolder {
private static Singleton instance = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return InstanceHolder.instance; //这里导致内部类InstanceHolder类被加载
}
}
执行示意图如下:
这个方法的实质就是允许创建对象时那两步的重排序,但是不允许非构造线程“看到”到这个重排序。
初始化一个类,包括执行这个类的静态初始化和初始化类中生命的静态字段,根据Java语言规范,在首次发生下列任意一种情况时,一个类或者接口类型T将被立即初始化。
- T是一个类,且一个T类型的实例被创建。
- T是一个类,且T中声明的一个静态方法被调用。
- T中声明的一个静态字段被赋值。
- T中声明的一个静态字段被使用,且这个字段不是一个常量字段。
- T是一个顶级类(Top Level Class),且一个断言语句嵌套在T内部被执行。
在这里,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。Java语言规范规定,对于任意一个类或接口C,都有一个唯一的初始化锁(LC)与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化阶段会获取这个初始化锁,这个初始化锁具有一个状态属性state,标志着该类的初始化状态,每个线程至少会获取一次这个初始化锁,各个线程会根据这个初始化状态来确定已经被初始化过,进而确保该类已经被初始化。
总结
通过对比基于volatile的DCL和基于类的初始化两个方案,我们可以看到基于类初始化的方案代码更加简洁。但是基于volatile的双重检查锁定的方案不仅可以对静态字段实现延迟初始化,还能对实例字段实现延迟初始化。字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问该字段的开销(因为每一次都得先获取初始化锁进行判断)。
因此,如果需要对实例字段使用线程安全的延迟初始化,则使用基于volatile的解决方案;如果需要对静态字段使用线程安全的延迟初始化,则使用基于类初始化的解决方案。