深蓝黑背景品牌封面:左侧大标题「happen-before」,副标题「Java 内存模型到底在保证什么」;右侧原创装饰——两条代表线程的平行轨道上各有时序事件点,中间一道内存屏障竖墙,一条虚线箭头从线程 A 的写事件指向线程 B 的读事件,表示跨线程的可见性保证;左上标注「经典题解析 · Java 后端」,右上标注「Day 02 · 09-30」 图 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。

问题与规则对照图:左侧是没有同步时的典型事故——线程 A 先写 x=1 再置 flag=true,线程 B 看到 flag 为 true 时仍可能读到 x=0,因为编译器与 CPU 会重排序,且工作内存缓存导致写入不及时可见。右侧是 happen-before 的规则体系:程序次序规则(同一线程内按代码顺序)、锁规则(解锁先于后续加锁)、volatile 规则(写先于后续读)、传递性(A 先于 B、B 先于 C 则 A 先于 C);其语义是「前一个操作的结果对后一个可见,且禁止跨越重排」。底部澄清:happen-before 不是时间上的先后,而是 JVM 给出的可见性与排序保证;不满足任何规则时,编译器和 CPU 才可以自由重排 图 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」,你能用今天这套规则链条推出来吗?欢迎写出你的推导。