学霸题:happens-before


为什么要有 happens-before

happe-before 是 JMM 最核心的概念,对应 Java 程序员来说,理解 happens-before 是理解 JMM 的关键。

从 JMM 设计者的角度来看,可见性和有序性其实是互相矛盾的两点:

  • 一方面,对于程序员来说,我们希望内存模型易于理解、易于编程,为此 JMM 的设计者要为程序员提供足够强的内存可见性保证,专业术语称之为 “强内存模型”。

  • 而另一方面,编译器和处理器则希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(比如重排序)来提高性能,因此 JMM 的设计者对编译器和处理器的限制要尽可能地放松,专业术语称之为 “弱内存模型”。

由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是为了找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松,于是 happens-before 原则就横空出世了。

happens-before 是什么

happens-before 直译为 “先行发生”,具体定义如下:

1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。

上面的1)JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证!

我们举个例子:

// 以下操作在线程 A 中执行
i = 1; // a

// 以下操作在线程 B 中执行
j = i; // b

// 以下操作在线程 C 中执行
i = 2; // c

假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。

现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。

上面的2)是 JMM 对编译器和处理器重排序的约束原则。就像上面说的,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程和正确同步的多线程程序),编译器和处理器怎么优化都行。

JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是执行结果不能被改变。

我们举个例子:

int a = 1;   // A
int b = 2;  // B
int c = a + b; // C

根据 Happens-before 规则(下文会讲),上述代码存在 3 个 Happens-before 关系:

1)A happens-before B

2)B happens-before C

3)A happens-before C

可以看出来,在 3 个 happens-before 关系中,第 2 个和第 3 个是必需的,但第 1 个是不必要的。也就是说,虽然 A happens-before B,但是 A 和 B 之间的重排序完全不会改变程序的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。

这里说一下:JMM 把 happens-before 要求禁止的重排序分成了下面两类:

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM 对着两种不同性质的重排序,采取了不同的策略,如下:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

JMM 的设计示意图:

image-20220327112914624

简单说一下 happens-before 和 as-if-serial语义:

  • as-if-serial 语义保证单线程内程序的执行结果不会被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial 语义给编程单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

as-if-serial 语义和 happens-before 这么做都是为了在不改变程序执行结果的前提下,尽可能提高程序执行的并行度。

happens-before 规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下 Happens-before 规则, 这些就是 JMM 中“天然的” Happens-before 关系,这些 Happens-before 关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,JVM 可以对它们随意地进行重排序:

1)程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。

3)volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后。

4)线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。

6)线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupted() 方法检测到是否有中断发生。

7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8)传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

巨人的肩膀:

《Java 并发编程的艺术》

https://mp.weixin.qq.com/s?__biz=MzI0NDc3ODE5OQ==&mid=2247486777&idx=1&sn=f40be5ebda5958a37c08607daeb64d6c&chksm=e959d881de2e5197ffc3d222a0081c7c7e3cafd2180daaba83b013ac708ec7ceada81eaed3e0&scene=178&cur_album_id=1683346627601743872#rd


文章作者: Gtwff
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Gtwff !
  目录