JUC之Java并发基础篇——指令重排与happens-before

​ 在执行程序时,为了提高性能,编译器和处理器会对指令做一些优化,即指令重排序。但是,重排序也要有一定的标准和依据,否则,就会出现程序不受控制,结果与预期不一致。所以,重排序一定要保证,在重排序后,程序的逻辑不发生改变。保证语义,有 as-if-serial ;保证内存可见性, Java 编程语言规范中,也有 happens-before 规则对其进行限制。

指令重排

经典案例

​ 先从一个经典案例来看下指令重排是否真的存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MyTest {
private static int x = 0, y = 0;
private static int a = 0, b =0;

public static void main(String[] args) throws InterruptedException {
int i = 0;
while(true) {
i++;
x = 0; y = 0;
a = 0; b = 0;

Thread one = new Thread(() -> {
a = 1;
x = b;
});

Thread two = new Thread(() -> {
b = 1;
y = a;
});

one.start();two.start();
one.join();two.join();

String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}

考虑到多线程,如果指令不重排,那么输出结果只会出现以下三种:

第 *** 次 (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
2
3
int x = 1;
int y = 2;
int z = x + y;

​ 所以上述代码,一二行可以进行重排,因为二者不存在任何依赖关系。而第三行不能重排至一二行之前,因为第三行依赖于前两行的数据结果。

异常情况下的重排序

​ 那么,如果在满足 as-if-serial 的情况下进行了重排序,运行中发了异常呢?

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
int x = 1;
int y = 2;
try {
x = 3;
y = 2 / 0;
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("x = " + x);
}
}

xy 并不存在依赖关系,所以可以进行指令重排,理论上打印结果会出现 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-beforefinalize() 方法的第一行指令。
  • 如果操作 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 的结果,是可以进行指令重排序的