从 DCL(双重检查锁定)谈 volatile 禁止指令重排序

引言

最近在看《Java并发编程的艺术》,看到双重检查锁定里谈到用 volatile 来解决创建对象时,指令重排序的问题,想了解清楚为什么 volatile 可以禁止指令重排序,结果得到了出乎意料的答案。

DCL(双重检查锁定)里发现的东西

下面是使用 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;
	}
}

关于双重锁定的相关知识不在这里展开了,我们要关注的是

private volatile static Instance instance;

以及

instance = new Instance();

instance = new Instance();创建了一个新对象,这一 行代码可以分解为如下的3行伪代码。

memory = allocate();  // 1:分配对象的内存空间 
ctorInstance(memory); // 2:初始化对象 
instance = memory;    // 3:设置instance指向刚分配的内存地址

我们知道,编辑器和处理器会进行代码优化,而其中重要的一点是会将指令进行重排序。
上边的代码经过重排序后可能会变为

memory = allocate();  // 1:分配对象的内存空间  
instance = memory;    // 3:设置instance指向刚分配的内存地址
					  // 注意:此时对象尚未初始化
ctorInstance(memory); // 2:初始化对象

这样会引起一些问题,打个比方,有一个线程A在创建对象,另一个线程B判断对象是否为空if (instance == null),如果指令被重排序,那么当A尚未初始化对象但已分配内存地址时,若B在做判断,会得到错误的结果。
所以,用 volatile 来禁止上述指令的重排序,使B的判断不会出错。
private volatile static Instance instance;
那这是怎么做到的呢,还是令人疑惑,我们继续探究。

volatile 的特性

先来看 valatile 的特性,可以看到并没有禁止指令重排序的相关特性。

  1. 可见性
    对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  2. 原子性
    对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
    不过,从上边的第一条特性可见性中,最后的,这三个字,我们其实已经能解决疑惑中的一部分,我们可以得到用 volatile ,使B的判断不会出错 ,B是一定在A创建对象完毕之后才能进行判断。那么还剩下一点volatile 来禁止上述指令的重排序

volatile 变量的汇编代码

这个时候,那么我们来看看在JIT汇编代码中mInstance = new Singleton()是怎样执行的:

0x01a3de0f:mov		$0x3375cdb0,%esi		;……beb0cd75 33
											;{oop('Singleton')}
0x01a3de14:mov		%eax,0x150(%esi)		;……89865001 0000
0x01a3de1a:shr		$0x9,%esi				;……c1ee09
0x01a3de1d:movb	$0x0,0x1104800(%esi)	;……c6860048 100100
0x01a3de24:lock	addl$0x0,(%esp)		;……f0830424 00
											;*put static instance
											;-
Singleton:getInstance@24

生成汇编码是lock addl $0x0, (%rsp), 在写操作(put static instance)之前使用了lock前缀,锁住了总线和对应的地址,这样其他的CPU写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。
加了 volatile之后,volatile在最后加了lock前缀,把前面的步骤锁住了,这样如果你前面的步骤没做完是无法执行最后一步刷新到内存的,换句话说只要执行到最后一步lock,必定前面的操作都完成了。那么即使我们完成前面两步或者三步了,还没执行最后一步lock,或者前面一步执行了就切换线程2了,线程B在判断的时候也会判断实例为空,进而继续进来由线程B完成后面的所有操作。当写完成后,释放锁,把缓存刷新到主内存。

注意

这里我们就可以看到此内存屏障只保证lock前后的顺序不颠倒,但是并没有保证前面的所有顺序都是要顺序执行的,比如我有1 2 3 4 5 6 7步,而lock在4步,那么前面123是可以乱序的,只要123乱序执行的结果和顺序执行是一样的,后面的567也是一样可以乱序的,但是整体上我们是顺序的,把123看成一个整体,4是一个整体 567又是一个整体,所以整体上我们的顺序的执行的,也达到了看起来禁止重排的效果

结论

volatile 没有禁止指令重排序,它只是在创建变量的过程中上锁,如果一个线程A在创建变量,另一个线程B尝试读取变量,那么,在A创建完毕之前,B是读不到变量的,这就避免了错误。
所以,我得到了一个我也不敢确定的结论:

书上错了,volatile 没有禁止指令重排序

就在《Java并发编程的艺术》的 3.8.3 小节:
当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。
如果哪位能解答一下我的疑惑,就再好不过了。

参考

  1. 《Java并发编程的艺术》——方腾飞 魏鹏 程晓明 著
  2. Volatile 以DCL失效谈内存屏障用来禁止指令重排序的原理——HJsirVolatile 以DCL失效谈内存屏障用来禁止指令重排序的原理——HJsir
  3. volatile变量详解——栋先生
原文地址:https://www.cnblogs.com/Sherlock-J/p/12925940.html