图 1:本文封面,点明主题「happen-before 的保证」。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。happen-before 是 Java 并发面试里最容易「背得出、讲不清」的概念——能背规则的人一抓一把,能说清它到底在保证什么的人不多。
想象面试现场,面试官写了一段经典代码:线程 A 先执行 x = 1,再执行 flag = true;线程 B 看到 flag 为 true 后去读 x。问:B 读到的 x 一定是 1 吗?
第一步,先答结论:没有同步,就不一定。
直觉上「先写 x 再置 flag,看到 flag 就能读到 x」天经地义,但 Java 语言规范第 17 章(内存模型)明确不给你这个保证。破坏它的有两股力量。
一是重排序。编译器和 CPU 为了性能会调整指令执行顺序,只要单线程内结果不变就行——「先写 x 再置 flag」在 A 眼里结果一样,交换顺序无人抗议;可在 B 眼里,flag 先亮、x 还没写,就出了灵异读数。二是工作内存缓存。每个线程可能把共享变量缓存在本地,A 写入的值不保证立刻对 B 可见,B 可能读到一个旧副本。
第二步,happen-before 到底是什么。
它是 JMM 给程序的一组「可见性与排序」承诺:如果操作 A happen-before 操作 B,那么 A 的结果对 B 可见,且 A 不允许被重排到 B 之后。注意它不是物理时间上的先后,而是规范层面的契约——满足了规则,JVM 才给你保证;一条规则都不满足时,编译器和 CPU 才可以自由重排。
最常用的几条规则:程序次序规则——同一线程内,前面的操作 happen-before 后面的操作;锁规则——对同一个锁的解锁 happen-before 后续的加锁;volatile 规则——对 volatile 变量的写 happen-before 后续对它的读;传递性——A happen-before B、B happen-before C,则 A happen-before C。此外还有线程启动(start 前的操作对新线程可见)与终止(join 返回者能看到被 join 线程的全部操作)等规则。
回到开头的代码:如果 flag 用 volatile 修饰,链条就接上了——程序次序规则让「x=1」happen-before「flag=true」,volatile 规则让「flag=true」happen-before「B 读到 flag」,程序次序规则再接上「B 读 flag」happen-before「B 读 x」,靠传递性得出:B 一旦看到 flag 为 true,x 必为 1。
图 2:左侧是事故现场,右侧是规则体系如何接上可见性链条。
第三步,用推演器把「不一定」坐实。
真实的重排序依赖硬件与 JIT,不易稳定复现,但可以枚举「规范允许的行为」:把两个线程的指令做全交错枚举,再比较「A 的写顺序受保护」与「A 允许重排」两个世界里 B 能读到什么。下面这份推演器用 Node.js 22.15.0 直接运行(node happens-before.js)即可:
'use strict';
// 线程 A:x = 1; flag = true 线程 B:r1 = flag;若 r1 为真则 r2 = x
// 枚举全部指令交错,统计 B 可能观察到的结果
function interleavings(a, b) {
if (!a.length) return [b.slice()];
if (!b.length) return [a.slice()];
return [
...interleavings(a.slice(1), b).map((t) => [a[0], ...t]),
...interleavings(a, b.slice(1)).map((t) => [b[0], ...t]),
];
}
function run(schedule) {
let x = 0, flag = false, r1 = false, r2 = null;
for (const op of schedule) {
if (op === 'A:x=1') x = 1;
else if (op === 'A:flag=true') flag = true;
else if (op === 'B:r1=flag') r1 = flag;
else if (op === 'B:r2=x' && r1) r2 = x;
}
return `r1=${r1 ? 1 : 0}, r2=${r2 === null ? '未读' : r2}`;
}
function outcomes(aOps) {
const bOps = ['B:r1=flag', 'B:r2=x'];
return [...new Set(interleavings(aOps, bOps).map(run))].sort();
}
console.log('A 顺序受保护(有 happen-before 的世界):');
console.log(' ', outcomes(['A:x=1', 'A:flag=true']).join(' | '));
console.log('A 允许重排(无 happen-before 的世界):');
console.log(' ', outcomes(['A:flag=true', 'A:x=1']).join(' | '));
实测输出如下:
A 顺序受保护(有 happen-before 的世界):
r1=0, r2=未读 | r1=1, r2=1
A 允许重排(无 happen-before 的世界):
r1=0, r2=未读 | r1=1, r2=0 | r1=1, r2=1
两个世界的差别一目了然:写顺序受保护时,「看到 flag 却读到旧 x」(r1=1, r2=0)这个结果根本不存在;允许重排时它就合法地冒了出来。happen-before 的全部意义,就是把这类结果从「允许的行为」里划掉。
易错点清单。
一,把 happen-before 当时间先后——它是可见性契约,不是时钟。二,以为「代码写在前面的语句一定先对其他线程生效」,没有规则护航时重排随时发生。三,只会背规则不会接链条——答题要能用程序次序 + volatile + 传递性把「x=1 对 B 可见」一步步推出来。四,以为加锁只为互斥——锁同时建立 happen-before 关系,解锁前的所有写对下一个持锁者可见。
回到面试现场,建议的答题节奏:先用 flag/x 的例子给出「不一定」的结论,拆出重排序与工作内存两股破坏力,再讲 happen-before 的契约本质与四条核心规则,最后用规则链条把开头的问题推到底。推理服务的配置热更新、降级开关、指标聚合全是多线程读写共享状态,这套推导就是你排查灵异 bug 的地图。
如果面试官追问「双重检查锁单例为什么必须给实例字段加 volatile」,你能用今天这套规则链条推出来吗?欢迎写出你的推导。