当前位置:网站首页>为什么Volatile能保证双重检查锁的线程安全
为什么Volatile能保证双重检查锁的线程安全
2022-08-02 14:13:00 【墨、鱼】
单例模式分为懒汉式和饿汉式。
懒汉式其实就是为了延迟初始化来降低初始化类和创建对象的开销。
1、双重检查锁的由来
1.1、非线程安全的懒汉式单例
一种懒汉式的单例写法如下:
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) // 1:A线程执行
instance = new Instance(); // 2:B线程执行
return instance;
}
}
现在看来这种写法不是线程安全的。
假如两个线程A、B并发调用getInstance
方法,
当B线程先执行到代码2,此时线程A执行到代码1,
由于线程B还未完全执行完instance
的初始化,所以线程A可能会看到instance
仍然为null,导致线程A再执行一次代码2,最终的结果是产生两个对象,和单例的定义相违背!
1.2、线程安全的懒汉式单例
很容易能想到将上述代码线程安全化的方法,没错,就是给getInstance
方法加上synchronized
修饰,即:
public class UnsafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
synchronized
可以保证顺序一致性,使A、B线程达到串行执行的效果。
但是这样做有什么问题呢?
在早期的JVM中,synchronized(甚至是无竞争的synchronized,当然现在的JVM会对synchronized做很多优化,如锁消除。)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。代码如下:
public class DoubleCheckedLocking {
// 1
private static Instance instance; // 2
public static Instance getInstance() {
// 3
if (instance == null) {
// 4:第一次检查
synchronized (DoubleCheckedLocking.class) {
// 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。
因此,可以大幅降低synchronized带来的性能开销。
上面代码表面上看起来,似乎两全其美。但这是一个错误的优化!在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
2、问题的根源
前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
这说明
instance=new Singleton()
本身不是原子性的操作。
所以上面3行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,为了执行性能这种重排序是真实发生的)。
2和3之间重排序之后的执行时序如下:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址,注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
在下文中我们统一将这三行为代码称为 “对象初始化指令”
在单线程环境下的执行情况如下:
Java语言规范规定所有线程在执行Java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。
上面重排序对单线程的执行结果不会有影响。
而在多线程环境下的执行情况如下:
具体执行时序如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:分配对象的内存空间 | |
t2 | A3:设置instance指向刚才分配的内存空间 | |
t3 | B1:判断instance是否为null | |
t4 | B2:由于instance不为null,线程B将能访问到instance引用的对象 | |
t5 | A2:初始化对象 | |
t6 | A4:访问instance引用的对象 |
这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。
此时,线程B可能会访问到一个还未初始化完成的对象。
3、解决方案
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
不允许“对象初始化指令”中2和3重排序。
允许“对象初始化指令”中2和3重排序,但不允许其他线程“看到”这个重排序。
3.1、基于volatile的解决方案
只需要对上面双重检查锁的例子稍微改动一下就可以实现线程安全的延迟初始化:
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();
}
}
return instance;
}
}
给instance的声明增加了volatile
修饰符后,上述2和3的代码不会重排序,因为JSR内存模型规范中volatile
的内存语义保证了“在多线程环境中指令重排序将会被禁止”。
上面示例代码将按如下的时序执行:
这个方案本质上是通过禁止重排序,来保证线程安全的延迟初始化。
3.2、基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案:
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ;
}
}
假设两个线程并发执行getInstance()方法,下面是执行的示意图:
这个方案的本质是允许上述“对象初始化指令”中的2和3重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序。
Java语言规定同一个接口或类在多线程环境下只能被初始化一次,因此对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。
Java初始化一个类或接口的处理过程如下:
通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。此时初始化锁的state = noInitialization
线程A获取到初始化锁,执行类的初始化,设置state = initializing,同时线程B在初始化锁对应的condition上等待。
线程A设置state = initialized,然后唤醒在condition中等待的所有线程。
线程B获取到初始化锁,执行类的初始化,发现state = initialized,则直接释放初始化锁,完成类初始化。
4、总结
通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
边栏推荐
猜你喜欢
1.开发社区首页,注册
Evaluation multipath weswood congestion control on ns3
光波导应用中的真实光栅效应
How to simulate 1/3 probability with coins, and arbitrary probability?
使用1D-1D EPE的光波导布局设计工具
Introduction to in-order traversal (non-recursive, recursive) after binary tree traversal
Software Testing Basics (Back)
4.发布帖子,评论帖子
二叉树的遍历:递归法/ 迭代法/ 统一迭代法(强QAQ)
Based on the matrix calculation in the linear regression equation of the coefficient estimates
随机推荐
永久更改pip源
Codeforces Round #605 (Div. 3)
unity-shader(入门)
Codeforces Round #605 (Div. 3)
Introduction to in-order traversal (non-recursive, recursive) after binary tree traversal
What are IPV4 and IPV6?
Detailed explanation of MATLAB drawing function plot
Redis 学习part one
二叉排序树与 set、map
MATLAB drawing command fimplicit detailed introduction to drawing implicit function graphics
二叉排序树与 set、map
mysql学习总结 & 索引
Unity-PlayMaker
灵活的区域定义
泰伯效应.
LITESTAR 4D应用:室内植物照明模拟
6. Unified logging
Network Security Packet Capture
求解斐波那契数列的若干方法
Knapsack Problem - Dynamic Programming - Theory