在执行程序时,为了提高性能,编译器和处理器会对指令做一些优化,即指令重排序。但是,重排序也要有一定的标准和依据,否则,就会出现程序不受控制,结果与预期不一致。所以,重排序一定要保证,在重排序后,程序的逻辑不发生改变。保证语义,有 as-if-serial
;保证内存可见性, Java
编程语言规范中,也有 happens-before
规则对其进行限制。
指令重排
经典案例
先从一个经典案例来看下指令重排是否真的存在。
1 | public class MyTest { |
考虑到多线程,如果指令不重排,那么输出结果只会出现以下三种:
第 *** 次 (0,1)
第 *** 次 (1,1)
第 *** 次 (1,0)
而实际中,出现了下面的结果
第4403次 (1,0)
第4404次 (1,0)
第4405次 (0,1)
第4406次 (0,1)
第4407次 (0,1)
第4408次 (0,1)
第4409次 (0,0)…. 修改代码跑出 1,1 来 ….
第1069712次 (1,1)
出现了 0,0
这个结果,说明出现了指令重排,否则,至少会出现一个 1 才对。
as-if-serial语义
前文有说,重排序要保证程序的逻辑不变性。具体点就是,单线程环境下,程序的行为和结果不能发生变化。这就是所谓的 as-if-serial
语义。而要保证这点,对于有依赖关系的前后操作,不能进行重排序。
1 | int x = 1; |
所以上述代码,一二行可以进行重排,因为二者不存在任何依赖关系。而第三行不能重排至一二行之前,因为第三行依赖于前两行的数据结果。
异常情况下的重排序
那么,如果在满足 as-if-serial
的情况下进行了重排序,运行中发了异常呢?
1 | public static void main(String[] args) { |
x
和 y
并不存在依赖关系,所以可以进行指令重排,理论上打印结果会出现 x = 1
的情况,但是实没并不会这样。这是因为编译器在处理这种情况时,会在 catch
语句中插入补偿代码,以保证 as-if-serial
语义。
happens-before
上文提到,指令重排要保证 as-if-serial
语义,但是只对单线程有效。那么,如果在多线程情况下,如果不加限制,那么工作内存和主内存再加上重排序,一定会出现各种乱七八糟的问题。所以一定要有一个规则,来保证内存可见性。从 JDK5
起, JMM
就完善了 happens-before
来强调两个操作的顺序。
含义
happends-before
不要理解为谁谁早于谁谁发生,不然你会一直弄不清这个规则说的到底是什么。happens-before
说的是两个操作的可见性。
两个操作可以用 happens-before 来确定它们的执行顺序。如果一个操作 happens-before 于另一个操作,那么我们说第一个操作优先且对于第二个操作是可见的。
如果我们有 x 和 y 两个操作,我们用 hb(x, y) 来表示 x happens-before y
- 如果 x 和 y 是同一个线程的两个操作且在代码上 x 先于 y ,那么有 hb(x, y)
- 对象构造方法的最后一行指令
happens-before
于finalize()
方法的第一行指令。 - 如果操作 x 与随后的操作 y 构成同步,那么 hb(x, y)。
- hb(x, y) 和 hb(y, z),那么可以推断出 hb(x, z)
参见: The Java® Language Specification#Happens-before Order
下面的具体也是翻译自此
具体规则
上面所说可以简化成以下列表:
锁规则
对一个监视器的解锁操作 happens-before
于后续的对这个监视器的加锁操作
这条规则的意思是,解锁动作一定要让所有线程都知道,不然后续可能没办法加锁。
volatile 规则
对 volatile
属性的写操作 happens-before
于后续对这个属性的读操作
这条规则的意思是,volatile
属性在进行写操作之后,对应的值一定要对读可见,即读的一定是最新值。顺便一提,volatile
还有一个作用是禁止指令重排。
线程A | 线程B |
---|---|
1,修改共享变量值 | |
2,写 volatile 变量 | |
3,读 volatile 变量 | |
4,读共享变量值 |
在本条规则的限制下,3 和 4 读取的共享变量值一定是最新的。因为有hb(1,2),hb(2,3),hb(3,4),所以有hb(1,4)
线程 start 规则
如果 A 线程中调用了线程 B.start(),那么B.start() happens-before
于 B线程中所有操作
这条规则参见下面表格:
线程A | 线程B |
---|---|
1,修改共享变量 | |
2,执行 B.start() | |
3,执行对应逻辑 | |
4,读共享变量 |
此时,B 在操作 4 中读取的变量值也一定是最新的
线程 join 规则
如果 A 线程中调用了线程 B.join(),那么 B 线程中的操作 happens-before
于 A 线程调用B. join() 之后的任何语句
同样的,看下面的表格:
线程A | 线程B |
---|---|
1,执行 B.join() | 2,写共享变量 |
3, 执行完毕,终止 | |
4,B.join()成功返回 | |
5,读共享变量 |
此时,A 在操作5中读取到的共享变量的值也一定是最新的
初始化规则
对象的默认值初始化 happens-before
于程序中对它的其他操作
这点也就是说,在调用了一个对象的构造器之后,一定会给所有的属性赋值默认值。基本类型赋值基本类型的默认值,对象赋值为 null
,这点早于其它所有操作
注:上面的表格里的推导,都用到了含义中讲到的这两点:
程序次序规则:如果 x 和 y 是同一个线程的两个操作且在代码上 x 先于 y ,那么有 hb(x, y)。
传递性规则:即,如果有 hb(x,y) 和 hb(y,z) 则有 hb(x,z)。
而程序次序规则也不是说 x 一定要 y 之前执行,只是说可见,如果没有依赖,是可以重排序的。
总结
- 指令重排是有条件的,要保证重排的前后两个操作,不存在依赖关系
- 指令重排只能保证重排后,对单线程而言,行为和结果不发生变化,并不保证多线程的安全
- 为了保证
as-if-serial
,异常时会有补偿逻辑,catch
中有错误补偿代码 happens-before
强调的是可见性,而不是谁谁先于谁发生A happens-before B
指的是 A 操作的结果对 B 可见,如果两个操作不存在依赖,即 B 不需要知道 A 的结果,是可以进行指令重排序的