问题是最好的导师, 细心是最棒的品质
DCL
一定要加volatile
吗?
一、DCL
DCL
全称double check lock
, 双重检查锁
这是单例模式的一种实现, 也是目前的标配实现, 在这之前还有饿汉
/懒汉
等实现, 先看一下DCL
的实现
public class DclSingleBean {
private volatile static DclSingleBean instance;
private DclSingleBean() {
System.out.println("doSomeThing...");
}
public static DclSingleBean getInstance() {
if (null == instance) {
synchronized (DclSingleBean.class) {
if (null == instance) {
instance = new DclSingleBean();
}
}
}
return instance;
}
}
这是一种安全的懒汉式单例实现, 很经典的一种设计模式.
-
volatile
关键字在这的意义是什么? -
volatile
可以去掉吗?
二、再谈volatile
上篇文章讲了些volatile
的可见性, 还有一个很耀眼的特性, 叫做禁止指令重排
0. JMM - Java内存模型
在 Java内存模型中规定了三种特性
- 原子性, 规定了一些操作具有原子性, 比如赋值操作
i = 1
- 有序性, 优化执行顺序以提高执行效率
- 可见性, 当一个线程修改了共享变量的值,其他线程是否能够立即知道这个修改.
以下引用自 Java内存模型与指令重排
- Happen-Before先行发生规则
如果光靠sychronized
和volatile
来保证程序执行过程中的原子性, 有序性, 可见性, 那么代码将会变得异常繁琐.
JMM
提供了Happen-Before
规则来约束数据之间是否存在竞争, 线程环境是否安全, 具体如下:
- 顺序原则
一个线程内保证语义的串行性; a = 1; b = a + 1; -
volatile
规则
volatile
变量的写,先发生于读,这保证了volatile
变量的可见性, - 锁规则
解锁(unlock
)必然发生在随后的加锁(lock
)前. - 传递性
A先于B,B先于C,那么A必然先于C. - 线程启动, 中断, 终止
线程的start()
方法先于它的每一个动作. - 线程的中断(
interrupt()
)先于被中断线程的代码.
线程的所有操作先于线程的终结(Thread.join()
). - 对象终结
对象的构造函数执行结束先于finalize()
方法.
1. 什么叫指令重排
我们有三行代码
1 int a = 1;
2 int b = 2;
3 int c= a + b;
正常的执行方式就是顺序执行,但我们会发现第一行与第二行是没有什么前后依赖关系的,那么如果把第一行与第二行同时执行不就可以提升运行速度吗?
于是JVM
的建设者就做了类似的优化,这种优化就是指令重排
,顾名思义就是对指令的执行顺序按照一定的规则进行重新排序以使得其运行更快
JVM
中做这件事的是JIT
<Just In Time Compiler
> 即时编译器
在部分的商用虚拟机中,Java 程序最初是通过解释器(
Interpreter
)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为热点代码
。为了提高热点代码的执行效率,在运行时,即时编译器(Just In Time Compiler
)会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。
参考 https://www.cnblogs.com/linghu-java/p/8589843.html
所以上述代码执行时可能就被优化为第一行与第二行同时执行, 然后再执行第三行.
2. 指令重排带来的问题
很多情况下的指令重排会加快运行效率,但一些特殊情况下的指令重排却可能带来一些难以定位的问题,其中 DCL
就是比较典型的一种.
为了方便解析DclSingleBean
类的实例化过程,我们加一个字段
public class DclSingleBean {
private int x;
public int getX() {
return x;
}
private volatile static DclSingleBean instance;
private DclSingleBean() {
x = 8;
System.out.println("doSomeThing...");
}
public static DclSingleBean getInstance() {
if (null == instance) {
synchronized (DclSingleBean.class) {
if (null == instance) {
instance = new DclSingleBean();
}
}
}
return instance;
}
}
- 增加实例变量
x
, 在构造时赋初始值
那DclSingleBean
类的实例化过程可以简化为以下步骤
private volatile static DclSingleBean instance;
private DclSingleBean() {
x = 8;
}
instance = new DclSingleBean();
- 在堆中为
instance
分配内存空间 - 对象字段赋
零
值, 这里的x
是int
类型, 所以此时x
为0
- 调用构造方法, 赋初始值
x
为8
- 将栈中引用与堆中对象建立连接
- 对象创建完成
如果允许重排序, 也就是去掉volatile
关键字, 假设第三步构造与第四步建立转换的执行顺序发生调换会发生什么?
- 将栈中引用与堆中对象建立连接
- 调用构造方法, 赋初始值
x
为8
在建立连接之后, 有其他线程此时进入第一重的null == instance
判断, 得到的是false
, 因为已经建立连接了. 然后直接返回了这个半初始化
的对象instance
, 拿到其中的x
值为0
, 这里的问题就暴露出来了.
说明
- 指令重排发生的几率非常小, 需要一定量级的并发才可能发生
这是volatile
的禁止指令重排的经典例子, 可以好好琢磨下, 容易陷入的误区是我既然加锁了, 为什么还有其他线程能拿到当前线程还没有实例完全的对象?
- 锁住的代码块在第一重判断与第二重判断之间, 所有线程都能进入第一重判断
-
static
修饰的变量是全局可见的, 一旦建立连接, 就肯定不是null
了
3. 禁止指令重排 - 内存屏障
我理解内存屏障就是指令之间的高墙, 禁止逾越.
而只有读/写才有顺序之分, 分别是以下指令
-
load
从主存中读取数据到工作内存 -
store
将数据从工作内存写回主存
而对应地有四种内存屏障
StoreStoreBarrier
LoadLoadBarrier
StoreLoadBarrier
LoadStoreBarrier
StoreStoreBarrier
就是写与写之间的内存屏障, 保证了屏障之前的store
操作发生于屏障之后的store
, 其他三个类似.
StoreLoad Barriers
同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence
),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。
拓展一下操作系统级别的实现指令
-
Windows
lock addl
-
Linux
sfence
lfence
mfence
详细可参见内存屏障详解
4. 指令重排的实例
贴一段代码, 来源Memory Reordering Caught in the Act, 使用Java
语言实现了一遍.
public class CatchReOrderClass {
private static int a, b, x, y;
public static void main(String[] args){
int i = 0;
for (;;) {
a = 0; b = 0;
x = 0; y = 0;
new Thread(() -> {
a = 1;
x = b;
}).start();
new Thread(() -> {
b = 1;
y = a;
}).start();
i++;
if (x == 0 && y == 0) {
System.out.println(i);
break;
}
}
}
}
执行次数足够多的情况下会结束循环, 此时表示发生了指令重排.
网友评论