重排(Reordering)
在代码中,线程执行结果的可见性会导致另外一种叫做乱序执行的现象。首先,不可见
性表现在变量上并没有时间顺序性。举个例子,线程A先后变更了2个共享变量a
和b
,但b
的变化可能先于a
被其他线程观察到,这样就导致了在其他线程的角度来看,b
是先于a
被
改变的。
在Java的乱序执行例子中,有一段经典代码,即写后读的乱序执行导致的异常。代码如下:
|
|
上述代码中,2个线程分别执行2个任务,如果程序是顺序执行的,那么无论线程之间如何交错,
都不可能出现a == 0 && b == 0
成立的情况。但实际在执行一定时间后,必然会出现
a == 0 && b == 0
成立的情况。
实际上,软件和硬件都会产生乱序:
- 指令重排(Instruction Reordering)。这是编译器优化的结果,在不改变单线程执行行为
的前提下,它会将你所写的源代码编译成它认为最适合CPU执行的顺序。
load
和store
指令最 大的区别在于结果对后续指令的影响,一般而言load
需要关注它的执行结果,因为后面的指令 极有可能依赖于它。因此,将无关的写后读进行重排,重排后能够在执行load
的同时执行store
,这样load
指令的延迟就被覆盖了。 - 硬件重排序(Hardware Reordering),这就是我们上面所说的内存的可见性问题。对于CPU而言,
store
指令通常存储于Store Buffer中,因此在内存中的内容不会立刻被刷新。因此,对于load
指令而言,它会首先检查Store Buffer中是否存在该内存地址,如果存在则会直接从中获 取最新的值。然而,在并发环境下则会出现由于其它核中的Store Buffer未刷入内存导致的线程 见变量不可见问题。
内存屏障
由于重排序发生在软件和硬件2个层面,因此编译器和CPU提供了相应的方法去分别阻止在编译期 和运行时产生的重排问题。
编译器的指令重排针对不同的语言有不同的解决方式,例如对于Java而言,
就有JMM(Java Memory Model)帮助程序员理解和进行正确的多线程内存操作,而对于C语言,
也会有提供类似asm volatile("":::"memory")
的方式显式在代码中插入内存屏障。
对于CPU而言,它提供了硬件的内存屏障去阻止屏障前后的指令进行重排,而使用内存屏障的方式 大致有2种:
- 通常指令集中会提供指令(
mfence
)显式定义内存屏障,在该指令之前的所有指令都不会和 之后的指令发生重排 - 使用指令集中提供的同步指令一般会隐式的产生内存屏障的效果,即同步指令前后的指令不会 发生重排
volatile
关键字
在Java中,为了解决重排带来的问题就需要使用volatile
关键字。使用这个关键字修饰的变量,
在执行store
和load
相关的指令时,会插入相应的内存屏障从而保证变量在内存当中的可见性。
与volatile
相关的内存屏障有4个:
LoadLoad
. 禁止volatile
变量的load
操作与后续所有的load
操作重排LoadStore
. 禁止volatile
变量的load
操作与后续所有的store
操作重排StoreStore
. 禁止volatile
变量的store
操作与之前所有的store
操作重排StoreLoad
. 禁止volatile
变量的store
操作与之后所有的volatile
变量的load
操作重排
上面的4个屏障实际可以用代码很好的记住,StoreLoad
屏障与上述的写后读代码相对应。而另外3
个屏障则可以通过下面的代码进行说明。
|
|
上述代码中,目的是通过flag
变量来控制整体的一致性,也就是说flag
为true
时,
taskA
中所做的所有事情都需要对其他线程可见,因此需要使用StoreStore
屏障。而
对于taskB
而言,它需要保证在flag
检测为true
之后才去读写取其他变量的内容,以
保证自己所读写所使用的全部变量都是最新的,因此需要LoadLoad
和LoadStore
屏障。
(顺带一提,上述代码即使没有volatile
也不会出现问题,猜测原因可能是因为x86-64只会
对写后读的指令进行重排)
总结
volatile
作为一个Java中的一个关键字,最为直接的理解就是:
- 读取
volatile
变量一定能得到最新的值 - 写入
volatile
变量一定能被其它线程所见
但本质上它的使用关系到了软硬件层面上指令重排的问题。
对体系结构相关知识想要了解的可以参考下面的课程: