这篇的推导链条没问题,但要真正拿来落地,得补两个前提:volatile 规则只在“读确实观察到了那次写”时才成立,而且 hb 应该是你验证方案的工具,不是写代码时反推的依据。
Java 的 happens-before 规则到底怎么理解?
happens-before 并非时间先后,而是前操作结果对后操作可见且不被重排的承诺,JMM 用 JLS 的程序顺序、锁、volatile、线程启动终止、中断、终结器和传递性八条规则落地判断,缺少该关系只代表允许出错,不能以运行结果作为正
读完这篇你能得到一个可以落地判断的结论:happens-before 不是「谁先谁后」的时间概念,而是「前一个操作的结果,对后一个操作是否可见」的承诺。
第一步:先弄明白它到底在解决什么问题
现代 CPU 和 JIT 编译器都会重排序指令,只要不改变单线程的执行结果。这带来两个后果:可见性(线程 A 写的变量,线程 B 可能一直读到旧值)和有序性(代码顺序和实际执行顺序不一致)。
JMM(Java 内存模型)给出的答案就是 happens-before:如果 A happens-before B,那么 A 的结果对 B 一定可见,且 A 不会被重排到 B 之后。注意——没有 happens-before 关系,不代表一定出错,只代表"允许出错",这才是它最难理解的地方。
第二步:背下 JLS §17.4.5 的 8 条规则
这份清单直接来自《Java 语言规范》,是最权威的定义:
- 程序顺序规则:同一线程内,前面的操作 hb 后面的操作
- 监视器锁规则:对锁的 unlock hb 之后对同一把锁的 lock
- volatile 变量规则:对 volatile 字段的写 hb 之后对同一字段的读
- 线程启动规则:
Thread.start()hb 该线程中的所有操作 - 线程终止规则:线程中所有操作 hb 其他线程从
join()成功返回 - 中断规则:
interrupt()调用 hb 被中断线程检测到中断 - 终结器规则:构造函数结束 hb 该对象
finalize()开始 - 传递性:A hb B 且 B hb C,则 A hb C
前三条覆盖日常 90% 的场景,第 8 条是前面七条能串起来起作用的关键。
第三步:用 volatile 规则跑一遍验证
新建 VolatileDemo.java,代码和运行结果如下:
public class VolatileDemo {
static volatile boolean flag = false;
static int data = 0;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
data = 42; // A
flag = true; // B:volatile 写
});
Thread t2 = new Thread(() -> {
while (!flag) {} // C:volatile 读
System.out.println(data); // D
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
在 JDK 8 及以上执行:
javac VolatileDemo.java
java VolatileDemo
结果永远是 42。推导链条是:A 在程序顺序上 hb B,B 由 volatile 写规则 hb C,C hb D,靠传递性得到 A hb D——所以 data 的写入对 D 可见。
注意:把
flag的volatile去掉,程序大概率还是输出 42,但这是 JIT 还没优化或 CPU 缓存恰好同步的结果。这种"跑一百次都对"的假象最容易骗人,别拿运行结果当正确性证明。
第四步:用双重检查锁定看清"缺了会怎样"
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton(); // 1 分配内存 2 初始化 3 赋引用
}
}
return instance;
}
}
instance = new Singleton() 的三步可能被重排成 1→3→2。没有 volatile 时,另一个线程可能在判断 instance != null 为真后,拿到一个构造函数还没跑完的半成品对象。加上 volatile 后,第 3 步的 volatile 写与后续 volatile 读建立 hb,禁止这种重排。
注意:
synchronized已经保证了块内与块外的 hb 关系,所以这里的volatile不是为了互斥,纯粹是为了禁止 2、3 两步重排——这是最容易被忽略的一点。
第五步:遇到复杂场景用 JCStress 压测
手工循环测不出重排序,交给 OpenJDK 的 JCStress:
mvn archetype:generate -Dfilter=org.openjdk.jcstress:jcstress-archetype
cd jcstress-samples && mvn clean install
java -jar target/jcstress.jar -t VolatileTest
报告里出现 ACCEPTABLE 之外的 FORBIDDEN 结果,说明你的并发假设是错的。
小结
- happens-before 定义的是可见性与顺序约束,不是墙上时钟的先后。
- 核心就 8 条:程序顺序、锁、volatile、start、join、interrupt、finalize、传递性,前三条最常用。
- 没有 hb 关系 ≠ 一定出错,只表示 JMM 不保证,测试跑通也不代表写对了。
- 双重检查锁定的
volatile是为了防重排,不是为了互斥。 - 验证靠 JCStress,别靠
for循环。
原文链接:https://www.gj0.com/thread-1819.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。
全部回复 2
周末:这篇的推导链条没问题,但要真正拿来落地,得补两个前提:volatile 规则只在“读确实观察到了那次写”时才成立,而且 hb 应该是你验证方案的工具,不是写代码…
周末这两点补得很关键,尤其“读必须观察到那次写”,是 volatile 规则从规范条文落到代码可见性的闸门。
严格说,JLS 的表述是“volatile 写 happens-before 后续对同一字段的读”,这个“后续”是在同步顺序里讲的;落到你那段代码,就是 t2 的 while(!flag) 必须真的读到 t1 写入的 true,才能把 A 对 data 的写通过 B→C→D 传递过来。如果读端看到的是旧值或另一个写,就不能拿这条 volatile 边去证明前面普通字段的可见性。所以条件等待要用 while 而不是 if,本质就是逼着读端持续观察,直到观测到目标写。
第二点也同意:happens-before 是验证方案的工具,不是写代码时反推的依据。落地顺序应该是先定同步契约——谁发布、谁获取、用锁还是 volatile、要不要 join——再用 hb 链条检查有没有缺口。反过来为了凑 hb 规则去堆 volatile,很容易写出看着安全、实际漏掉观察条件或原子性的代码。
常见坑:volatile 不保证 i++ 这类复合操作原子性;