JMM、happens-before 与 volatile:可见性如何被证明
一个线程把 running 改成 false,另一个线程为什么还可能继续循环?很多解释会立刻画 CPU 缓存,却跳过最重要的一步:Java 程序的正确性先由 Java Memory Model 定义。架构和排障要证明的不是“过一会儿大概能看到”,而是两个动作之间是否存在规范认可的 happens-before 关系。
JMM 不要求所有线程每时每刻读取某个全局最新值。它定义共享变量的读写、同步动作、执行合法性和数据竞争,让编译器、JIT 与处理器可以优化,同时给正确同步的程序稳定语义。我们先用两个会失败的实验进入,再把 volatile、锁、线程启动、任务交接和 final 字段放回同一张证据图。
sleep 不能替你建立可见性
先看停止标志:
final class PlainStopFlag {
boolean running = true;
}
PlainStopFlag flag = new PlainStopFlag();
Thread worker = new Thread(() -> {
long rounds = 0;
while (flag.running) {
rounds++;
}
System.out.println("stopped after " + rounds);
});
worker.start();
Thread.sleep(100);
flag.running = false;
worker.join(1_000);
System.out.println("alive=" + worker.isAlive());这不是稳定复现脚本:某次运行可能退出,换一个 JIT 阶段、机器或编译形态又可能不退出。恰恰因为结果不稳定,它不能作为测试门禁。真正确定的结论是,普通写和另一个线程的普通读之间没有同步边,程序存在数据竞争,不能依赖观察到的偶然结果。
把字段改成 volatile 后,写入与后续读取之间建立同步关系:
final class StopFlag {
volatile boolean running = true;
}这里解决的是状态发布,不是让循环更快。Thread.sleep(100) 只安排当前线程暂时不执行,它没有内存同步语义;日志打印、断点和调试器可能意外改变时序,也不能当作修复。
从动作和顺序读 JMM
单线程里我们按 program order 理解动作;多线程执行还要加入 synchronization order 与 happens-before。若两个线程对同一个变量有冲突访问,至少一个是写,而且它们没有被 happens-before 排序,就构成 data race。
常用同步边并不神秘:
一个 monitor 的解锁 happens-before 之后对同一 monitor 的加锁。对某个 volatile 字段的写 happens-before 之后对该字段的读。调用 Thread.start() happens-before 新线程中的动作。
线程中的全部动作 happens-before 另一个线程从该线程的 join() 成功返回。java.util.concurrent 组件还定义任务提交、Future 完成、队列交接等内存一致性效果。
程序顺序与这些边具有传递性。假设主线程先填充配置对象,再写 ready = true;工作线程读到 ready == true 后再读配置,那么 volatile 读写可以把此前普通写一并发布出去。
排查“偶发旧值”时就按图找:谁写,谁读,中间经过哪条同步边。如果回答只是“两个线程应该先后执行”,那还没有证据。
volatile 提供可见性和顺序,却不把复合动作变成原子操作
下面的计数器即便字段是 volatile,也会丢失更新:
final class BrokenCounter {
volatile int value;
void increment() {
value++;
}
}value++ 至少包含读取、计算、写回。线程 A 和 B 都读到 7,各自算出 8,再分别写回,最终只增加一次。volatile 保证读写的可见性与相应顺序,不把这组三步合成不可分割事务。
可重复实验不要只跑两个线程一次,而应使用起跑门同时放行,多轮记录实际值:
int threads = 8;
int loops = 100_000;
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
ready.countDown();
try {
start.await();
for (int n = 0; n < loops; n++) {
counter.increment();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
}).start();
}
ready.await();
start.countDown();
done.await();
System.out.printf("expected=%d actual=%d%n", threads * loops, counter.value);要精确原子计数,可以用 AtomicInteger;要维护多个字段的不变量,通常需要锁、不可变状态整体替换或把一致性下沉到数据库事务。选择依据不是“volatile 性能更好”,而是状态更新到底是不是单变量、是否允许中间状态、冲突时如何重试。
一次读到底能看见哪次写
JMM 讨论的不是一块内存“何时刷新”,而是一次执行是否合法。对每个普通读,执行都要为它选择一个它所看见的写;这个选择不能违反 happens-before 一致性,也不能凭空制造值。程序存在数据竞争时,允许的写读配对往往不止一种,所以“我本机连续看到最新值”只说明某些执行出现过,不能排除其他允许执行。
分析发布问题时,可以把程序缩成四个动作:写载荷 W(payload)、写标志 W(ready)、读标志 R(ready)、读载荷 R(payload)。如果都是普通访问,两个线程间没有同步顺序,读线程即使观察到 ready == 1,也没有规范边保证同时观察到载荷写入。使用 release 写与 acquire 读后,链条才闭合:载荷写按程序顺序先于 release,匹配的 acquire 读同步于该 release,acquire 又先于载荷读,传递性最终把两个普通访问排好序。
VarHandleOrderingDemo 把这条链写成了可运行代码:
cd examples/backend-development/concurrency/jmm-volatile
javac --release 17 -Xlint:all -Werror VarHandleOrderingDemo.java
java VarHandleOrderingDemo
# payload=42setRelease/getAcquire 比 volatile 访问弱一些,却足以表达单向发布。它们把“禁止哪一侧重排”说得更精确:发布前的写不能越过 release,观察到该发布的 acquire 之后的读不能越到前面。若双方使用不匹配的变量,或 acquire 读到更早一次写,链条仍没有建立。
final 字段又是另一条规则。构造函数正常结束前对 final 字段的写会经历冻结;对象没有在构造期间逸出时,后来读到对象的线程能获得特殊可见性保证。但它不把对象内部所有可变状态都变成线程安全,也不拯救 this 在构造函数中注册回调、启动线程或写入全局集合的提前逸出。安全发布要同时问“对象何时完成构造”和“引用通过哪条同步边交给读者”,不能把 final 当作发布通道。
安全发布比“对象已经 new 出来”多一步
构造完成的对象如果通过普通可变字段交给另一个线程,而两边没有同步关系,读取方就缺少安全发布保证。常见安全发布入口包括:
在静态初始化期间构造并保存对象。把引用写入 volatile 字段,再由读取方读取。在同一把锁保护下写入和读取。
通过线程安全容器、阻塞队列、Executor 任务提交或 Future 完成交接。在启动线程前准备数据,由 start 边发布给新线程。
不可变对象适合作为并发边界,但“字段声明为 final”不等于整个对象图都不可变。JLS 为正确构造期间写入的 final 字段提供特殊语义,前提是 this 没有在构造完成前逃逸。
final class Endpoint {
private final String host;
private final int port;
Endpoint(String host, int port) {
REGISTRY.add(this); // 错误:构造未结束就让 this 逃逸
this.host = host;
this.port = port;
}
}把自己注册到全局集合、启动能访问 this 的线程、从构造器调用可覆写方法,都可能造成构造期逃逸。更稳的做法是先完成构造,再由工厂把完整对象安全发布出去。
双重检查锁为什么必须配 volatile
延迟初始化常见写法如下:
final class ClientHolder {
private volatile Client client;
Client get() {
Client result = client;
if (result == null) {
synchronized (this) {
result = client;
if (result == null) {
result = new Client();
client = result;
}
}
}
return result;
}
}volatile 不是为了让第一次空判断原子化,而是为了发布构造完成的引用并约束相关重排序。局部变量 result 减少重复 volatile 读取。若没有性能证据,静态 holder、枚举单例或容器生命周期通常更简单;不要把双重检查锁当成所有对象创建的默认模板。
JUC 交接本身也能建立内存语义
很多代码不需要直接写 volatile。把任务提交到 Executor 前的动作 happens-before 任务开始执行;把对象放入 BlockingQueue 前的动作 happens-before 另一个线程取出该对象后的动作;异步计算的动作又能通过 Future.get() 交给等待者。
Request request = new Request();
request.setTraceId("trace-42");
executor.execute(() -> handle(request));如果 request 在提交后不再修改,Executor 的交接边足以发布此前状态。若提交者随后继续改同一个对象,任务线程与提交者重新产生竞争;同步容器只能保护交接动作,不能自动冻结对象。
这也是为什么消息式设计常比共享可变对象容易推理:每一次所有权转移都有明确入口。队列并不会替业务做深拷贝,所以交接后谁还能修改仍要写进契约。
内存屏障是实现线索,不是应用层契约
在 HotSpot 源码、JIT 汇编或 CPU 文档里会看到 acquire、release、full fence 和具体屏障指令。它们有助于解释成本和实现,但应用正确性应先站在 JLS/JUC 的语义上。把“某 CPU 是强内存模型,所以普通字段也能用”当成设计依据,会让代码在 JIT 优化、其他架构或未来版本上失去保证。
如果确实在开发底层并发组件,可以使用 VarHandle 选择 plain、opaque、acquire、release、volatile 或原子更新语义。语义越弱,证明责任越大;普通业务代码优先用不可变对象、锁、原子类和标准并发容器,不要为了几纳秒引入难以审查的弱序访问。
怎样证明问题是数据竞争,而不是猜测缓存
JMM 问题很难靠普通日志稳定复现,因为日志本身会改变执行时序。实验需要控制三件事:同时起跑、足够迭代、明确成功与失败断言。
用 CountDownLatch 或 barrier 让工作线程同时进入竞争区。对丢失更新这类结果设置确定断言;对允许多种结果的 litmus test,记录结果集合而不是只跑一次。分别运行错误版本和正确同步版本,确认修复消除的是同步缺口。
不用 sleep 代替协调,也不以“跑了一万次没失败”证明无数据竞争。
工具如 jcstress 适合验证细粒度并发算法,它会系统地生成调度并统计允许/禁止结果。业务代码仍应先降低共享状态,再考虑专门压力测试。
生产里最容易踩的三个边界
第一,配置热更新。把许多相关字段逐个写成 volatile,读取方仍可能看到新旧混合。更稳的是构造不可变快照,通过一个 volatile 引用整体替换。
private volatile RoutingSnapshot snapshot = RoutingSnapshot.empty();
void reload(Config source) {
RoutingSnapshot next = buildAndValidate(source);
snapshot = next;
}第二,缓存与延迟初始化。ConcurrentHashMap 能保护单次操作,但“先查、远程加载、再放入”可能重复加载。需要使用合适的原子复合操作,同时控制加载函数的阻塞、失败和递归风险。
第三,业务一致性。JMM 只管一个 JVM 中线程看到什么,不替你提供数据库事务、跨服务幂等或分布式共识。内存里的 AtomicInteger 无法阻止两个服务实例同时扣同一份库存。
评审共享状态时,先画同步边
审查并发代码时,不要只问“加没加 volatile”,而要让作者画出:
哪些变量会被多个线程访问,谁写、谁读。哪些访问冲突,依靠哪条 happens-before 边排序。对象何时完成构造,通过什么入口发布。
更新是单字段还是多个字段不变量,失败时是否允许重试。容器或任务交接后,原线程是否还会修改对象。证据是规范语义、最小实验,还是仅仅依赖当前机器现象。
用两份实验区分“出现过”与“被语义禁止”
完整源码位于 examples/backend-development/concurrency/jmm-volatile/。JmmVisibilityDemo.java 稳定制造复合更新丢失,同时展示 volatile 快照可见;VarHandleOrderingDemo.java 用 release/acquire 建立 payload 发布边。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out JmmVisibilityDemo.java VarHandleOrderingDemo.java
java -cp out JmmVisibilityDemo
java -cp out VarHandleOrderingDemo稳定输出为:
lostUpdate=1 expected=2 volatileSnapshot=visible
payload=42第一行不是说普通竞态永远得到 1,而是用可控交错证明“两个读改写不能由 volatile 自动合成原子操作”;第二行证明 acquire 读观察到 release 写后,也能看见它之前的 payload 写。工程门禁应为每个共享状态登记写者、读者、同步边和被禁止结果:正确同步版本在压力循环中不得出现禁止结果,错误 litmus 则必须能在受控交错下暴露缺口。错误版本偶尔一万次没失败不构成正确性证据,正确性来自 happens-before 证明,循环结果只负责防止实现回归。
这张图能解释清楚,才轮到比较 volatile、锁、原子类或消息队列的成本。
规范原文可继续查看 JLS 第 17 章 Threads and Locks;标准并发组件的交接语义集中在 java.util.concurrent 包说明。
