一、DCL问题分析
DCL,即Double Check Lock,双重检查锁定,通常使用在懒加载的单例模式中,一般单例模式里的懒加载代码如下:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
在单线程中,该单例模式是有效的,但是在多线程程序中,对于这种先检查后操作的先验条件,如果没有同步策略,会产生竞态条件,最终导致线程安全问题,可能导致重复创建了多次对象。
为了保证代码同步,可以将 getInstance()
方法声明为 synchronized
,但是这会导致不管单例是否已经实例化,每次获取单例都要加锁加锁,性能底下,所以应该用同步代码块包裹if分支里的代码,并且为了避免单例被重复创建,在获取锁之后还要再检查一次单例是否为空来决定是否执行创建单例对象的操作,代码如下所示:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
这个写法兼顾了效率和安全性:
- 如果 instance 为空,直接返回单例;
- 如果 instance 不为空,为了避免多个线程重复创建对象,使用 synchronized 同步;
- 第一个拿到锁的线程,判断到单例为空,创建对象,然后释放锁,而后续的对象拿到锁后判断单例非空,退出同步块,返回单例,对于再晚一些的线程(第一个线程完成构建对象之后的线程),到了第一个判断到单例非空,也直接返回单例。
看似非常完美,实际上依然存在问题,对象的创建实际上分为三个部分:
1、分配内存空间
2、初始化对象
3、将内存空间的地址赋值给对应的引用
但是,由于可能发生重排序,2跟3的执行顺序可能会反过来,如下:
1、分配内存空间
2、将内存空间的地址赋值给对应的引用
3、初始化对象
如果发生这种情况,会导致第一个线程完成构建对象之后的进入方法的线程在第一个判断时可能判断到单例非空,但是该单例还没有完成初始化,就被返回了,没有被安全发布,当外部程序使用了这个未被正确初始化的单例时,会发生不可预测的错误。
二、解决方案
为了解决上述问题,核心是保证单例被正确地初始化,解决办法有:
1、不允许创建单例对象时2和3的重排序
2、允许2和3的重排序,但是在对象被正确构建完成之前,其他线程不允许看到这个“重排序”
2.1 基于volatile的解决方案
volatile可以保证共享变量的可见性,还能禁止非线程安全的重排序,所以将单例声明为 volatile
可以避免上述的重排序,代码如下:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
2.2 基于类初始化的解决方案
类加载器在加载类时会初始化被声明为 static
的变量和代码块,JVM在类初始化阶段会获取一个锁,这个锁可以同步多个线程对同一个类的初始化,代码如下:
public class Singleton {
private static class SingletonHolder {
private static Singleton instance = new Singleton();
}
private Singleton() {}
public static Singleton getInstance() {
return SingletonHolder.instance;
}
}
为了满足懒加载,不能直接在类中直接定义一个声明为 static
的实例,所以要定义一个内部静态类的静态加载来满足这种需求,JVM可以在使用类的线程之前,完成这种静态初始化,因此该方案的本质是运行步骤2和步骤3重排序,但是不允许其他线程看见。
Java语言规定,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之相对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化阶段期间会获取这个初始化锁,并且每一个线程至少获取一次锁来确保这个类已经被初始化过了。
网友评论