【Java】多线程 - Happens-before 原则

Posted by 西维蜀黍 on 2019-02-27, Last Modified on 2021-09-21

除了从应用层面保证目标代码段执行的有序性(Ordering)外,JVM 还通过被称为 happens-before 原则隐式地保证单线程执行的有序性。

先行发生原则(Happens-before 原则)

先行发生原则(happens-before 原则)非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以解决在并发环境下,两操作之间是否可能存在冲突的所有问题。

先行发生(happens-before)是 Java 内存模型中定义的两项操作之间的偏序关系(partial order),如果线程 A 执行的操作 A 先行发生于线程 B 中执行的操作 B,那么操作 A 产生的影响能够被操作 B 观察到。“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

例子

举个例子:

///以下操作在线程A中执行
i = 1;
//以下操作在线程B中执行
j = i;
//以下操作在线程C中执行
i = 2;

假设线程 A 中的操作 i=1 先行发生于线程 B 的操作 j=i ,那么就可以确定在线程 B 的操作执行后,j 一定等于 1 。因为,根据先行发生原则,i=1 的结果可以被线程 B 观察到。

现在保持线程 A 先行发生于线程 B,假设线程 C 的执行发生在 A 与 B 之间,但是线程 C 与 B 没有先行发生关系。那么 j 会等于多少呢?

答案是不确定。因为线程 C 对变量 i 的影响可能会被 B 观察到,也可能不会。换句话说,对于多线程执行的情况,一个线程最先被执行,但这并不意味着这个线程执行的结果能后后执行的线程锁观察到。

本质上,是因为两者之间没有先行发生关系。这时候线程 B 就存在读取到过期数据的风险,因而不具备多线程安全性。

总结

总结来说,多线程之间的执行在时间上具有先后执行顺序,并不意味着先执行线程的执行结果能够被后执行线程所观察到(因此存在变量的可见性问题)。

Happens-before规则的直接作用是约束指令重排序,从而保证同步,确定了线程的安全性

”天然的“ Happens-before关系

Happens-before是JMM中定义两项操作的偏序关系,如果操作A和操作B满足Happens-before,比如操作A先行发生于操作B,那么操作B一定能看到操作A的影响。

如果仅靠synchronized和volatile来保证Java内存模型的有序性,那么我们日常代码的实现将无法想象。下面是JMM中天然的Happens-before关系,这些先行发生关系无需任何同步器协助就已经存在:

  • 程序次序规则(Program Order Rule) :一个线程内,按照代码顺序,书写在前面的操作,先行发生于(happens-before)书写在后面的操作。
  • 管程锁定规则(Monitor Lock Rule):一个管程(monitor)上的 unLock 操作,先行发生于(happens-before)后面对同一个管程的 lock 操作。
  • volatile 变量规则(volatile Variable Rule):对一个 volatile 变量的写操作,先行发生于(happens-before)后面对这个变量的读操作。
  • 传递规则(Transitivity) :如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  • 线程启动规则(Thread Start Rule) Thread对象的start()方法,先行发生于(happens-before)此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule) 线程中的所有操作,都先行发生于(happens-before)对此线程的终止检测。
  • 线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用,先行发生于(happens-before)被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule) 一个对象的初始化完成(构造函数执行结束),先行发生于(happens-before)它的finalize()方法的开始。

Java语言无须任何同步手段保障,就能成立的先行发生规则,就只有上面这些了。

如果两个操作之间的关系不在此列,或者无法从上列规则推导出来的话,它们就没有顺序性保障,换句话说,虚拟机可以对它们进行随意地重排序。

程序次序规则(Program Order Rule)

对于程序次序规则(Program Order Rule) 来说,就是一段程序代码的执行,在单个线程中看起来是有序的。

注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。

虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,因为JVM只会对不存在数据依赖性的指令进行重排序

管程锁定规则(Monitor Lock Rule)

如果想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么A和B之间必须满足 Happens-Before 关系。如果两个操作之间缺乏 Happens-Before 关系,那么JVM可以对它们任意地重排序。举一个简单的例子,当两个线程使用同一个锁进行同步时,它们之间的 Happens-Before 关系如下:

总结

单线程

对于不存在数据依赖的两个操作,如果能基于上述规则推导出某两个操作之间具有“先行发生”关系,则JVM会保证这两个操作执行上的先后顺序(而不会进行指令重排序)。

对于存在数据依赖的两个操作,由于天然地不会进行指令重排序,因此自然也具有“先行发生”关系,因而JVM也会保证这两个操作执行上的先后顺序。

多线程

当两个操作执行时具有时间上的先后执行顺序时,而且能基于上述规则推导出某两个操作之间具有“先行发生”关系,则才能保证先执行操作的可见性。

“时间上的先后顺序”与“先行发生”之间的区别

以下示例将会演示如何使用这些规则去判定操作间是否具有顺序性,对于读写共享变量的操作来说,就是线程是否安全。

我们还可以从下面这个例子,感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。

private int value = 0;

public void setValue(int value){
    this.value = value;
}

public int getValue(){
    return value;
}

代码清单演示的是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

分析

依次分析一下先生发生原则中的各项规则:

  • 由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;
  • 由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则在这里也不适用;
  • 由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;
  • 后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系;
  • 因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起。

因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中的getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

修复

至少有两种比较简单的方案可以修复这个问题:

  • 把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;
  • 把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volalite关键字的使用场景,这样就可以套用volatile变量规则。

结论

通过上面的例子,我们可以得出结论:

一个操作“时间上的先发生”不代表这个操作会是“先行发生”,同样一个操作“先行发生”也不能推导出这个操作必定是“时间上的先发生”,一个典型的例子就是指令重排序。

// 以下操作在同一个线程中执行
i = 1;
j = 2;

如上代码清单所示,两条赋值语句在同一个线程中执行,根据次序规则,“int i = 1”的操作先行发生于“int j = 2”,但是“int j = 2”的代码完成可能先被处理器执行,这并不影响先行发生规则的正确性,因为我们在这条线程之中没有办法感知到这点。

综上所述:

时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

Reference