双重检查锁定
双重检查锁定,即Double-Checked Lock问题,是并发编程中由于指令重排和不正确同步导致的经典并发问题
延迟初始化
在介绍本文重点双重检查锁定前,必须要先了解双重检查锁定问题是由何而来的。
学习过Spring的同学一定都听过懒加载(lazy-init),延迟初始化与懒加载是同类型的思想,都是为了避免、推迟不必要的高性能开销操作或降低程序的启动时间。而本文要讨论的双重检查锁定就是为了延迟初始化服务的,详见下文。
单线程环境下的延迟初始化
在假定的单线程环境下,下列代码就可以很好的实现延迟初始化这一操作
// 单线程延迟初始化
public class Test {
private static Object instance;
public static Object getInstance(){
if(instance == null){
instance = new Object();
}
return instance;
}
}
多线程环境下的延迟初始化
而在多线程环境下,显而易见的上述代码是线程不安全的延迟初始化实现方式,例如在经典的设计模式之一单例模式中,在目标类还未被实例化时,有两个线程同时尝试获取实例,就有可能出现下图情况:(若干情况之一)
可见有两个实例被创建,单例的唯一性被破坏,因此需要采取某些手段来保证线程安全的延迟初始化操作。
保证线程安全
使用synchronized同步处理
出现线程安全问题是,易想到使用synchronized关键字对目标方法进行同步处理一保证线程安全,如下列代码所示:
//使用synchronized关键字做同步处理保证线程安全
public class Test {
private static Object instance;
public synchronized static Object getInstance(){
if(instance == null){
instance = new Object();
}
return instance;
}
}
对getInstance()方法进行同步处理后,的确能够实现线程安全的延迟初始化操作,在目标实例不会被频繁使用的场景下,此种解决方法也许能够很好的满足需求。
但如果目标实例会被频繁的使用,那么使用synchronized带来的性能开销将令人难以忍受,因为事实上只有getInstance()方法被初次调用时,synchronized才会起作用,同时也完成了全部职责,后续的全部调用是单纯的降低性能。在此基础上,双重检查锁定诞生了。
错误的双重检查锁定
双重检查锁定的目的就是替代synchronized在高频繁使用场景下的使用,降低不必要部分的性能开销,最初的尝试代码如下:
//最初的双重检查锁定尝试
public class Test {
private static Object instance;
public static Object getInstance(){
if(instance == null){
synchronized (Test.class){
if(instance == null) {
instance = new Object();
}
}
}
return instance;
}
}
在上述代码中,如果
- 多条线程同时尝试创建对象,则通过synchronized加锁来保证只有一个线程能创建对象。
- 对象已经创建完成时,则不再需要加锁,直接返回已经被创建好的对象。
这是代码理想的执行情况,同时解决了线程安全和性能开销的问题,但正如小标题所示,这是一种错误的优化方式,上述代码实际上仍然不能保证线程安全,问题见下。
最初的双重检查锁定问题所在
问题的根源其实是指令重排序
重排序
编译器和处理器为了优化程序性能,有时会对指令进行重新排序。举一个本场景下的例子,如创建对象的语句instance = new Object(),该语句可以实际分解为以下三个步骤
- 为对象分配内存空间
- 初始化对象
- 为instance设置所指向的内存地址
而上述三个步骤就有可能发生指令重排,实际执行顺序有可能改变为132,此种情况下,在刚刚执行完为instance设置指向时,instance已经不等于null,但实际上对象还未被初始化,这就意味着在多线程的环境下,有线程有可能获取到一个未经初始化的对象,这是一种十分糟糕的情况。
重排序引发的双重检查锁定问题
仍假设有两个线程近乎同时访问getInstance()方法,此时instance为null,线程A先于线程B访问。在不考虑指令重排序的情况下,线程A会按顺序执行如下操作
- 判断instance是否为空
- instance为空,为对象分配内存空间
- 初始化对象
- 设置instance指向的内存地址
- 返回instance
但经历可能的指令重排后,操作3和操作4可能会调换位置,即可能出现如下表格情况:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:判断instance是否为null | |
t2 | A2:由于instance为null,为对象分配内存空间 | |
t3 | A4:设置instance指向的内存地址 | |
t4 | B1:判断instance是否为null | |
t5 | B2:由于instance不为null,返回instance | |
t6 | A3:初始化对象 | |
t7 | A5:返回instance |
由上执行时序表格可见,在t5时间点,线程B拿到了一个尚未初始化的对象,这显然是不应该发生且危险的情况。
双重检查锁定的改进方法
使用volatile保证可见性
首先可以对双重检查锁定进行优化,即使用volatile关键字对变量进行修饰,这样可以保证被修饰变量的可见性,即对于一个volatile变量的读,总能保证看到任意线程对这个volatile变量最后的写入。其底层实现依靠的是禁止部分指令重排。
修改后代码如下:
//使用volatile关键字修饰instance变量以(禁止指令重排)保证可见性
public class Test02 {
private volatile static Object instance;
public static Object getInstance(){
if(instance == null){
synchronized (Test02.class){
if(instance == null) {
instance = new Object();
}
}
}
return instance;
}
}