JMM 与 happens-before:多线程读写为什么「看不到」新值
理解 Java 内存模型,重点不是「主内存和工作内存」那张图,而是:两个线程读写同一个变量时,Java 保证了什么、没保证什么,以及怎样让结果变得可推理。
先看一段在 JDK 21 上可以稳定复现的代码。主线程一秒后把 stop 设为 true,工作线程却永远停不下来:
// JDK 21,HotSpot 默认参数
public class StopFlag {
private static boolean stop; // 故意不加 volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (!stop) {
spins++;
}
System.out.println("worker exits after " + spins + " spins");
});
worker.start();
TimeUnit.SECONDS.sleep(1);
stop = true;
System.out.println("main set stop = true");
worker.join(TimeUnit.SECONDS.toMillis(3));
System.out.println("worker alive after 3s: " + worker.isAlive());
System.exit(0);
}
}实测三组结果(JDK 21.0.5,每种方式运行 3 次,结果相同):
| 运行方式 | worker alive after 3s |
|---|---|
| 默认参数(JIT 开启) | true,循环没有退出 |
java -Xint(只解释执行) | false,正常退出 |
把字段改为 volatile boolean stop | false,正常退出 |
这不是 JVM 的 bug。这段代码存在数据竞争(data race),Java 语言规范对它的结果不做任何可见性承诺,JIT 编译器把循环里的 stop 当成不变量读一次就合法。理解这件事,就理解了 JMM 要回答的问题。
一、先说结论
- JMM 不是运行时内存区域。 堆、栈、方法区回答的是「数据放在哪」;Java 内存模型(Java Memory Model,JMM)回答的是「多个线程读写共享变量时,哪些读必须看到哪些写」。它定义在 JLS 第 17.4 节。
- 核心工具是 happens-before 关系。 如果操作 A happens-before 操作 B,那么 B 一定能观察到 A 的效果。它是一种语义上的偏序约束,不等同于墙上时钟的先后。
- 没有 happens-before 关系的读写,结果不可推理。 物理上先写了,另一个线程也可能读到旧值,上面的例子就是证据。
- 建立 happens-before 的手段:锁、
volatile、线程启动与join、final字段的正确构造,以及java.util.concurrent中各组件文档化的保证。
二、happens-before 的规则
java.util.concurrent 包文档的「Memory Consistency Properties」一节列出了语言层面的五条规则,外加一条传递性:
| 规则 | 含义 | 典型用途 |
|---|---|---|
| 程序顺序 | 同一线程内,前面的动作 happens-before 后面的动作 | 单线程推理 |
| 监视器锁 | 对同一个监视器的解锁 happens-before 后续的加锁 | synchronized 保护的读写 |
| volatile | 对 volatile 字段的写 happens-before 后续对它的读 | 状态标记、引用发布 |
| 线程启动 | 调用 start() happens-before 新线程中的任何动作 | 启动前准备好的数据,新线程一定能看到 |
| 线程 join | 线程中的所有动作 happens-before 其他线程从 join() 成功返回 | 等子线程算完再读结果 |
| 传递性 | A → B 且 B → C,则 A → C | 组合上面的规则 |
回到开头的例子:主线程写 stop 与工作线程读 stop 之间,上面哪一条都不满足,所以规范不要求读到 true。加上 volatile 后命中第三条,问题消失。
用一个 volatile 字段发布其他普通字段
传递性经常被忽略,但它让下面这段代码是正确的:
final class ResultBox {
private int result; // 普通字段
private volatile boolean ready;
void produce() { // 只在一个线程里调用
result = 42; // (1) 普通写
ready = true; // (2) volatile 写
}
int consume() { // 在另一个线程里调用
if (ready) { // (3) volatile 读
return result; // (4) 普通读
}
throw new IllegalStateException("not ready");
}
}推理链:(1) → (2) 是程序顺序;(2) → (3) 是 volatile 规则;(3) → (4) 是程序顺序。由传递性得到 (1) → (4),所以读到 ready == true 时一定能看到 42。
ready 在两个线程之间建立同步点,普通字段 result 借助传递性被安全发布这里 result 并没有「变成」volatile;是 ready 在两个线程之间建立了一个同步点。这个前提很重要:只有一个线程写。如果多个线程都会调用 produce(),写与写之间就没有保护了。
三、三个性质:原子性、可见性、有序性
很多资料把并发问题归纳为三类,这个框架可以保留,但要知道每类由什么机制保证:
| 手段 | 可见性(读到谁写的值) | 有序性(重排能否被观察到) | 原子性(复合操作能否被拆开) |
|---|---|---|---|
| 锁 | ✓ | ✓ | ✓ |
volatile | ✓ | ✓(与该字段相关的部分) | ✗ |
Atomic* | ✓ | ✓ | ✓(仅限单个 CAS 操作) |
final | ✓(正确构造后) | ✓ | 不可变,无需原子性 |
volatile 那一行的 ✗ 最容易被忽略:
private volatile int count;
void increment() {
count++; // 读、加一、写回三步,两个线程可能读到同一个旧值,丢失一次更新
}可见性只保证「读到最近一次写」,不保证「读和写之间没人插队」。计数要用 AtomicLong、LongAdder 或锁。
有序性:双重检查锁为什么必须加 volatile
final class Config {
private static volatile Config instance; // 去掉 volatile 就不再正确
private final Map<String, String> values;
private Config(Map<String, String> values) {
this.values = Map.copyOf(values);
}
static Config get() {
Config c = instance; // 只读一次 volatile 字段
if (c == null) {
synchronized (Config.class) {
c = instance;
if (c == null) {
instance = c = new Config(loadFromDisk());
}
}
}
return c;
}
}new Config(...) 在概念上包含「分配内存、执行构造、把引用赋给 instance」。没有 volatile 时,其他线程在锁外读 instance,与锁内的写之间没有 happens-before,规范允许它看到一个非 null、但字段还没初始化好的对象。
实际项目中更简单的写法是静态内部类持有者(初始化由类加载的锁保证),或者直接用 enum 单例,不需要自己推理。
四、final 与安全发布
JLS 17.5 对 final 字段有额外保证:对象正确构造之后(构造期间 this 没有逸出),其他线程看到这个对象时,一定能看到 final 字段的最终值,即使发布路径本身没有同步。
这就是不可变对象适合并发共享的原因。但下面几种写法会破坏「正确构造」:
final class PriceListener {
private final Map<String, BigDecimal> prices;
PriceListener(EventBus bus) {
bus.register(this); // this 逸出:事件线程可能在构造完成前回调
this.prices = new ConcurrentHashMap<>();
}
}构造函数里注册监听器、启动线程、把 this 放入全局容器,都是逸出。修复方式是把「构造」和「发布」拆开,比如用静态工厂方法:先 new,再 register。
还要注意 final 只管引用本身。final List<String> names = new ArrayList<>() 之后,多线程对这个 ArrayList 的读写仍然需要保护。
五、JMM 与硬件:别把教学模型当实现
常见的「每个线程有工作内存,从主内存拷贝变量」是一个教学模型,它帮助理解「为什么会读到旧值」,但真实原因更多样:
- JIT 优化:开头的例子里,编译器可以把循环条件外提,根本不再访问内存。
- CPU 缓存与寄存器:值可能留在寄存器或核心私有缓存中。
- 指令重排:处理器和编译器可以调整执行顺序,只要单线程可观察结果不变。
所以更准确的回答是:规范没有要求可见,实现就可以利用这一点做优化。至于「volatile 通过内存屏障立即刷回主存」这类说法,把 HotSpot 在某些平台上的实现细节当成了语义,了解即可,推理时依据的仍然是 happens-before。
六、工程里怎么落地
优先不共享可变状态。 不可变对象、消息传递、ConcurrentHashMap 这类并发容器,已经把 happens-before 封装好了,比手写 volatile 协议可靠得多。
发布可变对象时显式选择一种手段:
| 场景 | 推荐手段 |
|---|---|
| 开关、停止标记 | volatile boolean |
| 定期整体替换的配置快照 | volatile 引用 + 不可变对象 |
| 计数、统计 | LongAdder / AtomicLong |
| 多个字段需要一起变 | 锁 |
| 延迟初始化单例 | 静态持有者类或 enum |
| 任务结果在线程间传递 | CompletableFuture / BlockingQueue |
排查「偶现读到旧值」: 先确认读写之间有没有 happens-before,而不是怀疑 CPU 或 JVM。一个快速信号是:问题只在压测或运行一段时间(JIT 编译后)出现,本地调试(经常处于解释执行)复现不了。
测试并发正确性: 一次跑通不能证明语义正确。使用 JCStress 这类工具让线程交错成千上万次,并在不同 JIT 编译状态下反复运行。
七、常见误区
- 「JMM 就是堆和栈」:混淆了运行时数据区与并发内存语义。
- 「物理上先写,后读一定能看到」:没有 happens-before 就没有保证,开头的实验就是反例。
- 「volatile 能保证线程安全」:它保证可见性,不保证复合操作的原子性。
- 「锁只保证原子性」:锁同时建立了可见性,这正是监视器锁规则的内容。
- 「只读的字段不用管」:如果它在构造后被修改或通过逸出的
this发布,就不是真正只读。
小结
JMM 可以浓缩成一句话:多线程读写的正确性不取决于物理时间,而取决于读写之间有没有 happens-before 关系。锁、volatile、final、线程启动与 join,以及并发工具类,都是建立这种关系的手段;没有建立关系的代码,即使大多数时候跑对了,也只是运气。
往下深入,可以沿着 volatile、CAS 与 LongAdder 和 从 AQS 看 ReentrantLock 看同步手段怎样一层层建立在 happens-before 之上;落到线上,则关注三件事:对象怎么发布、有没有数据竞争、ThreadLocal 是否泄漏。线程在服务中的数量与隔离问题,见 线程池参数怎么定。
配套实验
- codesphere-labs/java/jmm-visibility:停止标记在默认参数、
-Xint、volatile三种方式下各运行 3 次(验证记录)
参考资料