JMM 可见性边界:happens-before、volatile、final 与进度
版本基线:语言语义以 JLS SE 17 §17 为准,API 契约以 Java SE 17 为准,实现分析固定到 OpenJDK 17u
jdk-17.0.12+7。本机实际编译运行版本为 Oracle JDK 17.0.12+8(x86_64,/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home)。互斥锁与公平性归锁正文,AQS 与 Condition 归AQS 正文;CAS/ABA 与原子类归CAS 正文;本页只讲内存可见性、volatile/final语义与进度口径。
三类证明义务:可见性、原子性、有序性与进度
并发缺陷经常被笼统写成"线程不安全",但修复手段取决于问题属于哪一层。先看四个故障样本:
| 故障 | 交错 | 观察结果 | 错误修复 |
|---|---|---|---|
| 丢失更新 | 两个线程都读 0,再各自写 1 | 最终是 1,不是 2 | 给计数器加 volatile |
| 不可见 | 写线程改普通标志,读线程紧自旋 | 读线程可能永远读不到新值 | 在读线程加 sleep |
| 不安全发布 | 先写普通字段,再发布引用;读线程先读引用 | 读线程可能看到默认值或永远看不到引用 | 只在读侧加循环等待 |
| 无进展 | 高竞争 CAS 自旋、锁内长 I/O | 吞吐下降、个别线程长时间无推进 | 换更"底层"的原子类 |
它们对应的证明义务不同:
- 原子性回答"一个操作能否被观察到只发生一部分"。
i++是读、加、写三步,两个线程可以都持有旧值。 - 可见性回答"一个线程的写入,另一个线程何时必须看到"。没有 happens-before 边时,读到旧值是规范允许的结果。
- 有序性回答"操作能否以程序顺序之外的方式被观察"。引用先可见、构造中的字段后可见,是典型的有序性故障。
- 进度回答"操作最终能否完成"。它不是内存模型条款,而是活性属性;修复手段(缩短临界区、限制重试、换分散结构)与前三类完全不同。
还要区分"单次方法调用"与"复合操作":并发容器的单次 get/put 各有并发语义,但"先 get 再 put"不构成事务。跨方法、跨字段的不变量需要锁、CAS 状态机或外部事务,容器与内存模型都不代劳(见并发容器写侧契约)。
JLS §17.4 把"两个动作访问同一变量、至少一个是写、且不被 happens-before 排序"定义为数据竞争。一个程序是正确同步的(correctly synchronized),当且仅当它的所有顺序一致执行都没有数据竞争;此时程序的每个执行都表现为顺序一致。这个保证比"测试没复现"强得多,但它只覆盖被正确同步的访问,不覆盖业务不变量。另一条规范边界:非 volatile 的 long/double 读写允许拆成两个 32 位动作(JLS §17.7),volatile 的 64 位读写则保证原子;HotSpot 在 64 位平台通常把普通 64 位访问实现为原子,但这是实现便利,不是可移植承诺。
happens-before:可见性的最小证明
JLS §17.4.5 给出一组基本边,java.util.concurrent 包文档用几乎相同的措辞总结成"内存一致性属性":
原码连续摘录(仅统一公共缩进):java/util/concurrent/package-info.java,jdk-17.0.12+7,L227–L237。
* <h2 id="MemoryVisibility">Memory Consistency Properties</h2>
*
* Chapter 17 of
* <cite>The Java Language Specification</cite> defines the
* <i>happens-before</i> relation on memory operations such as reads and
* writes of shared variables. The results of a write by one thread are
* guaranteed to be visible to a read by another thread only if the write
* operation <i>happens-before</i> the read operation. The
* {@code synchronized} and {@code volatile} constructs, as well as the
* {@code Thread.start()} and {@code Thread.join()} methods, can form
* <i>happens-before</i> relationships. In particular:完整的规则集合:
| 边 | 规则 |
|---|---|
| 程序顺序 | 同一线程中,前面的动作 happens-before 后面的动作 |
| 监视器 | 对同一监视器的 unlock happens-before 后续的 lock |
| volatile | 对同一 volatile 字段的写 happens-before 后续的读 |
| 线程启动 | Thread.start() happens-before 新线程中的任何动作 |
| 线程结束 | 线程中的所有动作 happens-before 其他线程从 join() 成功返回 |
| 默认初始化 | 每个变量都被默认初始化,默认写与程序开头同步 |
| 传递性 | 若 A hb B 且 B hb C,则 A hb C |
安全发布的推导链:
// 写线程
data = 42; // A
ready = true; // B:volatile 写
// 读线程
if (ready) { // C:volatile 读
int x = data; // D
}A hb B(程序顺序),B hb C(volatile 规则),C hb D(程序顺序),传递得 A hb D。因此读到 ready == true 时必须看到 data == 42(前提是没有后续覆盖写)。去掉 volatile,A 与 D 之间没有边,读 0、读 42、永远读不到都合法。
屏障为什么不修复丢失更新:CyclicBarrier.await() 内部的锁建立了"各线程 await 前的动作 hb 各线程 await 后的动作",于是两个线程的读都先于两个写;但两个写之间没有先后,仍都写 1。屏障对齐时序,没有把读-改-写变成互斥区(实测见并发实验与本文实验节)。
volatile:单变量的可见性、顺序与内存效应分级
volatile 保证:
- 对同一 volatile 字段的写 happens-before 后续的读;写之前的普通动作随发布链对读线程可见。
- 64 位
long/double的 volatile 读写原子。 - 编译器与处理器不能以破坏上述语义的方式重排;但它不禁止所有重排,更不提供互斥。
volatile 不保证:
i++、check-then-act 等复合操作原子。- 多个字段之间的同一时刻快照;两个 volatile 字段各自可见即可。
- 数组元素可见:
volatile int[] a只让引用a有 volatile 语义,a[0]没有。 - 互斥:多个写线程仍可同时进入读-改-写路径。
数组元素这条边界在 JDK 里有官方答案——AtomicIntegerArray 等类为元素提供 volatile 语义:
原码连续摘录(仅统一公共缩进):java/util/concurrent/atomic/package-info.java,jdk-17.0.12+7,L73–L80。
* <p>The
* {@link java.util.concurrent.atomic.AtomicIntegerArray},
* {@link java.util.concurrent.atomic.AtomicLongArray}, and
* {@link java.util.concurrent.atomic.AtomicReferenceArray} classes
* further extend atomic operation support to arrays of these types.
* These classes are also notable in providing {@code volatile} access
* semantics for their array elements.
*内存效应不是一个开关,而是一组分级:VarHandle 定义了 plain(无排序保证)、opaque、acquire/release、volatile 四档。AtomicInteger 的两个写方法直接展示了这分级:
原码连续摘录(仅统一公共缩进):AtomicInteger.java,jdk-17.0.12+7,L94–L112。
* Sets the value to {@code newValue},
* with memory effects as specified by {@link VarHandle#setVolatile}.
*
* @param newValue the new value
*/
public final void set(int newValue) {
value = newValue;
}
/**
* Sets the value to {@code newValue},
* with memory effects as specified by {@link VarHandle#setRelease}.
*
* @param newValue the new value
* @since 1.6
*/
public final void lazySet(int newValue) {
U.putIntRelease(this, VALUE, newValue);
}set 是 volatile 写;lazySet 是 release 写:它保证 lazySet 之前的普通写在被后续的 acquire/volatile 读观察到标志时一并可见,但不阻止它与之后的写重排。实验第 6 节验证了这个配对:普通字段 payload=42 在 flag.lazySet(1) 之后,读线程用 flag.get()(volatile 读)观察到 1 时,payload 必为 42。lazySet 的边界:它不是"更快且完全等价"的 set——对后续写的排序比 volatile 弱,只适合"发布完成标志/清理提示"这类单向场景。
因此 volatile 的适用面:单写线程的状态标志、关闭信号、不可变快照的引用发布、双重检查锁定的实例引用。多个写线程的计数、状态迁移和跨字段不变量,交给 CAS 循环、锁或原子状态机(见CAS 正文)。
final 字段:构造完成时点的冻结
JLS §17.5 给 final 字段一条独立于 volatile/锁的保证:冻结(freeze)发生在构造器退出时。只要对象被正确构造(构造期间 this 未逸出),其他线程无论通过什么链路看到该对象引用,都会看到 final 字段在构造器中写入的值——哪怕引用本身的发布是数据竞争。这解释了为什么无同步发布的不可变对象(如 String)仍能安全使用。
四条精确边界:
- 冻结有时点:构造器退出前,final 字段没有特殊保证。构造中途逸出
this,读线程可以读到默认值。 - 只冻结构造完成时点的值:
final引用指向的可变对象、数组元素,其后续修改不获保护。 - 前提是构造期不逸出:在构造器里把
this注册到监听器、启动读该对象的线程,都会破坏前提;之后即使补上锁也与 17.5 无关,只能走普通同步。 - 普通字段没有这条保证:同一对象上非 final 字段经数据竞争发布时可能读到默认值;观察不到失败不代表有保证。
实验第 3 节同时演示两端:构造器先逸出 this 再阻塞在闩锁上,读线程此刻读 final 与普通字段都得到默认值 0(闩锁保证读先于赋值,这个 0 是推导结论不是运气);构造完成且用 join 建立 happens-before 后必须读到 42;而无同步发布(引用数据竞争)时 final 仍保证 42,同对象的普通字段 42 只是本机观察值。
进度:无阻塞算法的保证分级
可见性和原子性回答"对不对",进度回答"能不能完成"。java.util.concurrent.atomic 包文档对这组工具的定位是:
原码连续摘录(仅统一公共缩进):java/util/concurrent/atomic/package-info.java,jdk-17.0.12+7,L37–L41。
* A small toolkit of classes that support lock-free thread-safe
* programming on single variables. Instances of Atomic classes
* maintain values that are accessed and updated using methods
* otherwise available for fields using associated atomic {@link
* java.lang.invoke.VarHandle} operations."lock-free"在这里是系统级承诺:竞争下至少有一个线程在有限步内取得进展。非阻塞算法的保证通常分三级(社区通行口径,课程推导,JDK 文档未逐条定义):
| 级别 | 承诺 | 单线程是否可能饿死 |
|---|---|---|
| obstruction-free | 无其他线程竞争时,有限步内完成 | 是 |
| lock-free | 系统整体持续进展 | 是:个别线程可能一直输掉 CAS |
| wait-free | 每个线程都在有限步内完成 | 否 |
对工程最重要的推论:CAS 重试循环没有个体时延上界。8 线程对一个 AtomicInteger 做读改写,总计数必然精确,但失败重试次数是负载与调度的函数——实验第 4 节观察到单轮 33.8 万次 CAS 失败,这既不是缺陷也不是性能指标,只是"个体无上界、整体在推进"的直接体现。同理,volatile 自旋等待解决了可见性,却把进度押在对方"迟早会写"上:对方若崩溃或延迟,自旋方永远空转;需要上界时用带时限的等待(await(timeout)、tryLock,见锁正文)。
观察与契约:为什么"没复现"不是证据
无同步的共享访问在 x86 这类强缓存一致机器上经常"看起来正确":写很快被看到、值很少撕裂。这不是保证,而是实现巧合;同一程序换了 JIT 决策或平台就可能表现不同。实验第 1、2 节给出两种口径的对照:
- 自由竞争的普通
int计数在本机单轮丢失 7.5 万次更新——但这只是观察值。规范允许的完整集合是 {丢失, 不丢失},一次运行落在哪边都不能当作证明;正确性只能来自 happens-before 推导(读与写无边 → 数据竞争 → 结果未定义)。 - 普通布尔停止标志在本轮 15 亿自旋内从未被观察到置位——JIT 把普通读提升出循环后这是允许结果;但同一份代码在解释执行或不同编译决策下又会"能看到"。volatile 版本则被断言必然观察到,这条断言的依据是规范,不是运气。
工程含义:测试不能证明"没有可见性问题";只能证明"这个交错下暴露了问题"。评审竞态代码时,把"会不会出错"换成"哪两条动作之间缺边";缺边就是缺陷,与是否复现无关。
竞争时间线:无同步竞争与 this 逸出
| 时间 | T1 | T2 | 规范结论 |
|---|---|---|---|
| t1 | 读 counter=0 | — | — |
| t2 | — | 读 counter=0 | 两个读都合法(无先后边) |
| t3 | 加 1 写回 | — | — |
| t4 | — | 加 1 写回 | 最终 1 或 2 均允许;丢失更新是数据竞争的合法结果 |
| 时间 | 构造线程 | 读线程 | final 字段 |
|---|---|---|---|
| t1 | 逸出 this(注册/发布) | — | 尚未冻结 |
| t2 | 阻塞(实验中用闩锁固定) | 读 finalValue | 必为默认值 0(赋值动作尚未发生) |
| t3 | 赋值 finalValue=42,构造器退出 | — | 冻结发生 |
| t4 | — | 经任意链路再读 | 42;无同步发布也适用(JLS §17.5) |
设计案例:配置热更新的发布链(算例,非实测)
假设一个网关服务:2000 QPS 读取路由配置,运营每天发布 3 次更新,配置对象含 40 个字段与 1 个嵌套列表,要求读到旧版或新版都自洽,不允许读到半更新的混合版。以下为假设算例,非实测。
三个方案:
volatile Config引用 + 不可变对象:每次更新构建完整新对象,单次引用写完成发布。读路径零锁、零复制;写路径 O(对象图)。自洽性由引用发布的原子性 + final/不可变保证。适用读多写少;要求 Config 真正不可变(字段 final、列表List.copyOf)。AtomicReference<Config>+ CAS:与 1 语义等价(引用原子替换),另可用updateAndGet表达"读-改-写"。当存在多个写者可能并发覆盖时,CAS 使覆盖可检测;但配置发布通常是单写者,1 更简单。- 可变 Config + 逐字段 setter + 锁:写侧锁内改 40 个字段,读侧要么同锁(读路径加锁)要么无锁(读到混合版)。前者给 2000 QPS 读路径引入串行化,后者破坏自洽要求;两处都不如 1。除非配置极大且更新频繁到全量构建成为内存压力,否则不选。
不变量与恢复责任:读到旧版与新版都必须可服务;发布方负责新对象构建完整后再切换引用;引用切换失败(构建抛异常)时旧引用仍在,读路径无感。演进触发:当配置大到全量构建产生可观 GC 压力(用 GC 日志验证,不是猜),再评估分片或差量结构。这个案例里"正确性来自 volatile/不可变的组合,性能来自读路径零锁",两条主张分别对应本文与容器正文的验证方式。
条件变化与边界
| 条件变化 | 结论如何变化 |
|---|---|
| 给共享变量加锁(同一监视器) | 同时获得互斥与 happens-before;但锁的进度属性(饥饿、持有时长)成为新的责任,见同步器正文 |
把 volatile 换成 lazySet | 失去与后续写的排序;只剩单向 release,仅适合完成标志类场景 |
| 32 位平台或未来 JVM | 非 volatile 64 位读写撕裂从"理论允许"变成"实际可能";不要按 64 位 HotSpot 的行为写死结论 |
| 把发布链中间变量去掉(直接数据竞争发布) | volatile 链断裂,只剩 final 字段自身的 17.5 保证;普通字段回到无保证 |
| 多写者同时发布配置 | 单 volatile 引用的"最后写赢"语义需要显式接受或改用 CAS;见 CAS 正文 |
| 升级 JDK | JLS 语义稳定,但 JIT 提升读出循环、合并写等优化决策会变;"本轮没复现"的观察值不可跨版本外推 |
实验:输入、步骤、预期与边界
公开入口(仓库根):
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh jmm探针为 labs/java-senior/JmmProbe.java(--add-opens java.base/java.util.concurrent=ALL-UNNAMED 仅用于反射读取 CopyOnWriteArrayList.array 内部字段,不是业务配置)。本节相关的输出(2026-10-24,Oracle JDK 17.0.12+8,macOS 26.7.1 x86_64):
== 1. unsynchronized race: observation, then fix ==
plain int final=124245 expected=200000 -> OBSERVE lost updates: 75755
AtomicInteger final=200000 expected=200000
== 2. stop flag: plain (may not reproduce) vs volatile ==
plain flag never observed: hit spin limit(JIT 可把普通读提升出循环;JLS 不保证无同步可见,属允许结果)
volatile flag observed after 691571260 spins(volatile 写读建立 happens-before,断言通过)
== 3. final freeze: this-escape vs completed constructor ==
during construction (escape + latch ordering): final=0 plain=0
after join (hb established): final=42 plain=42
racy publication: final=42 (JLS 17.5 保证,断言) plain=42 (无保证,观察值)
== 4. progress: CAS retry cost and dispersed counting ==
AtomicInteger CAS loop total=400000 casFailures=338821(重试为观察值:无个体时延上界)
LongAdder total=400000(增量分散到 Cell;sum() 非原子快照,仅在无并发更新时精确)预期与断言口径:断言只压在规范保证上——volatile 必被观察、final 经任意发布链必为构造值、原子总量精确、闩锁排序下的默认值必为 0;普通字段的全部结果(丢/不丢、看到/看不到、42/0)都以观察值记录并标注"非契约"。第 2 节的普通标志本轮未被观察到(自旋提升),同一代码在解释执行下通常又能观察到,这正是"可能不复现"的标本。
边界:不覆盖 32 位平台的字长撕裂实测、不覆盖 JEP 425 虚拟线程下的调度差异、不提供吞吐或延迟数据;完整十节输出与负例记录见实测证据。
追问链
第一层:为什么 volatile 修不好计数器?
i++ 是读、加、写三个动作,volatile 只保证每个动作的可见性与顺序,不提供互斥;两个线程仍可都读到旧值再各自写回。修复要么 CAS 读-改-写循环(原子类),要么锁,要么 LongAdder 分散热点——选择依据见CAS 正文。
第二层:final 都加了,为什么还可能读到半初始化对象?
final 的冻结发生在构造器退出时。构造期间 this 逸出(注册监听器、启动线程、存入集合),读线程在构造完成前拿到引用,读到的是默认值——这不是 final 失效,而是保证尚未生效。先修逸出,再谈 final。
第三层:测试跑了一夜没复现,能上线吗?
不能据此下结论。无同步访问落在哪一侧由 JIT 与平台决定,规范允许两种结果;一夜测试只是采样。把评审问题换成"哪两条动作之间缺边":能指出缺边就是缺陷,与复现无关;不能指出缺边再谈测试证据。
第四层:评审一个"用 volatile 优化过的热点路径",按什么顺序问?
先问不变量(哪些字段必须同时自洽);再问写者数量(单写者引用发布 / 多写者 CAS / 复合状态上锁);再问进度(自旋有无上界、锁内有没有 I/O);最后才问性能主张的证据。顺序反了容易接受一个"快但错"的实现。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JLS SE 17 §17.4 内存模型 | Java SE 17 | happens-before、数据竞争、DRF-SC、顺序一致保证 |
| JLS SE 17 §17.4.5 happens-before order | Java SE 17 | 基本边与传递性 |
| JLS SE 17 §17.5 final Field Semantics | Java SE 17 | 冻结时点、构造期逸出、数据竞争发布下的保证 |
| JLS SE 17 §17.7 Non-atomic Treatment of double and long | Java SE 17 | 非 volatile 64 位读写的规范边界 |
| Java SE 17 java.util.concurrent 包文档 | Java SE 17 | 内存一致性属性、容器级 happens-before 承诺 |
| Java SE 17 VarHandle | Java SE 17 | plain/opaque/acquire-release/volatile 访问模式分级 |
| OpenJDK 17u AtomicInteger | jdk-17.0.12+7 | set(setVolatile)与 lazySet(setRelease)对照(L94–L112) |
| OpenJDK 17u atomic 包文档源码 | jdk-17.0.12+7 | 原子数组元素 volatile 语义(L73–L80)、lock-free 定位(L37–L41) |
| OpenJDK 17u j.u.c 包文档源码 | jdk-17.0.12+7 | Memory Consistency Properties(L227–L237) |
本地 JmmProbe 实验 | Oracle JDK 17.0.12+8 | 竞争观察、停止标志、final 冻结、进度观察实测输出 |
访问日期:2026-10-24。源码行号以 jdk-17.0.12+7 标签为准;运行环境为 Oracle JDK 17.0.12+8。无阻塞三级分级为课程推导(社区通行口径),JDK 文档只对 atomic 包声明 lock-free 定位。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。