Jakob Jenkov 2023-08-02
Java 的 happens-before(先行发生)保证 是一套规则,用于规范 Java 虚拟机(JVM)和 CPU 在为了性能优化而对指令进行重排序时的行为。该保证使得线程能够依赖变量值何时从主内存同步到工作内存(或反之),以及在同步某个变量的同时,还有哪些其他变量也已被同步。
Java 的 happens-before 保证主要围绕对 volatile 变量的访问,以及在 synchronized 块内对变量的访问展开。
本教程将介绍 Java volatile 和 synchronized 提供的 happens-before 保证,但不会深入讲解这两个关键字的所有细节。相关内容可参考以下教程:
指令重排序(Instruction Reordering)
现代 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
这些指令可以被重排为:
a = b + c
l = m + n
y = x + z
d = a + e
这样,CPU 至少可以并行执行前三条指令;一旦第一条完成,即可开始执行第四条。
如你所见,指令重排序能提升 CPU 指令的并行执行能力,从而提高性能。
只要程序的语义不变(即最终结果与源代码顺序执行一致),JVM 和 CPU 就允许进行指令重排序。
多 CPU 计算机中的指令重排序问题
在多线程、多 CPU 系统中,指令重排序会带来一些挑战。下面通过一个代码示例来说明这些问题(请注意:此示例仅为说明问题,并非推荐实践)。
假设有两个线程协作尽可能快地在屏幕上绘制帧:
- 一个线程负责生成帧(生产者)
- 另一个线程负责绘制帧(消费者)
这两个线程需要通过某种通信机制交换帧。下面是一个名为 FrameExchanger 的通信类示例:
生产者线程尽可能快地生成帧,消费者线程则尽可能快地绘制这些帧。
有时,生产者可能在消费者绘制完前一帧之前就生成了两帧。此时,只应绘制最新的一帧——我们不希望消费者落后于生产者。如果生产者在上一帧尚未绘制时就生成了新帧,则直接覆盖旧帧(即“丢弃”旧帧)。
有时,消费者可能刚绘制完一帧就准备好绘制下一帧,但生产者尚未生成新帧。这时,消费者应等待新帧到来,而不是重复绘制相同的帧(这会浪费 CPU 和 GPU 资源)。
FrameExchanger 类统计已存储和已取出的帧数量,以便了解有多少帧被丢弃。
以下是 FrameExchanger 的代码(Frame 类定义省略,因其结构不影响理解):
public class FrameExchanger {
private long framesStoredCount = 0;
private long framesTakenCount = 0;
private boolean hasNewFrame = false;
private Frame frame = null;
// 由帧生成线程调用
public void storeFrame(Frame frame) {
this.frame = frame;
this.framesStoredCount++;
this.hasNewFrame = true;
}
// 由帧绘制线程调用
public Frame takeFrame() {
while (!hasNewFrame) {
// 忙等待,直到新帧到达
}
Frame newFrame = this.frame;
this.framesTakenCount++;
this.hasNewFrame = false;
return newFrame;
}
}
注意 storeFrame() 方法中的三条指令看起来互不依赖,因此 JVM 或 CPU 可能认为可以重排序它们以提升性能。
但如果指令被重排如下:
public void storeFrame(Frame frame) {
this.hasNewFrame = true; // 先设为 true
this.framesStoredCount++;
this.frame = frame; // 后赋值
}
那么字段 hasNewFrame 会在 frame 字段被赋值之前就被设为 true。这意味着,如果绘制线程正在 takeFrame() 的 while 循环中等待,它可能会提前退出循环,并读取到旧的 Frame 对象,导致重复绘制旧帧,浪费资源。
虽然在此例中重复绘制旧帧不会导致程序崩溃,但在其他场景下,此类重排序可能导致程序逻辑错误。
Java volatile 的可见性保证
Java 的 volatile 关键字提供了一些可见性保证:对 volatile 变量的写入会立即同步到主内存,读取则直接从主内存加载。这种同步机制确保了变量值对其他线程可见。
volatile 写入的可见性保证
当你写入一个 volatile 变量时,该值会直接写入主内存。此外,当前线程可见的所有变量也会同步到主内存。
示例:
this.nonVolatileVarA = 34;
this.nonVolatileVarB = new String("Text");
this.volatileVarC = 300; // volatileVarC 是 volatile 变量
当第三行写入 volatileVarC 时,前两个非 volatile 变量的值也会被同步到主内存。
volatile 读取的可见性保证
当你读取一个 volatile 变量时,该值会直接从主内存读取。此外,当前线程可见的所有变量也会从主内存刷新其值。
示例:
c = other.volatileVarC; // 读取 volatile 变量
b = other.nonVolatileB;
a = other.nonVolatileA;
当读取 other.volatileVarC 时,nonVolatileB 和 nonVolatileA 也会从主内存重新加载。
Java volatile 的 Happens-Before 保证
为了防止指令重排序破坏 volatile 的可见性保证,Java 引入了 volatile happens-before 保证,对 volatile 变量周围的指令重排序施加限制。
修改后的 FrameExchanger(使用 volatile)
将 hasNewFrame 声明为 volatile:
public class FrameExchanger {
private long framesStoredCount = 0;
private long framesTakenCount = 0;
private volatile boolean hasNewFrame = false; // volatile
private Frame frame = null;
public void storeFrame(Frame frame) {
this.frame = frame;
this.framesStoredCount++;
this.hasNewFrame = true;
}
public Frame takeFrame() {
while (!hasNewFrame) { /* 忙等待 */ }
Frame newFrame = this.frame;
this.framesTakenCount++;
this.hasNewFrame = false;
return newFrame;
}
}
现在,当 hasNewFrame = true 时,frame 和 framesStoredCount 会同步到主内存;每次消费者线程读取 hasNewFrame 时,也会从主内存刷新其他变量。
但如果 JVM 将 storeFrame() 重排为:
public void storeFrame(Frame frame) {
this.hasNewFrame = true; // 先设 volatile 为 true
this.framesStoredCount++;
this.frame = frame; // 后赋值
}
那么在 hasNewFrame 被设为 true 时,frame 还未被赋值!此时消费者可能读到未初始化或旧的 frame,导致错误。
volatile 写入的 Happens-Before 保证
在写入 volatile 变量之前的所有写操作(无论是否 volatile),都必须在该 volatile 写入之前完成。
因此,在 storeFrame() 中,前两条写入不能被重排到 hasNewFrame = true 之后。
但前两条之间可以自由重排(因为它们都不是 volatile):
this.framesStoredCount++;
this.frame = frame;
this.hasNewFrame = true; // OK
这不会破坏语义,因为 frame 仍在 hasNewFrame 之前被赋值。
volatile 读取的 Happens-Before 保证
对 volatile 变量的读取,必须发生在其后所有读取(无论是否 volatile)之前。
示例:
int a = this.volatileVarA; // volatile 读取
int b = this.nonVolatileVarB;
int c = this.nonVolatileVarC;
第二、三条读取不能被重排到第一条之前。但它们彼此之间可以重排:
int a = this.volatileVarA;
int c = this.nonVolatileVarC; // OK
int b = this.nonVolatileVarB;
由于 volatile 读取会刷新所有可见变量,因此后续读取能获得最新值。
在 takeFrame() 中,while (!hasNewFrame) 是对 volatile 的读取,因此后续对 frame 等的读取不会被提前,保证了正确性。
Java synchronized 的可见性保证
synchronized 块也提供类似 volatile 的可见性保证。
synchronized 进入时的可见性保证
当线程进入 synchronized 块时,所有可见变量都会从主内存刷新。
synchronized 退出时的可见性保证
当线程退出 synchronized 块时,所有可见变量都会写回主内存。
示例:ValueExchanger
public class ValueExchanger {
private int valA, valB, valC;
public void set(Values v) {
this.valA = v.valA;
this.valB = v.valB;
synchronized(this) {
this.valC = v.valC;
} // 退出时,valA/B/C 都写回主内存
}
public void get(Values v) {
synchronized(this) {
v.valC = this.valC;
} // 进入时,所有变量从主内存刷新
v.valB = this.valB;
v.valA = this.valA;
}
}
synchronized 块的位置很关键:
set()中放在最后,确保所有修改在退出时同步到主内存。get()中放在最前,确保读取前从主内存获取最新值。
Java synchronized 的 Happens-Before 保证
synchronized 提供两个 happens-before 保证:
1. synchronized 块开始的 Happens-Before 保证
进入 synchronized 块前,不能有对该块内所用变量的读取被重排到块内之后。
在 get() 方法中,如果将读取 valB 和 valA 重排到 synchronized 块之前:
// ❌ 不允许的重排
v.valB = this.valB;
v.valA = this.valA;
synchronized(this) { ... }
那么 valB 和 valA 就无法获得主内存的最新值,破坏可见性保证。
2. synchronized 块结束的 Happens-Before 保证
在 synchronized 块内或之前的写入,不能被重排到块结束后。
在 set() 方法中,如果将 valA 和 valB 的写入放到 synchronized 块之后:
// ❌ 不允许的重排
synchronized(this) { this.valC = v.valC; }
this.valA = v.valA;
this.valB = v.valB;
那么 valA 和 valB 的修改就不会在退出同步块时写回主内存,其他线程可能看不到更新。