本文翻译自http://tutorials.jenkov.com/java-concurrency/java-happens-before-guarantee.html,人工翻译,仅供学习交流。
Java happens-before-guarantee规则
Java happens-before-guarantee是一组规则,它决定了如何允许Java VM和CPU重新排列指令以提高性能。当一个变量值与主存同步或从主存同步时, happens-before-guarantee使得线程可以依赖同时同步的其他变量。Java happens-before-guarantee的中心是volatile变量和从同步块中的变量的访问。
本文将会提到Java volatile和Java synchronized提供的happens-before-guarantee,我不会解释所有相关的内容。这些术语在这里有更详细的介绍:
指令重排
如果指令不相互依赖,现代CPU并行执行指令。例如,以下两个指令并不相互依赖,因此可以并行执行:
a = b + c
d = e + f
下面的两条指令不易并行执行,因为第二条指令依赖于第一条指令的结果:
a = b + c
d = a + e
假设上面的两个指令是一个更大的指令集的一部分,如下所示:
a = b + c
d = a + e
l = m + n
y = x + z
指令可以像下面这样重新排序。这样CPU就可以并行执行至少前3条指令,只要第一个指令完成,它就可以开始执行第四个指令。
a = b + c
l = m + n
y = x + z
d = a + e
重新排序指令可以提高CPU中指令的并行执行速度。提高执行速度就是提升整体性能。
只要程序的语义不改变,Java虚拟机和CPU可以进行指令重新排序。只要这些指令是按照源代码中列出的顺序准确执行的,最终结果肯定是一样的。
多CPU计算机中的指令重排问题
指令重新排序在多线程和多CPU系统中将会有一些挑战,我将通过代码示例来说明这些问题。请记住,该示例只是为说明问题而专门构造的,代码示例是不推荐的。
假设两个线程协作,以尽可能快的速度在屏幕上绘制帧。一个线程产生帧,一个线程绘制帧。
两个线程需要通过某种通信机制交换帧。在下面的代码示例中,创建了一个通信机制的示例——FrameExchanger的Java类。
帧生产线程尽可能快地生成帧。画帧线程会以最快的速度画出这些帧。在画帧线程把帧画出来之前,生产者线程可能产生2帧,这种情况下,应该绘制最新的帧。我们不希望画帧线程落后于帧生产线程。在绘制前一帧之前,如果生产者线程有一个新的帧准备好,之前的帧会被新帧覆盖。换句话说,前一帧被“删除”了。
在帧生产线程产生一个新帧之前,画帧线程可能绘制好一个帧,并准备绘制一个新的帧。在这种情况下,我们希望绘图线程等待新帧。我们没有理由浪费CPU和GPU资源去重新绘制刚刚绘制过的相同帧。屏幕不会因此而改变,用户也不会从中看到任何新内容。
在FrameExchanger计算存储的帧数和取出的帧数,这样我们就可以知道掉了多少帧了。
下面是frameexchange的代码。注意:Frame类定义被省略了。为了理解frameexchange的工作原理,这个类是什么样并不重要。生成线程将持续调用storeFrame(),绘画线程将持续调用takeFrame()。
public class FrameExchanger {
private long framesStoredCount = 0:
private long framesTakenCount = 0;
private boolean hasNewFrame = false;
private Frame frame = null;
// called by Frame producing thread
public void storeFrame(Frame frame) {
this.frame = frame;
this.framesStoredCount++;
this.hasNewFrame = true;
}
// called by Frame drawing thread
public Frame takeFrame() {
while( !hasNewFrame) {
//busy wait until new frame arrives
}
Frame newFrame = this.frame;
this.framesTakenCount++;
this.hasNewFrame = false;
return newFrame;
}
}
请注意,storeFrame()方法中的三个指令似乎并不依赖于彼此。这意味着,如果Java VM或CPU认为指令重排是有利的,可以重新排序指令。但是,想象一下如果这些指令重新排序会发生什么,就像这样:
public void storeFrame(Frame frame) {
this.hasNewFrame = true;
this.framesStoredCount++;
this.frame = frame;
}
在frame字段被指定为引用新的frame对象之前,字段hasNewFrame将被设置为true的。如果画帧线程在takeFrame()方法的while-loop中等待,画帧线程可以退出while-loop,取旧的Frame对象。这将导致对旧帧的重新绘制,导致资源的浪费。
在这种特殊情况下,重新绘制旧帧不会导致应用程序崩溃或出错。它只会浪费CPU和GPU资源。在其他情况下,这样的指令重新排序可能会使应用程序出现故障。
Java volatile可见性保证
Java volatile关键字为写入和读取的时候提供了一些可见性保证,volatile变量会导致变量值与主存之间的同步。这种与主存之间的同步使得这个值对其他线程可见。因此有了可见性保证这一术语。
在本节中,我将简要介绍Java volatile可见性保证,并解释指令重新排序可能会破坏volatile的可见性保证。这就是我们为什么会有Java volatile happens-before-guarantee,在指令重排时设置一些限制,避免volatile可见性保证不会破坏。
Java volatile写可见性保证
当写入一个Java volatile 变量时,值是被直接写回主存中。对volatile变量进行写操作的线程可见的所有变量都会被同步到主存中。
为了展示Java volatile写可见性保证,下面有个示例:
this.nonVolatileVarA = 34;
this.nonVolatileVarB = new String("Text");
this.volatileVarC = 300;
这个例子包含了对volatile变量的两次写入,一次写入一个volatile变量。这个例子没有显示哪个变量被声明为volatile,因此,为了更清楚地说明,假设一个名为volatile的变量(实际上是字段)被声明为volatile。
当上面例子中的第三条指令写如volatile变量VolatileVarC时,这两个非易失性变量的值也将同步到主存。因为这些变量在写入volatile变量时对线程是可见的。
Java volatile读可见性保证
当您读取Java volatile的值时,该值保证直接从内存中读取。读取volatile变量的线程的所有变量也会从主存中刷新它们的值。为了说明Java volatile的读可见性保证,请看下面的例子:
c = other.volatileVarC;
b = other.nonVolatileB;
a = other.nonVolatileA;
注意第一条指令是读取volatile变量(other.volatileVarC)。当other.volatileVarC从主存中读取,other.nonVolatileB和other.nonVolatileA也可以从主存中读取。
Java volatile happens-before-guarantee规则
Java volitile happens-before-guarantee规则对volatile变量的指令重新排序设置一些限制。为了说明这个规则是必要的,让我们修改本教程前面的FrameExchanger类,使其具有hasNewFrame变量:
public class FrameExchanger {
private long framesStoredCount = 0:
private long framesTakenCount = 0;
private volatile boolean hasNewFrame = false;
private Frame frame = null;
// called by Frame producing thread
public void storeFrame(Frame frame) {
this.frame = frame;
this.framesStoredCount++;
this.hasNewFrame = true;
}
// called by Frame drawing thread
public Frame takeFrame() {
while( !hasNewFrame) {
//busy wait until new frame arrives
}
Frame newFrame = this.frame;
this.framesTakenCount++;
this.hasNewFrame = false;
return newFrame;
}
}
现在,当hasNewFrame变量设置为true时,frame和frameStoredCount也将同步到主存。此外,每次画帧线程读取内部while循环中的hasNewFrame变量时,frame和framesStoredCount也将从主存中刷新。即使是framesTakenCount也会从主存中更新。
如果Java VM重新排序了storeFrame()方法中的指令,就像这样:
// called by Frame producing thread
public void storeFrame(Frame frame) {
this.hasNewFrame = true;
this.framesStoredCount++;
this.frame = frame;
}
当第一个指令被执行时(因为hasNewFrame是volatile的),也就是在将新值赋给它们之前,现在framesStoredCount和frame字段将同步到主存。在将新值赋给frame变量之前,执行takeFrame()方法的画帧线程可能会退出while-loop。即使产生线程给frame变量赋了一个新值,不能保证这个值已经同步到主存,所以它对于画帧线程是可见的!
写入volatile变量Happens-before-guarantee规则
正如上文,在storeFrame()方法中重新排序指令可能会使应用程序出现故障。这就是需要对允许对volatile变量进行写操作的指令重新排序进行限制的原因了。
在写入一个volatile变量之前,保证其他的非volatile和volatile变量会被提前写入。
因为hasNewFrame是一个volatile变量,在storeFrame()方法中两个前面的指令不能重新排序在写hasNewFrame之后。
// called by Frame producing thread
public void storeFrame(Frame frame) {
this.frame = frame;
this.framesStoredCount++;
this.hasNewFrame = true; // hasNewFrame is volatile
}
前面的两个指令不是volatile变量,所以JVM可随意重排。就像下面:
// called by Frame producing thread
public void storeFrame(Frame frame) {
this.framesStoredCount++;
this.frame = frame;
this.hasNewFrame = true; // hasNewFrame is volatile
}
只要frame变量仍然在hasNewFrame变量之前被写入,这种重新排序不会破坏takeFrame()方法中的代码,整个程序仍然按照预期工作。
读取volatile变量Happens-before-guarantee规则
对于读取volatile变量,volatile变量也有一系列的Happens-before-guarantee规则。只是方向相反:
在读取非volatile和volatile变量之前,将会首先读取volatile变量。
我说的方向相反指的是对于volatile写操作,之前的所有写操作指令都在它之前。对于volatile读操作,所有的读操作都是在它之后。
看看下面的例子:
int a = this.volatileVarA;
int b = this.nonVolatileVarB;
int c = this.nonVolatileVarC;
指令2和3必须在第一个指令后执行,因为第一个指令读取一个volatile变量。也就是说,在读取两个非volatile变量之前,首先会读取volatile变量。
指令2和3可以随意重排,只要是在指令1之后执行。因此,这种重新排序是允许的:
int a = this.volatileVarA;
int c = this.nonVolatileVarC;
int b = this.nonVolatileVarB;
由于volatile读可见性保证,当this.volatileVarA从主存中读取,所有其他的变量此时对线程都是可见。因此,this.nonVolatileVarC和this.nonVolatileVarB也在同一时间从主存读入。读取volatileVarA的线程可以认为nonVolatileVarB和nonVolatileVarC是最新的。
如果最后两条指令中的任何一条重新排序在第一条volatile读指令之上,在该指令被执行时,对该指令的保证不会成立。这就是后面读取不能重新排序以显示在volatile变量的读取之上的原因。
关于takeFrame()方法,对volatile变量的第一次读取是在while循环中读取hasNewFrame字段。对volatile变量的第一次读取是在while循环中读取hasNewFrame字段。在这种特殊情况下,将任何其他读操作移到while循环之上也会破坏代码的语义,所以这些重新排序是不被允许的。
// called by Frame drawing thread
public Frame takeFrame() {
while( !hasNewFrame) {
//busy wait until new frame arrives
}
Frame newFrame = this.frame;
this.framesTakenCount++;
this.hasNewFrame = false;
return newFrame;
}
Java同步可见性保证
Java同步块提供了类似于Java volatile 变量的可见性保证。我将简要解释Java同步可见性保证。
Java同步进入可见性保证
当一个线程进入一个同步块时,所有对线程可见的变量都从主存中刷新。
Java同步退出可见性保证
当一个线程退出一个同步块时,所有对线程可见的变量都被写回主存。
Java同步可见性示例
看看ValueExchange类:
public class ValueExchanger {
private int valA;
private int valB;
private int valC;
public void set(Values v) {
this.valA = v.valA;
this.valB = v.valB;
synchronized(this) {
this.valC = v.valC;
}
}
public void get(Values v) {
synchronized(this) {
v.valC = this.valC;
}
v.valB = this.valB;
v.valA = this.valA;
}
}
注意set()和get()方法内部的两个同步块。请注意块是为何放在两个方法的最后和第一个位置的。
在set()方法中,方法末尾的同步块强制所有变量在更新后同步到主存。当线程退出同步时,将变量值刷新到主存。这就是它在方法中被放在最后的原因——确保所有更新的变量值都被刷新到主存。
在get()方法中,同步块被放置在方法的开头。当调用get()的线程进入synchronized块时,所有变量都从主存中重新读取。这就是为什么这个同步块被放在方法的开头——确保所有变量在读取之前都从主存中刷新。
Java Synchronized happens-before-guarantee规则
Java同步块在保证之前提供了两个事件:一个保证与同步块的开始有关,另一个保证与同步块的结束有关。我将在下面的部分中介绍这两个方面
Java同步块开始happens-before-guarantee规则
Java同步块的开始提供了可见性保证(在前面的教程中)。当一个线程进入一个同步块,所有对线程可见的变量都将从主存中读取(刷新)。为了能够维持这一保证,对指令重新排序的一系列限制是必要的。为了说明原因,我将使用前面显示的ValueExchange的get()方法:
public void get(Values v) {
synchronized(this) {
v.valC = this.valC;
}
v.valB = this.valB;
v.valA = this.valA;
}
如您所见,同步块位于方法的开头将保证所有的变量this.valA,this.valB和this.valC从主存中刷新(读入),接下来对这些变量的读取将使用最新的值。
在同步块开始之前,变量的任何读取都不能重新排序。如果一个变量在同步块开始之前重排,您将失去从主存中刷新变量值的保证。这就是下面的情况,不允许对指令重新排序:
public void get(Values v) {
v.valB = this.valB;
v.valA = this.valA;
synchronized(this) {
v.valC = this.valC;
}
}
Java同步块结束happens-before-guarantee规则
当线程退出同步块时,同步块的结束提供了可见性保证,所有改变的变量将被写回主存。为了能够维持这一保证,对指令重新排序的一系列限制是必要的。为了说明原因,我将使用前面显示的ValueExchange的set()方法:
public void set(Values v) {
this.valA = v.valA;
this.valB = v.valB;
synchronized(this) {
this.valC = v.valC;
}
}
当调用set()的线程退出synchronized块时,方法末尾的同步块将保证所有的改变的变量this.valA,this.valB和this.valC都将被写回(刷新)到主存中。
在同步块结束之后,变量的任何写入都不能重新排序。如果对变量的写入在同步块结束之后重新排序,您将失去将变量值写回主存的保证。这就是下面的情况,不允许对指令进行重新排序:
public void set(Values v) {
synchronized(this) {
this.valC = v.valC;
}
this.valA = v.valA;
this.valB = v.valB;
}