Java中的双重检查锁 Double Checked Locking
在实现单例模式中,如果没有考虑多线程并发的情况下,初学者很容易写出下面的错误单例模式代码:
public class Singleton(){
private static Singleton uniqueSingleton;
private Singleton(){}
private Singleton getInstance(){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton();
}
return uniqueSingleton;
}
}
很显然,在多线程的情况下,这样写可能会导致uniqueSingleton
被创建出多个实例,比如下面的情况考虑有两个线程同时调用getInstance()
方法时:
Time | Thread A 记为线程A | Thread B 记为线程B |
---|---|---|
T1 | 检查到uniqueSingleton 为空 | |
T2 | 检查到uniqueSingleton 为空 | |
T3 | 初始化对象A | |
T4 | 返回对象A | |
T5 | 初始化对象B | |
T6 | 返回对象B |
用文字表述如下:
如果线程A和B同时执行了getInstance()
方法,然后以如下方式执行:
- 线程A进入if判断,此时
instance
为null
,因此可以进入if内 - 线程B进入if判断,此时A还没有创建
instance
,因此instance
也为null
,因此线程B也进入了if内 - 线程B初始化了一个对象并返回
- 线程B也初始化了一个对象并返回
因此此时一个对象实际上还是可以被创建多次,并没有达到单例的效果。
加锁
那么出现这种情况,第一反应肯定是加锁,因此可以将上述代码更改如下:
public class Singleton(){
private static Singleton uniqueSingleton;
private Singleton(){}
//加锁
private synchronized Singleton getInstance(){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton();
}
return uniqueSingleton;
}
}
这样虽然解决了问题,但是因为用到了synchronized
会导致比较大的开销,并且加锁实际中只需要在第一次初始化的时候用到,之后的调用并不需要再进行加锁,因此这种方法实际上需要改进。
双重检查锁
双重检查锁是对上述加锁问题的一种优化,先判断对象是否已经被初始化,再决定是否加锁。
错误的双重检查锁
public class Singleton{
private static Singleton uniqueSingleton;
private SIngleton(){}
public Singleton getInstance(){
if(null == uniqueSingleton){
synchronized(Singleton.class){
if(null == uniqueSingleton){
uniqueSingleton = new Singleton(); //错误示范
}
}
}
return uniqueSingleton;
}
}
如果这样写的话,运行顺序就变成了:
1.检查变量是否被初始化(不去获得锁),如果已经被初始化就直接返回
2.获得锁
3.再次检查变量是否已经被初始化,如果还没有初始化就初始化一个对象
执行双重检查是因为,如果多个线程同时通过了第一次检查,并且其中的一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象。
这样,除了初始化的时候会出现加锁的情况,后续的调用都会避免加锁而直接返回,解决了性能消耗的问题。
隐患
上述的写法看似解决了问题,实际上有个很大的隐患。实例化对象的那一行代码,实际上可以分解为三个步骤:
- 分配内存空间
- 初始化对象
- 将对象指向刚分配的内存空间
但是有些编译器为了提升性能,会采用指令重排序,因此可能会将第二步和第三步进行重排序(在某些JIT编译器中这种情况是会真实发生的),顺序就变成了:
- 分配内存空间
- 将对象指向刚分配的内存空间
- 初始化对象
而当考虑重排序后,两个线程发生了以下的调用:
Time | Thread A 线程A | Thread B 线程B |
---|---|---|
T1 | 检查到uniqueSingleton 为空 | |
T2 | 获得锁 | |
T3 | 再次检查到uniqueSingleton 为空 | |
T4 | 为uniqueSingleton 分配内存空间 | |
T5 | 将uniqueSingleton 指向内存空间 | |
T6 | 检查到uniqueSingleton 不为空 | |
T7 | 访问uniqueSingleton (此时对象还未完成初始化) | |
T8 | 初始化uniqueSingleton |
文字表述如下:
-
A、B线程同时进入了第一个if判断
-
A首先初始化
synchronized
块,由于uniqueSingleton
为null,所以它执行了uniqueSingleton = new Singleton();
-
由于JVM内部的优化机制,JVM先划出一些分配给Singleton实例的空白内存,并赋值给instance成员,注意此时JVM还没有开始初始化这个实例,然后A离开了这个
synchronized
块。- B进入
synchronized
块,由于uniqueSingleton
此时不是null,因此它马上离开了synchronized
块 并将结果返回给调用该方法的程序。
- B进入
-
此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。
在知晓了问题发生的根源之后,我们可以想出两种办法来实现线程安全的单例模式,
1)不允许2和3重排序。
2)允许2和3重排序,但是不允许其它线程“看到”这个重排序。
正确的双重检查锁
public class Singleton {
private volatile static Singleton uniqueSingleton;
private Singleton() {
}
public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}
为了解决隐患问题,我们需要在uniqueSingleton
前加上关键字volatile
。使用了volatile
关键词后,编译器的指令重排序被禁止,所有对加上该关键字的共享变量的写(write)操作都发生在读(read)操作之前。至此,双重检查锁就可以完美工作了!
基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的 初始化。在执行类的初始化期间,JVM会尝试去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另外一种线程安全的延迟加载初始化方案:
public class SingletonFactory{
private static class InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance; //这里将导致InstanceHodler类被初始化
}
}
这个方案的实质是:允许重排序,但是不允许非构造线程“看到”这个排序。
本文由 Sanarous 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可,转载前请务必署名
本文链接:https://bestzuo.cn/posts/1f03cc33.html
最后更新于:2019-03-06 13:08:08
评论