先行发生原则

Context

果然,『深入理解Java虚拟机』这本书读一遍是远远不够的,温故总能知新~今天要写的是先行发生原则,说来惭愧,,好像读第一遍的时候并没有太深刻的印象,昨晚再读的时候就拍大腿了:醍醐灌顶啊!!所以,今天还真得扮演搬运工的角色。

啥是先行发生原则

说到先行发生原则,还得再往前倒倒,说下 Java 内存模型的特征。
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这个3个特征来建立的。

  • 原子性(Atommicity): 由 Java 内存模型来直接保证原子性变量的操作,包括 read、load、use、assign、store、write, 大致可以认为基本数据类型的访问读写是具有原子性的(long、double 的非原子性协定除外)。如果场景需要更大范围的保证原子性,则需要 lockunlock 操作。反映到 Java 代码中就是同步块– synchronized 关键字。因此,所以 synchronized 块之间的操作也具备原子性。
  • 可见性(Visibility): Java 内存模型是通过在变量修改后将新值同步回主存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可行性的,无论是普通变量还是 volatile 变量都是如此。只不过,volatile 关键字能保证新值被立即同步回主内存,以及每次使用前从主内存刷新,普通变量则不能保证这一点。
    除此之外,synchronized 和 final 关键字也可以保证变量的可见性。同步快的可见性是由『对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)』这条规则获得的。而 final 关键字的可见性是指: 被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把 this 的引用传递出去^1,那么在其他线程中就能看见 final 字段的值。(最初以为是因为 final 修饰的变量不能被修改,所以导致所有线程读到的值都是一样的,不变的,,然鹅并不是[^2])。
  • 有序性(Ordering): Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义(Within-Thread As-If Serial Semantics)”,后半句是指“指令重排序”和“工作内存和主内存同步延迟”线程。
    volatile 关键字本身就含有禁止指令重排的语义,而 synchronized 则是由『一个变量在同一时刻只允许一条线程对其进行 lock 操作。』这条规则获得的,这条规则决定了持有同一个锁( lock 操作的变量)的两个同步块只能串行的进入。

好像 synchronized 关键字在需要这3种特性的时候都可以作为其中一种解决方案,于此同时其万能的属性导致了被我们程序员滥用。。
 
逼逼叨半天,还是没提到啥是先行发生原则。。别急,马上。。
如果 Java 内存模型中所有的有序性都靠 volatile 和 synchronized 来完成,那么有一些操作将变得非常繁琐,然鹅我们在写代码的过程中并没有感受到这一点,这就是因为『先行发生原则』。它是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生是在 Java 内存模型中定义两项操作之间的偏序关系,如果说操作 A先行发生于操作 B,则说明发生操作B之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

1
2
3
4
5
6
7
8
// 以下操作在线程 A 中执行
i = 1;

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

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

👆 假设线程 A 中的操作”i = 1”先行发生于线程 B 的操作”j = i”, 那么可以确定的是线程 B 的操作执行后,变量 j 一定等于 1,得出这个结论的依据有两个:

  1. 根据先行发生原则,”i = 1”的结果可以被观察到;
  2. 线程 C 还没”登场”, 线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和线程 B 之间的先行发生关系,而线程 C 出现在线程 A 和线程 B 的操作之间,但是线程 C 与线程 B 没有先行发生关系,那 j 的值就无法确定了。

下面是 Java 内存模型中一些”天然”的先行发生关系,它们无须任何同步器的协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则中推导出来的话,他们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序原则(Program Order Rule): 在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于写在后面的操作。准确来说是程序流顺序而不是代码顺序,因为要考虑分支和循环。
  • 管程锁定原则(Monitor Lock Rule): 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而”后面”是指时间上的先后顺序。
  • volatile 变量规则(Volatile Variable Rule): 对于一个volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的”后面”同样是指时间上的先后。
  • 线程启动规则(Thread Start Rule): Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule): 线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止运行。
  • 线程中断原则(Thread Interruption Rule): 线程中的所有操作都先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结原则(Finalizer Rule): 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()[^3]法的开始。
  • 传递性(Transitivity): 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C。

举个栗子:👇

1
2
3
4
5
6
7
8
9
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 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来满足先行发生原则。
通过上面的例子,,我们可以得出一个结论:**一个操作”时间上的先发生”不代表这个操作会是”先行发生”,那如果一个操作”先行发生”是否就能对导出这个操作必定是”时间上的先发生”呢?很遗憾,同样也不能,一个典型的例子就是”指令重排序”**。👇

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

👆根据程序次序规则,int i = 1;先行发生于int j = 2;但是int j = 2;的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程中是无法感知到这点的。

[^2]: Java的Final 关键字的内存语义

[^3]: Java 禁止使用 finalizer 方法