Java的Happens-Before 关系
先决条件:线程、同步块和易失性关键字
Happens-before 是一个概念、一种现象,或者只是一组定义编译器或 CPU 重新排序指令的基础的规则。 Happens-before 不是Java语言中的任何关键字或对象,它只是一个规则,以便在多线程环境中,周围指令的重新排序不会导致产生错误输出的代码。
如果这是您第一次遇到这个概念,这个定义可能看起来有点难以理解。要理解它,让我们首先了解对它的需求从何而来。
Java内存模型(也称为 JMM 模型)定义了在单线程或多线程环境中如何在线程和硬件之间进行数据存储和交换。
要记住的一些要点如下:
- 每个 CPU 内核都有自己的一组寄存器。
- 每个 CPU 内核一次可以执行多个线程。
- 每个 CPU 内核都有自己的一组缓存。
- 线程在 CPU 内核上执行,但其数据是从 RAM 存储和访问的,其中局部变量位于“线程堆栈”内,而对象位于“堆”内。
局部变量和对线程内部对象的引用存储在线程堆栈中,而对象本身存储在堆中。 CPU 上运行的线程对变量的请求遵循此路线RAM -> 缓存 -> CPU 寄存器。类似地,当对变量进行一些处理并更新其值时,更改将通过CPU 寄存器 -> 缓存 -> RAM。因此,在使用共享变量的多个线程时,当一个线程更新共享变量的值时,必须对寄存器进行更新,然后是缓存,最后是 RAM。当另一个线程需要读取该共享变量时,它会读取 RAM 中存在的值,该值通过缓存和寄存器传输。如果你从基本层面来看,如果读写操作被延迟,以至于在执行另一个读写之前正确的值没有存储在内存中,那么它可能会导致内存一致性错误。
当使用多个线程时,这个存储和检索过程可能会带来一些问题,例如:
- 竞争条件:两个线程共享某个变量,对其进行读写但不以同步方式进行,从而导致值不一致的情况。
- 更新可见性:一个线程对共享变量所做的更新可能对另一个线程不可见,因为该值尚未更新到 RAM。
这些问题通过使用同步块和易失性变量来解决。
指令重新排序
在编译或处理期间,编译器或 CPU 可能会重新排序指令以并行运行它们以提高吞吐量和性能。例如,我们有 3 条指令:
FullName = FirstName + LastName // Statement 1
UniqueId = FullName + TokenNo // Statement 2
Age = CurrentYear - BirthYear // Statement 3
编译器不能并行运行 1 和 2,因为 2 需要 1 的输出,但是 1 和 3 可以并行运行,因为它们彼此独立。因此编译器或 CPU 可以通过这种方式对这些指令进行重新排序:
FullName = FirstName + LastName // Statement 1
Age = CurrentYear - BirthYear // Statement 3
UniqueId = FullName + TokenNo // Statement 2
但是,如果在线程共享一些变量的多线程应用程序中执行重新排序,则可能会降低我们程序的正确性。
现在回想一下我们在上一节中讨论的两个问题,竞争条件和更新的可见性。 Java为我们提供了一些解决方案来处理这些类型的情况。我们将了解它们是什么,最后发生在该部分之前。
易挥发的
对于声明为 volatile 的字段/变量,
private volatile count;
- 对该字段的每次写入都将直接写入/刷新到主内存(即绕过缓存。)
- 对该字段的每次读取都是直接从主存储器读取的。
这意味着共享变量计数,无论何时由线程写入或读取,它将始终对应于其最近写入的值。这将防止竞争条件,因为现在线程将始终使用共享变量的正确值。此外,对共享变量的更新也将对读取它的所有线程可见,从而防止更新可见性问题。
volatile 还规定了一些更重要的点:
- 在写入 volatile 变量时,该线程可见的所有非 volatile 变量也将写入/刷新到主内存,即它们的最新值将与 volatile 变量一起存储在 RAM 中。
- 当您读取易失性变量时,该线程可见的所有非易失性变量也将从主内存中刷新,即它们的最新值将被分配给它们。
这称为volatile 变量的可见性保证。
所有这些看起来和工作正常,除非 CPU 决定重新排序您的指令,导致您的应用程序执行不正确。让我们明白我们的意思。考虑这个程序:
执行:
图中的以下代码用更简单的语言表达如下:
- 输入学生提交的新作业
- 然后收集那个新任务。
我们的目标是每次“只收集新准备的作业。所以提出相同的示例代码如下:
插图:
// Sample class
class ClassRoom {
// Declaring and initializing variables
// of this class
private int numOfAssgnSubmitted = 0;
private int numOfAssgnCollected = 0;
private Assignment assgn = null;
// Volatile shared variable
private volatile boolean newAssignment = false;
// Methods of this class
// Method 1
// Used by Thread 1
public void submitAssignment(Assignment assgn)
{
// This keyword refers to current instance itself
// 1
this.assgn = assgn;
// 2
this.numOfAssgnSubmitted++;
// 3
this.newAssignment = true;
}
// Method 2
// Used by Thread 2
public Assignment collectAssignment()
{
while (!newAssignment) {
// Wait until a new assignment is submitted
}
Assignment collectedAssgn = this.assgn;
this.numOfAssgnCollected++;
this.newAssignment = true;
return collectedAssgn;
}
}
- submitAssignment()方法由线程Thread1使用,该线程接受学生在assign字段中提交的作业,然后增加提交的作业计数,然后将 newAssignment 变量翻转为 true。
- collectAssignment()方法由线程 Thread2 使用,它等待直到新的赋值被提交,当 newAssignment 的值变为 true 时,它将提交的赋值存储到变量'collectedAssgn' 中,增加收集的赋值计数并翻转newAssignment 为 false,因为没有挂起的分配。最后,它返回收集的分配。
现在,volatile 变量newAssignment充当并发运行的 Thread1 和 Thread2 之间的共享变量。由于所有其他变量以及 newAssignment 本身对每个线程都是可见的,因此读写操作将直接使用主内存完成。
如果我们关注 submitAssignment() 方法,语句 1、2 和 3 是相互独立的,因为没有语句使用另一个语句,因此您的 CPU 可能会想“为什么不重新排序它们?”无论出于何种原因,它都可以提供更好的性能。因此,让我们假设 CPU 以这种方式重新排列了三个语句:
this.newAssignment = true; // 3
this.assgn = assgn; // 1
this.numOfAssgnSubmitted++; // 2
现在想一想,我们的目标是每次收集一个新的新赋值,但是现在由于语句 3 将 newAssignment 更新为 true,甚至在新的赋值存储到赋值之前,while 循环中的现在将退出 Thread2,并且 Thread2 的指令有可能在 Thread1 的其余指令之前执行,从而导致提交较旧的 Assignment 值对象。即使直接从主存储器中检索值,在这种情况下如果指令以错误的顺序执行也是无用的。
即使变量的可见性得到保证,指令的重新排序也可能导致不正确的执行。因此,关于 volatile 变量的可见性, Java引入了happens-before 保证。
发生在易失性之前
Happens-Before 状态关于重新排序。如下:
- 当对在写入 volatile 之前发生的任何对变量的写入重新排序时,将保留在写入 volatile 变量之前。
- 当重新排序位于读取某些非易失性或易失性变量之前的易失性变量的任何读取时,保证发生在任何后续读取之前。
在上述示例的上下文中,第一点是相关的。在写入 volatile(语句 3)之前发生的对变量(语句 1 和 2)的任何写入都将保留在写入 volatile 变量之前。这意味着禁止在语句 1 和语句 2 之前重新排序语句 3。这反过来又保证了 newAssignment 仅在将 Assignment 的新值分配给 ' assgn'后才设置为 true。这称为volatile 的发生在可见性保证之前。此外,语句 1 和 2 可以在它们之间重新排序,只要它们不在语句 3 之后重新排序。
同步块
在Java中的同步块的情况下:
- 当一个线程进入一个同步块时,该线程会从主存中刷新当时该线程可见的所有变量的值。
- 当线程退出同步块时,所有这些变量的值都将写入主内存。
Happens-Before 在同步块中
在同步块的情况下,发生在重新排序的状态之前:
- 在同步块退出之前发生的对变量的任何写入都保证在同步块退出之前保留。
- 在读取变量之前发生的同步块的入口被保证保留在对同步块入口之后的变量的任何读取之前。
现在更深入地了解Java发生之前关系的根源。让我们考虑一个场景,以便更好地理解它。
插图:
如果一个动作 'x' 对另一个动作 'y' 可见并且在另一个动作 'y' 之前被排序,那么hb(x, y)表示的两个动作之间存在先发生关系。
- 如果 x 和 y 是同一线程的操作,并且 x 在程序顺序中排在 y 之前,则 hb(x, y)。
- 从对象的构造函数的末尾到该对象的终结器的开头有一个发生之前的边缘。
- 如果一个动作 x 与后面的动作 y 同步,那么我们也有 hb(x, y)。
- 如果 hb(x, y) 和 hb(y, z),则 hb(x, z)。
Note: It is important to know that if we have hb(x, y) then it does not necessarily mean that x always occurs in the implementation before y, as long as the execution produces correct results, reordering of such actions is legal.
关于同步状态的更多规则如下:
- 监视器上的解锁发生在该监视器上的每个后续锁定之前。
- 对 volatile 字段的写入发生在对该字段的每次后续读取之前。
- 对线程的 start() 调用发生在已启动线程中的任何操作之前。
- 线程中的所有操作都发生在任何其他线程从该线程上的 join() 成功返回之前。
- 任何对象的默认初始化发生在程序的任何其他操作(默认写入除外)之前。
- 当一个语句调用 Thread.start 时,与该语句有先发生关系的每个语句也与新线程执行的每个语句有先发生关系。导致创建新线程的代码的效果对新线程是可见的。
- 当一个线程终止并导致另一个线程中的 Thread.join 返回时,则被终止线程执行的所有语句与成功加入之后的所有语句都具有happens-before 关系。线程中代码的效果现在对执行连接的线程可见。