JMM、happens-before 与 volatile
共享对象交给另一个线程后,读取方能看到哪些字段值,由 Java 内存模型(Java Memory Model,JMM)约束。一个线程先写入数据,再设置完成标志;另一个线程观察标志并读取数据。两条线程之间需要建立正确的发布关系。
共享数据
├─ 实例字段:某个对象的 payload、ready
├─ 静态字段:类中的全局配置引用
└─ 数组元素:同一个数组的 items[0]、items[1]
线程自己的局部变量
└─ 局部引用可以指向共享对象
引用变量属于当前调用,引用所指的对象仍可能被其他线程修改JMM 讨论读写的合法结果,JVM 运行时内存结构讨论对象、栈、元空间等存放在哪里。把字段放在堆中,并不能推导出另一个线程应在何时读到它的新值;把引用放在局部变量中,也不会复制它指向的对象。共享变量的定义见JLS 17.4.1。
用一个发布例理解可见性
先写载荷,再发布标志
下面的完整程序有两个参与者:主线程写入 payload,publication-reader 线程读取它。ready 是 volatile 字段;payload 是普通 int。
public final class Publication {
private int payload;
private volatile boolean ready;
private Publication() { }
public static void main(String[] args) throws InterruptedException {
Publication message = new Publication();
int[] observed = {-1};
Thread reader = new Thread(() -> {
long start = System.nanoTime();
while (!message.ready) {
if (System.nanoTime() - start > 3_000_000_000L) return;
Thread.onSpinWait();
}
observed[0] = message.payload;
}, "publication-reader");
reader.start();
message.payload = 42;
message.ready = true;
reader.join(5000);
if (reader.isAlive()) throw new IllegalStateException("reader still running");
if (observed[0] != 42) throw new IllegalStateException("observed=" + observed[0]);
System.out.println("reader observed payload=" + observed[0]);
}
}在 Linux Bash 中以普通用户保存为 Publication.java。使用完整 JDK;实验基线为 Temurin 25.0.4+7,代码也已在 Temurin 17.0.20+8 运行。JAVA_HOME 改为本机实际安装目录,安装与编译目标说明见Java 平台、JDK 与工程版本基线。
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JAVA_HOME/bin/java" -version
"$JAVA_HOME/bin/javac" -version
LAB_OUT=$(mktemp -d /tmp/jmm-learning.XXXXXX)
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" Publication.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" Publication正常输出:
reader observed payload=42--release 17 同时限制源码语言级别、可使用的 Java API 和生成的 class 目标版本;它不会把正在运行的 JDK 25 变成 JDK 17。输出中的 42 是读取线程实际取得的值,主线程在 join 后才读取 observed,避免用主线程自己读取 payload 冒充跨线程实验。
循环里的三秒限制用于让实验在未观察到标志时结束;读不到标志会保留 -1,并使主线程报错。生产等待优先使用队列、门闩或 Future 等阻塞机制,避免让一个线程长期自旋。Thread.onSpinWait只是运行时提示,删除它不改变正确的同步关系;它不会补上缺失的可见性保证。
ready 发布了此前的普通写入
写者在同一线程内先执行 payload = 42,再执行 ready = true。读者观察到这个 true 后,才读取 payload。四个动作形成下列关系:
写者 读者
W(payload = 42)
│ 程序顺序
W(volatile ready = true) ── 同步关系 ──→ R(volatile ready) == true
│ 程序顺序
R(payload) == 42
传递结果:W(payload = 42) happens-before R(payload)
条件:同一 ready;观察到这次发布;之后没有另一次无同步的 payload 修改。volatile 使发布标志的读写建立同步关系,再通过程序顺序和传递性带上此前的普通字段写入。因此,不需要把这份一次性载荷的每个字段都声明为 volatile。
交换写者两条语句会改变含义:先发布 ready,随后再写 payload,读取方就可能在载荷写入前取值。如果写者在 ready = true 后继续修改同一载荷,也需要为后续修改设计同步。一次性布尔标志还缺少消息编号和消费确认,不能原样复用成多轮消息传输协议。
普通 ready 则没有这条发布关系。此时即使读到 true,读者也可能读到旧载荷;循环也可能持续使用旧标志。这是允许出现的错误执行,不要求每台机器、每次运行都出现。sleep、yield 和调试断点不能替代同步,相关规则见JLS 17.3。
happens-before 如何约束一次读取
三种顺序分别描述什么
program order(程序顺序)描述单线程的动作顺序。synchronization order(同步顺序)对一次执行中的同步动作建立总序。happens-before 则由线程内顺序、synchronizes-with(同步关系)及传递性连接起来,是跨线程推理使用的偏序。
偏序意味着有些动作之间没有确定关系。它不是给所有动作安排同一个挂钟时间,也不要求每条机器指令都按源码位置执行;编译器与处理器可以优化,只要可观察结果符合 Java 语义。
| 交接方式 | 建立关系的位置 | 必须满足的条件 |
|---|---|---|
| monitor | 解锁 → 后续加锁 | 同一个 monitor;所有相关访问遵守同一锁协议 |
| volatile | 写 → 后续读 | 同一个字段;“后续”按同步顺序判断 |
| Thread.start | 启动前动作 → 新线程动作 | 数据准备发生在 start 之前 |
| 线程终止检测 | 目标线程动作 → 检测终止后的动作 | join 确认终止,或 isAlive 确认线程已终止;有界 join 超时返回不够 |
| 任务提交 | 提交前动作 → 任务执行 | 通过相应 Executor 的提交入口 |
| Future 取结果 | 异步计算动作 → 取得结果后的动作 | 按 get 的成功交接语义;不能用 isDone 轮询替代 |
| 队列、门闩等 | 放入/释放前动作 → 取得/成功等待后的动作 | 同一元素或同步器,并符合具体 API 的约定 |
语言层顺序由JLS 17.4.4—17.4.5规定;JUC 内存一致性说明把它们连接到常用组件。对同名但不同实例的两个锁操作,或把一个实例的 volatile 写与另一个实例的读相连,都无法得到这条关系。
合法读、数据竞争与业务竞态
对同一个变量的两次访问,只要至少一次是写,就属于冲突访问;不同线程的冲突访问没有被 happens-before 排序,会形成数据竞争(data race)。
分析一个普通读 r 能否观察某次写 w,可以先检查两个条件:不能已经有 r happens-before w;也不能有另一次写 w' 位于 w happens-before w' happens-before r 之间。第二个条件排除了跳过已经有序到达的更新,继续选择更老的写。这是 happens-before 一致性约束;完整合法执行还要满足同步顺序一致性、线程内语义和因果性等要求,不能把两条条件当成完整的执行模拟器。正式定义见JLS 17.4.5。
正确同步的程序可以按顺序一致的交错来推理:各线程保留自己的程序顺序,读写排列成一种全局交错。不过,即使每一次读写都安全,一组业务步骤仍可能交错出错误结果。例如两个线程各自“读取余额、判断足够、扣减”,单个访问的正确性没有把三个步骤合成一个整体。
这种对执行交错敏感的业务问题通常称为竞态条件(race condition)。volatile 计数器的 ++ 就是一个典型例子。
volatile 的单次访问与复合更新
两次自增为什么只留下 1
volatile int value 的一次读和一次写各自是原子的。value++ 包含读取、加一和写回,两个线程可以分别取到同一个旧值:
线程 A 线程 B
读 value → 0
读 value → 0
计算 0 + 1
计算 0 + 1
写 value ← 1
写 value ← 1
最终 value = 1这组交错没有违反 volatile 的单次访问规则。问题出在两个更新都以 0 为起点,第二次写回覆盖了第一次更新。
下载 JMM 实验包,解压后进入 jmm-volatile 目录。包内包含前面的 Publication.java 和完整的 MemoryModelLab.java。沿用已设置的 JAVA_HOME 和 LAB_OUT,在同一个 Linux Bash 中编译:
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" Publication.java MemoryModelLab.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab lost-update
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab atomic预期输出:
volatile increments=2 result=1
atomic increments=2 result=2lost-update 模式的字段确实声明为 volatile。它把自增拆成读值与写回,并在两次读值之后设置 CyclicBarrier,让两条线程都先拿到 0。屏障固定了需要观察的交错,因此结果稳定为 1;它不是通过随机压力碰运气复现,也不用于研究未同步读取的硬件现象。
atomic 模式把更新换成 AtomicInteger.incrementAndGet(),两次更新得到 2。原子读改写把“取旧值并加一”作为不可分割的操作,见 AtomicInteger API。执行结束后,主线程会检查线程是否退出并校验结果,子线程异常会使命令失败。
按需要保护的操作选择工具
若多个线程只是读取配置,单个写者偶尔替换一份配置,volatile 引用适合承担发布。若多个线程竞争更新一个计数,用原子类表达一次更新更直接。
涉及多个条件时,先确定整个操作的范围。例如“库存大于 0 才扣减”需要把检查与扣减连起来;分别调用 get() 和 decrementAndGet(),仍有其他线程插入的机会。可以使用锁保护整段逻辑,或用比较并交换循环让检查与更新针对同一个旧值。CAS 的失败重试、ABA 和高竞争代价在CAS、原子类与 LongAdder中展开。
单次访问的原子性还有两个容易忽略的细节:
普通对象引用、int 等单次读写不会读到“半个值”,但其他线程何时、按什么顺序看到写入,仍要由同步关系解释。
对非 volatile 的 long 和 double,语言规范允许把一次 64 位访问拆成两个 32 位部分;volatile long/double 的读写必须原子化。不要把某台 64 位机器的运行现象推广成语言保证。规则见 JLS 17.7。
数组也要区分引用与元素。volatile int[] values 使 values 引用的访问具备 volatile 语义,values[0] = 1 修改的是数组元素。需要独立并发更新元素时,可以选择原子数组;需要多个元素一起保持一致时,应考虑锁或整体替换数组快照。
把相关字段作为一份快照发布
两个 volatile 字段可能来自不同版本
假设路由由 host 和 port 组成:旧配置是 old.example.com:80,新配置是 new.example.com:443。把两个字段都设为 volatile,写者仍要执行两次赋值。读者可能在中间窗口拿到新 host 和旧 port。
执行:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab split-fields预期输出:
split observed=new.example.com:80 final-port=443实验先写新 host,通知读者读取,然后才写新 port。读到的每个字段都是合法的,拼起来却不是一个完整配置版本。最终 port 变成 443,也不会撤销读者已经使用过的混合地址。
这里需要保护的是一对值。可以让更新方和读取方都在同一个锁下访问这对字段;读多写少时,也可以构造一个新对象,再一次性发布它的引用。
一次捕获引用,读取同一份配置
下面是下载包中快照结构的核心声明:
record Endpoint(String host, int port) {}
static final class Routing {
volatile Endpoint current = new Endpoint("old.example.com", 80);
}写者完成新对象的构造后执行 routing.current = new Endpoint("new.example.com", 443)。读者先做一次 Endpoint local = routing.current,再从 local 读取两个字段:
发布引用 current ──→ 新快照 new.example.com:443
↑ 后续读取 current 的线程可取得它
读者局部变量 local ──→ 已捕获的旧快照 old.example.com:80
↑ host 与 port 均从这一对象读取执行:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab snapshot
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab double-read预期输出:
snapshot captured=old.example.com:80 current=new.example.com:443
double-read observed=old.example.com:443第一种模式让读者先捕获旧引用,等待写者发布新引用后再读 local,得到的仍是一整份旧配置。这种行为适合“每次操作固定使用一个配置版本”的要求。
第二种模式故意写成两次读取:先取 routing.current.host(),替换发生后再取 routing.current.port()。虽然两份 Endpoint 都不可变,两次 volatile 读取却可以选中不同对象,于是拼出旧 host 加新 port。局部变量的作用就是固定这次业务操作选中的对象。
快照还需要约束后续修改与多个写者
Endpoint 只持有 String 和 int,因此可以作为不可变值使用。若 record 改为持有 List、Map 或数组,final 字段只固定引用;调用者仍可能修改所指对象。构造时要复制可变输入,访问时也不能泄露可修改的内部数组。集合的浅复制只隔离集合结构,元素本身是否可变还要继续检查。
快照解决读取的一致性,更新规则则取决于业务。如果两个写者都从版本 1 计算新配置,再分别覆盖 current,后写入者可能抹掉前一个写者的修改。需要合并更新时,使用同一锁串行计算与发布,或通过原子引用 CAS 校验旧版本并重算。只有业务允许整份配置直接覆盖时,最后写入生效才是可接受的规则。
构造完成、安全发布与延迟初始化
final 保存初始化结果,发布负责对象交接
final 字段具有特殊的初始化安全语义。构造时赋值,且构造过程中没有把对象暴露给其他线程,其他线程在构造完成后取得该对象,就能看到正确初始化的 final 字段。这个保护还涉及经 final 引用访问到的、构造完成时已准备好的对象或数组内容。
JLS 17.5用构造器退出时的 freeze 动作描述它;退出可以是正常或异常退出,具体保证还取决于引用的取得路径。常用的安全写法是先完成构造,再向外提供对象。不要在构造器内把 this 放进公共集合、注册会立即回调的监听器,或启动捕获 this 的线程。
final 的初始化保护与普通 happens-before 交接要分开理解。即使对象引用通过有数据竞争的方式传递,符合条件的 final 字段仍有初始化保护;但引用可能一直未被读到,同一对象的普通字段也没有因此全部获得保护。因此,实际共享对象时仍应通过 volatile、锁、类初始化或并发组件完成交接。
对象发布后,如果其内部还有可变状态,就要为后续修改设计同步方式。final Map 字段不能阻止调用 put,安全发布也不会让两个并发 put 自动串行。
优先使用已有的初始化机制
只需按类延迟创建一个固定实例时,静态 holder 的写法较短:
public final class LazySettings {
public record Settings(String region, int timeoutMillis) {}
private LazySettings() {}
private static final class Holder {
static final Settings INSTANCE = new Settings("local", 1000);
}
public static Settings get() {
return Holder.INSTANCE;
}
}保存为 LazySettings.java,可沿用前面的目录编译:
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" LazySettings.java成功时没有输出。首次调用 get 触发 Holder 初始化,并发调用遵守类初始化的同步规则;读取方取得已经初始化的 INSTANCE。机制见 JLS 12.4.2。这里的唯一性按定义该类的类加载器区分,不是整台机器只有一个实例。初始化失败也有类初始化失败语义,不能把它当作会自动重试的远程配置加载器。
如果初始化取决于某个对象实例的状态,才可能需要双重检查锁定(DCL)。完整的最小结构如下,保存为 LazyClient.java:
public final class LazyClient {
public record Client(String endpoint) {}
private volatile Client instance;
public Client get() {
Client local = instance;
if (local == null) {
synchronized (this) {
local = instance;
if (local == null) {
local = new Client("local");
instance = local;
}
}
}
return local;
}
}"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" LazyClient.java第一次检查避免已初始化时仍进入锁。第二次检查处理“等待锁期间,另一个线程已完成初始化”的情况。instance 的 volatile 写把构造前后的准备工作交接给不加锁的读取路径;local 保存这次读取选中的引用。锁保护首次创建,volatile 保护锁外读取,两者承担不同部分。
Client 在这里是不可变值;替换成带连接、缓存等可变状态的真实客户端后,还需要遵循它自身的线程安全和关闭规则。DCL 只管理实例的创建与发布。
队列和任务提交也能交接数据
并发组件已经定义了常用的数据交接关系:
| 使用动作 | 可以依赖的交接 | 后续仍需处理的情况 |
|---|---|---|
| BlockingQueue 放入并取出元素 | 放入前准备的内容,交给随后取得该元素的线程 | 放入后继续修改同一元素 |
| ExecutorService 提交任务 | 提交前的准备动作,交给任务执行线程 | 提交后调用方继续改动任务捕获的对象 |
| Future.get 取得计算结果 | 计算动作,交给取得结果后的调用方 | 其他线程继续修改返回的可变结果 |
| CountDownLatch 成功 await | countDown 前的动作,交给成功等待后的线程 | 把超时返回误当成等待成功 |
对应约定见 BlockingQueue、ExecutorService和 CountDownLatch。
例如,生产者准备好订单消息再 put,消费者 take 后可以读取准备好的内容。若生产者 put 后仍改同一消息的金额,消费者与生产者又开始竞争这个可变字段。实用的处理方式是交接不可变消息,或者明确交接后原持有者停止修改。
Future 的异常、超时与取消还涉及执行生命周期。get(timeout) 抛出超时异常时,没有取得计算结果;取消后的结果状态也不能用来确认工作线程完成清理。相关处理见线程状态、中断、取消与超时。
VarHandle 如何选择访问顺序
访问模式由每次操作确定
VarHandle 可以定位实例字段、静态字段或数组元素,并在每次访问时选择模式。它适合实现并发组件;普通应用通常优先用 volatile、锁和已经封装好的原子类。
| 模式 | 读 / 写方法 | 关键约束 |
|---|---|---|
| plain | get / set | 普通访问;不提供跨线程发布顺序 |
| opaque | getOpaque / setOpaque | 保证单次位级原子性与同一变量的一致访问顺序;不承担其他字段的发布 |
| acquire / release | getAcquire / setRelease | 匹配的获取读取把发布前动作连到读取后的动作 |
| volatile | getVolatile / setVolatile | 在获取/发布之外,volatile 操作之间还有全序要求 |
规范见 VarHandle API。plain 模式对 long/double 仍有前述原子性限制。访问模式会覆盖字段声明处的内存顺序:即使字段声明为 volatile,通过 VarHandle.get 读取时采用的也是 plain 语义。
配对发布普通载荷
下载包的 acquire 模式通过一个普通 int signal 发布 payload。SIGNAL 是指向 Message.signal 的 VarHandle:
SIGNAL = MethodHandles.lookup()
.findVarHandle(Message.class, "signal", int.class);关键操作分布在两个线程中:
写者 读者
message.payload = 42
SIGNAL.setRelease(message, 1) ───→ SIGNAL.getAcquire(message) 读到 1
读取 message.payloadrelease 之前的载荷写入,通过匹配的 acquire 读取交给读者。本例 signal 只有 0 到 1 的一次转换,读者确认取得 1 后才读 payload。
运行两个发布模式:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab volatile
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MemoryModelLab acquire预期输出:
mode=volatile reader=42
mode=acquire reader=42两个模式都先启动真正的读取线程,再由主线程写入载荷。起步门闩从读者通知写者,发生在 payload 写入之前,因此没有替代要观察的写者到读者发布。
把读取侧改成 getOpaque 或 get,原有发布推理便缺少获取侧;把 Message 换成另一个实例,也不再访问同一个 signal。出现 WrongMethodTypeException 时,先检查字段类型、实例坐标以及返回值转换是否一致,而不是归因于“内存不可见”。
内存屏障与机器指令处在实现层
JMM 约束程序可观察到的结果。JIT 与处理器可以调整实际执行,只要这些结果仍符合规则。所谓“重排序”,可能来自编译优化,也可能来自处理器执行和内存访问机制;从一次输出反推某条机器指令的位置通常不成立。
把 volatile 解释成“每次都绕过缓存、直接读取主内存”也过于具体。不同处理器和 JVM 会用不同指令与缓存一致性机制实现同样的语言语义。性能取决于访问频率、共享写入、处理器和代码形态,应对实际工作负载测量。
VarHandle 的 fence 方法提供局部顺序约束,单独放一个 fullFence 并没有指定另一个线程何时取得哪份数据。组件协议仍需说明发布载体与读取动作。原子读改写还有独立的方法语义,例如弱 CAS 可以伪失败;它们应随具体算法分析,不宜把名称相近的方法任意互换。
定位共享状态错误与验证修复
从实际冲突的动作找缺口
首先列出哪个对象被哪些线程共享,再标出读写动作。线程栈能说明某一刻线程在哪里,无法直接回答某字段的读取得了哪次写入。
| 现象 | 优先核查 | 修复后观察 |
|---|---|---|
| 停止标志长期未被观察 | 标志是否有同步访问,线程是否其实阻塞在别处 | 区分观察标志与退出阻塞,使用有界等待 |
| 计数比提交次数少 | 更新是否拆成读取与写回 | 相同提交数下验证原子更新 |
| 地址由两个配置版本拼成 | 分别更新字段,或重复读取快照引用 | 单次操作固定一份快照 |
| 取得对象后字段仍是默认值 | 构造逃逸、普通引用无同步发布 | 先完成构造,再通过明确入口交接 |
| 加日志后问题消失 | 日志改变了调度,是否还引入锁 | 保留原同步结构,用受控交错复现 |
对于“线程迟迟不结束”,还要回到线程状态、中断、取消与超时判断阻塞、取消和清理;给字段加 volatile 不能唤醒阻塞 I/O。
编译产物可以辅助确认声明。对前面已编译的 Publication 执行:
"$JAVA_HOME/bin/javap" -p -v -classpath "$LAB_OUT" Publication在 ready 字段附近应看到 ACC_PRIVATE, ACC_VOLATILE。这能核对实际加载前的 class 是否带 volatile 标记;它没有展示线程交错,也不是 JIT 机器指令报告。命令选项见 javap 工具文档。
完整运行下载包
在 Linux 普通用户可写目录中解压 ZIP 后,进入 jmm-volatile 目录。需要完整 JDK、Bash 与 GNU coreutils 的 timeout;无需 Maven、数据库或网络服务。
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
bash run.sh把 JAVA_HOME 改成实际 JDK 安装路径。脚本打印 Java 版本,使用 --release 17 严格编译,在临时目录运行 Publication 和七种 MemoryModelLab 模式,最后输出:
PASS memory-model-labJava 17 与 25 都可以执行这份源码。实测环境为 Linux 容器中的 Eclipse Temurin 17.0.20+8 和 25.0.4+7;版本行应按本机实际结果阅读。
也可以在已配置 Docker 的 Linux 主机上运行。以下命令在 jmm-volatile 目录执行,只挂载该目录,容器使用 UID/GID 10001:10001,不需要写回源文件:
LAB_SOURCE="$(pwd -P)"
JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --pull never --rm --network none --read-only \
--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$LAB_SOURCE,dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
"$JDK_IMAGE" bash /lab/run.shpull 需要访问镜像仓库;运行阶段关闭网络。源目录应允许容器中的用户读取与遍历。若要验证 Java 17,将镜像换成 eclipse-temurin:17.0.20_8-jdk 后重新拉取和运行。
脚本每个模式最多运行 20 秒,内部等待也有超时。出现 handshake timeout、worker still running 或结果断言失败时,保留完整模式名、异常、JDK 与平台信息,先检查资源限制、源码是否改动和运行方式。不要延长无限等待掩盖失败。若提示找不到 javac、timeout 或文件无权限,先修正环境,再判断程序行为。
手动编译产生的 LAB_OUT 可以在实验结束后清理,只删除本次临时编译目录:
case "$LAB_OUT" in
/tmp/jmm-learning.*) rm -rf -- "$LAB_OUT" ;;
*) printf '%s\n' 'LAB_OUT 不是本次临时目录,未删除' ;;
esac
unset LAB_OUTrun.sh 使用自己的临时目录并自动清理,不会删除下载的源码。
哪些结果需要压力测试
受控交错适合重现丢失更新、混合配置等具体问题;它主动安排了线程先后,不能覆盖所有弱内存执行。没有同步的读取偶尔出现默认值,可以暴露缺陷;连续运行都得到期望值,则只能说明这些运行没有遇到异常。
OpenJDK jcstress用于并发正确性压力实验,可以声明多线程动作、收集允许与禁止的结果,并在不同 JVM 与硬件上增加探索机会。它适合检验自定义并发组件的假设,但有限次数的测试仍不等于形式证明。
应用代码的修复依据应同时包含清晰的共享数据规则和能够复现的问题测试。对普通业务,优先缩小共享可变对象的范围:使用不可变消息、一次性快照、队列交接以及局部变量,往往比设计新的弱内存协议更容易维护。
权威资料与规范地址
查语言规则时使用 JLS 章节,查组件交接条件时使用对应 API;工具页面提供命令参数与实验框架说明。
| 资料 | 完整地址 |
|---|---|
| JLS 17.4.1 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.4.1 |
| Thread.onSpinWait | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Thread.html#onSpinWait() |
| JLS 17.3 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.3 |
| JLS 17.4.4—17.4.5 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.4.4 |
| JUC 内存一致性说明 | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/package-summary.html |
| JLS 17.4.5 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.4.5 |
| AtomicInteger API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html |
| JLS 17.7 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.7 |
| JLS 17.5 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.5 |
| JLS 12.4.2 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-12.html#jls-12.4.2 |
| BlockingQueue | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/BlockingQueue.html |
| ExecutorService | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ExecutorService.html |
| CountDownLatch | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CountDownLatch.html |
| VarHandle API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/VarHandle.html |
| javap 工具文档 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/javap.html |
| OpenJDK jcstress | https://github.com/openjdk/jcstress |
