Volatile关键字是如何禁止重排序的?
先说结论:由于对volatile变量的复制操作之后会加上一句“addl$0x0,(%esp)”指令操作,而这个额外添加上的指令和后续对volatile操作的其他指令没什么关系,根本没有必要把这两个指令放在一起,没有优化的空间。
有个朋友在阅读周志明老师的《深入理解Java虚拟机》时,发现书中是这么解释Volatile关键字可以禁止重排序的:
那为何说它(volatile)禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必 须能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此,lock addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
讲的有点晦涩,初读的时候没有理解。百度别人的博客又都是各种内存屏障,感觉不方便理解。我在这里也写一下我的理解,方便这位朋友理解。
1.重排序原因
先上一段代码,解释一下为什么需要重排序:
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
// 假设以下代码在线程A中执行
volatile boolean initialized = false;
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
sleep();
}// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
从代码中可以看出来,A线程读取配置文件,B线程根据配置文件做事,两个线程通过initialized
变量进行同步。
//上面的代码看似没有问题,可如果initialized没有被volatile修饰
//那么实际上述代码会被优化变成下面这个样子:
boolean initialized = false;
initialized = true;
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
优化成这样的原因可以这样认为:在单线程执行的前提下,当CPU执行到initialized
变量初始化的时候,看到后面还有一次对这个变量的操作,而且这两个操作如果放在一起,并不会影响结果(比如把initialized = true;这句话和boolean initialized = false;放在一起,这个线程的结果会发生改变吗?并不会,initialized都会是true)那么为什么不把他们两个放在一起呢?毕竟放在一起操作,就不用再切换当前操作的对象了,两次都是对initialized
操作的,反之则需要从initialized
切换到别的,再切换回来,麻烦不?(此处由于忘记了组成原理中的知识,只能模糊的讲讲操作对象,其实应该是一些寄存器什么的,李姐万岁)
从上面的结论可以知道,重排序是为了更快的执行代码而做的优化,它会在保证结果可重现,不出错的前提下,将一些操作放在一起节约时间。这里的更快执行是有条件的,即两个可被重排序优化的指令操作的对象应该是同一个,那么才可以节约切换操作对象的时间。
2.重排序会导致什么问题
上面也讲了重排序是有条件的,它会保证单线程执行下结果的正确性,那么多线程呢?直觉告诉我们,这里面有问题:
取消先前代码中的initialized的volatile修饰,加入重排序后代码如下:
Map configOptions;
char[] configText;
// 假设以下代码在线程A中执行
boolean initialized = false;
initialized = true;
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
sleep();
}// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
考虑下面这种情况:线程A执行的时候,由于重排序,在第二部就将initialized = true;了,那么如果这是线程B执行了,那么B就认为已经读取了配置文件,就执行后面的,那必然有问题的。
3.Volatile关键字是如何禁止重排序的?
在引用一下书中的例子:
摘抄一下重点:
这句指令中的“addl$0x0,(%esp)”(把ESP寄存器的值加0)操作,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,通过这样一个操作,可让前面volatile变量的修改对其他处理器立即可见。
并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2 把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序 不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证 处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。所以在同一个处理器中,重排序 过的代码看起来依然是有序的。
lock addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
从上面可以得出我们的结论:由于对volatile变量的复制操作之后会加上一句“addl$0x0,(%esp)”指令操作,那么(重点来了)我们在这里还能重排序吗?肯定是不能的,原因是**这个额外添加上的指令和后续对volatile操作的其他指令没什么关系,根本没有必要把这两个指令放在一起,没有优化的空间。**举个例子就是:initialized = true;和addl$0x0,(%esp)这两个指令操作的对象不是同一个,就算强行凑到一起,也没有半点提升,那还大费周章的重排序啥呢?
最后也留一个问题:上图中双重校验锁中volatile的作用?第一个答出来的奖励你请我喝可乐!请我喝!!!