JMM · HAPPENS-BEFORE · VOLATILE · SYNCHRONIZED · FINAL · 重排序 · 内存屏障 · 双重检查锁
一个线程写的值,另一个线程什么时候看得到
编译器和 CPU 为了快,会把你的代码调换顺序、把变量先放在寄存器和写缓冲区里,单线程看不出来,多线程就会看到“不可能”的结果。Java 内存模型(JMM)不承诺所有代码都按顺序执行,只承诺一件事:只要两次访问之间有 happens-before 关系(用 volatile、锁、线程启动和 join 等建立),后一次一定能看到前一次的写;没有数据竞争的程序,结果就和按顺序一条条执行一样。
假设场景线上一个定时任务,用一个 boolean 标志让工作线程停下,结果怎么也停不下来;代码评审时有人说双重检查的单例必须加 volatile,你说不清为什么。面试里“volatile 和 synchronized 的区别”“happens-before 是什么”也必问。注意它和“JVM 内存结构”是两回事,后者讲堆、栈、元空间,见 JVM 内存结构。标实测的数字来自 ② 的程序,在 2 核 ARM 云主机上用 JDK 17 跑。
①要解决的问题:写了,不等于别人看到了
线程 1 先准备数据、再把 ready 设为 true;线程 2 等到 ready 为 true 就去读数据。看起来天经地义,中间却有三个地方可能“不按你写的来”。
可见性
一个线程写的值,别的线程什么时候能看到。
有序性
一个线程里的几次读写,别的线程看到的顺序,是不是和代码里写的一样。
原子性
一个操作会不会被切成几半、中间被别的线程插进来。count++ 就是读、加、写三步。
②朴素做法:以为“写了就看得到、按顺序执行”
最自然的假设叫“顺序一致性”:所有线程的读写排成一条线,每个线程内部按代码顺序。下面两个实验把这个假设打破了。
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;
/** 亲眼看到 Java 内存模型要解决的两个问题:看不见、乱了序。JDK 8 / 17 / 21 都能跑。
* java JmmDemo stop 不加 volatile 的停止标志
* java JmmDemo stopv 加了 volatile
* java JmmDemo sb [n] 两个线程各写一个、读另一个,统计四种结果
* java JmmDemo sbv [n] 同上,但 x、y 用 volatile 语义读写 */
public class JmmDemo {
// ---------- 1. 可见性 ----------
static boolean stop; // 普通字段
static volatile boolean vstop; // volatile 字段
static void stopDemo(final boolean useVolatile) throws Exception {
Thread worker = new Thread(() -> {
long n = 0;
if (useVolatile) { while (!vstop) n++; }
else { while (!stop) n++; }
System.out.println("工作线程看到了停止标志,退出(循环了 " + n + " 次)");
});
worker.setDaemon(true);
worker.start();
Thread.sleep(1000); // 让循环先跑一会儿,被 JIT 编译
if (useVolatile) vstop = true; else stop = true;
System.out.println("主线程已把标志设为 true");
worker.join(3000);
System.out.println(worker.isAlive() ? "3 秒后工作线程还在跑:它一直没看到这次写" : "工作线程已结束");
}
// ---------- 2. 重排序:store buffering ----------
static void sbDemo(final int n, final boolean useVolatile) throws Exception {
final int[] x = new int[n], y = new int[n], r1 = new int[n], r2 = new int[n];
final AtomicIntegerArray vx = new AtomicIntegerArray(n), vy = new AtomicIntegerArray(n);
final AtomicInteger barrier = new AtomicInteger();
Thread a = new Thread(() -> {
for (int i = 0; i < n; i++) {
await(barrier, i);
if (useVolatile) { vx.set(i, 1); r1[i] = vy.get(i); } // volatile 写 x,再 volatile 读 y
else { x[i] = 1; r1[i] = y[i]; } // 普通写 x,再普通读 y
}
});
Thread b = new Thread(() -> {
for (int i = 0; i < n; i++) {
await(barrier, i);
if (useVolatile) { vy.set(i, 1); r2[i] = vx.get(i); }
else { y[i] = 1; r2[i] = x[i]; }
}
});
long t0 = System.nanoTime();
a.start(); b.start(); a.join(); b.join(); // join 之后,主线程能看到两个线程的全部结果
int[] c = new int[4];
for (int i = 0; i < n; i++) c[r1[i] * 2 + r2[i]]++;
System.out.printf("%d 次(%d ms):r1=1,r2=1 %d 次 | r1=0,r2=1 %d 次 | r1=1,r2=0 %d 次 | r1=0,r2=0 %d 次%n",
n, (System.nanoTime() - t0) / 1_000_000, c[3], c[1], c[2], c[0]);
}
// 两个线程每一轮都在这里会合,然后几乎同时开始这一轮
static void await(AtomicInteger barrier, int round) {
barrier.incrementAndGet();
while (barrier.get() < 2 * (round + 1)) { /* 自旋等另一个线程 */ }
}
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "stop";
int n = args.length > 1 ? Integer.parseInt(args[1]) : 1_000_000;
System.out.println("Java " + System.getProperty("java.version") + "," + Runtime.getRuntime().availableProcessors() + " 核,模式 " + mode);
switch (mode) {
case "stop": stopDemo(false); break;
case "stopv": stopDemo(true); break;
case "sb": sbDemo(n, false); break;
case "sbv": sbDemo(n, true); break;
default: System.out.println("未知模式");
}
}
}
$ java JmmDemo stop 主线程已把标志设为 true 3 秒后工作线程还在跑:它一直没看到这次写 $ java JmmDemo stopv 主线程已把标志设为 true 工作线程看到了停止标志,退出(循环了 1902526033 次) $ java JmmDemo sb 1000000 1000000 次(153 ms):r1=1,r2=1 19 次 | r1=0,r2=1 355235 次 | r1=1,r2=0 353629 次 | r1=0,r2=0 291117 次 $ java JmmDemo sbv 1000000 1000000 次(202 ms):r1=1,r2=1 263077 次 | r1=0,r2=1 368155 次 | r1=1,r2=0 368768 次 | r1=0,r2=0 0 次
实验 1:主线程写了 stop = true,工作线程 3 秒都没看到,因为 JIT 把循环里对 stop 的读提到了循环外面(①的第 3 处)。实验 2:两个线程都“先写后读”,却有 29% 的情况两个都读到 0,因为写还在写缓冲区里排队,读已经先做了(①的第 2 处)。
缺的那块:一套规则,告诉程序员“怎么写才保证看得到、顺序对”,同时告诉编译器和 CPU“哪些地方不能乱动”。这就是 Java 内存模型。
③几种思路:内存模型可以定多严
硬件和语言都要回答同一个问题:允许多少重排。定得越严越好懂,但越慢;定得越松越快,但越难写对。
| 模型 | 允许什么 | 谁在用 | 代价 |
|---|---|---|---|
| 顺序一致性 | 不允许任何重排,所有线程看到同一个全局顺序 | 理论上的理想模型 | 写缓冲区、寄存器缓存、指令重排都不能用,慢很多 |
| x86 的 TSO | 只允许“先写后读”被重排成“先读后写”(写缓冲区造成的) | Intel、AMD | 比较好懂;实验 2 那种结果在 x86 上也会出现 |
| ARM、RISC-V 的弱模型 | 读读、读写、写写、写读四种都可能重排 | 手机、苹果 M 系列、云上的 ARM 服务器(本页的实验机) | 最快,最难写对,靠屏障指令约束 |
| 语言级:无数据竞争就顺序一致 | 没标记同步的地方随便优化;标记了同步(volatile、锁)的地方按规则排序 | Java(JMM)、C++11、Go | 程序员要把共享变量的同步标出来 |
Java 选最后一种:不管底下是 x86 还是 ARM,同一份程序只要没有数据竞争,结果都和顺序执行一样;具体怎么做到,由 JVM 在每种 CPU 上插不同的屏障指令。本页学 JMM 的规则(happens-before)和三个关键字各保证什么。
④关键洞察:重排序不可怕,没有 happens-before 才可怕
先看重排序从哪来、为什么单线程看不出来;再看 JMM 用来约束它的那张“先后关系图”;然后看为什么 Java 只保证“没有数据竞争的程序”;最后把 volatile、synchronized、final 各管什么对齐。
先知道这些
数据竞争
两个线程访问同一个变量,至少一个是写,而且两次访问之间没有 happens-before 关系。JMM 的保证都以“没有数据竞争”为前提。
as-if-serial
编译器和 CPU 怎么重排都行,只要本线程看到的结果和按顺序执行一样。别的线程不在这个承诺里。
缓存是一致的
多核 CPU 的缓存之间有一致性协议,同一地址不会长期存两个不同的值。问题出在写缓冲区、乱序执行和编译器优化。
JIT
热点代码会被 JIT 编译成机器码,编译时会做循环外提、删掉“没用”的读写等优化,所以有的问题只在跑了一会儿之后才出现。
洞察 1重排序从哪来:三层都在“帮你优化”
洞察 2happens-before:一张“谁一定先于谁”的图
JMM 不描述机器怎么执行,只规定一组“先后关系”。如果操作 A happens-before 操作 B,那么 A 的写对 B 一定可见,而且 B 看到的 A 之前的一切也都生效了。关系能传递,所以把几条边连起来就能推出结论。
能建立 happens-before 的几条规则(JLS 17.4.5)
- 程序顺序:同一个线程里,前面的操作先于后面的操作。
- 锁:对一把锁的解锁,先于之后任何线程对同一把锁的加锁。
- volatile:对一个 volatile 变量的写,先于之后对同一个变量的读。
- 线程启动:
t.start()先于线程 t 里的任何操作。 - 线程结束:线程 t 里的所有操作,先于别的线程从
t.join()返回。②的实验就是靠 join 拿到结果数组的。 - 传递性:A 先于 B、B 先于 C,则 A 先于 C。
另外还有中断(interrupt 先于被中断线程发现中断)、对象构造结束先于它的 finalizer 等。并发包里的工具都在这些规则上搭起来:比如 ConcurrentHashMap.put 先于随后读到这个值的 get,线程池里 submit 先于任务开始执行。
洞察 3为什么只保证“没有数据竞争的程序”设计对比
被放弃的:所有读写都顺序一致
- 每次读写共享变量都要插屏障,写缓冲区形同虚设。
- JIT 不能再把变量放在寄存器里、不能把读提到循环外,几乎所有优化都要关掉。
- 绝大多数变量只有一个线程在用,却都要为极少数共享变量付钱。
选中的:无数据竞争就顺序一致(DRF-SC)
- 程序员用 volatile、锁、并发工具把共享变量的同步标出来。
- JVM 只在这些标记处插屏障、禁止相关的重排,其余代码照常优化。
- 换来的承诺:只要没有数据竞争,程序的结果就和按顺序一条条执行一样,不用去想底下是 x86 还是 ARM。
决定这个设计的约束有两条:一是性能,共享变量在程序里只占极少数;二是“一次编写到处运行”,x86 和 ARM 允许的重排完全不同。所以 Java 不规定机器怎么执行,只规定 happens-before;JVM 在每种 CPU 上用不同的屏障指令去兑现它(第三圈)。代价是:有数据竞争的程序,JMM 只给很弱的保证,结果可能随 CPU、JDK 版本、JIT 是否编译而变。
洞察 4volatile、synchronized、final 各保证什么
| 可见性 | 有序性 | 原子性 | 代价 | |
|---|---|---|---|---|
| volatile | 写之后的读一定看到 | volatile 写之前的读写不会被排到它后面;volatile 读之后的不会被排到它前面 | 只有单次读或写是原子的,count++ 不是 | 读几乎免费;写要一道屏障 |
| synchronized | 解锁前的写,下一个拿同一把锁的线程看得到 | 临界区里的操作不会被排到锁外面 | 整个代码块 | 有竞争时要排队;锁不同就没有保证 |
| final 字段 | 构造器结束后,任何线程拿到这个对象,都能看到 final 字段的初始值 | 只管这一件事 | — | 几乎免费;前提是构造器里 this 没有提前逃逸 |
Atomic*、VarHandle | 同 volatile | 同 volatile,9 起还能选更弱的模式 | 单个变量的 CAS、自增 | 竞争激烈时 CAS 反复重试 |
volatile boolean running = true; // 状态标志:一个线程写、别的线程读
private final Map<String, String> config; // 构造器里赋值,之后只读:不可变对象可以放心共享
synchronized void add(Item i) { items.add(i); } // 读和写都要加同一把锁
synchronized List<Item> snapshot() { return new ArrayList<>(items); }
AtomicLong counter = new AtomicLong(); // 计数:用原子类,不用 volatile long++
counter.incrementAndGet();
class Config { // 延迟加载的单例:静态内部类,类初始化天然线程安全
private static class Holder { static final Config INSTANCE = new Config(); }
static Config get() { return Holder.INSTANCE; }
}
⑤手推一遍:双重检查锁为什么要加 volatile
经典的延迟加载单例。new Singleton() 在机器层面是三步:分配内存、执行构造器写字段、把地址写进 instance。用下面的分步演示,分别推一遍不加和加了 volatile 的情况。
class Singleton {
private static volatile Singleton instance; // 演示里切换“有 / 没有 volatile”
private int port;
private Singleton() { port = 8080; }
static Singleton get() {
if (instance == null) { // 第一次检查:不加锁,图的是快
synchronized (Singleton.class) {
if (instance == null) // 第二次检查:防止两个线程都创建
instance = new Singleton(); // 分配内存 → 构造器写 port → 写入 instance
}
}
return instance;
}
}
⑥边界和变体:哪里最容易以为自己写对了
第一圈的坑每条标了根源在 ④ 的哪一条;第二圈是团队里该怎么写;第三圈讲 volatile 在 CPU 上插了什么、VarHandle 的几种模式和各版本的情况。
注意事项
- 现象
volatile int count,两个线程各加 100 万次,结果小于 200 万。- 根源
- volatile 只保证单次读、单次写的可见性和顺序。
count++是“读、加一、写回”三步,两个线程可能读到同一个旧值,各自加一后写回,丢掉一次。 - 做法
- 计数用
AtomicLong,高并发计数用LongAdder;“先判断再修改”这类复合操作用锁。
- 现象
- 极少数情况下拿到一个字段还是默认值的单例,偶发 NPE 或配置为空,几乎无法复现。
- 根源
- “把地址写进 instance”可以被排到构造器之前;第一次检查不加锁,和写之间没有 happens-before,另一个线程可能看到非 null 却没初始化完的对象。
- 做法
instance加volatile;更简单的是用静态内部类(Holder)或枚举单例,交给类初始化的线程安全保证。
- 现象
- 改了
running = false,后台线程照样跑;本地调试时又是好的。② 实测 - 根源
- 循环被 JIT 编译后,对普通字段的读被提到了循环外面,相当于
if (!stop) while (true)。调试时代码没被 JIT 编译,所以看不出来。 - 做法
- 标志用 volatile,或者用
AtomicBoolean;线程池里的任务优先用中断(Future.cancel(true))配合检查Thread.interrupted()。
- 现象
- “写入要防止并发,所以 set 方法加了 synchronized;读只是看一眼,不用加。”结果读到旧值,或者读到写了一半的状态。
- 根源
- 锁的 happens-before 只发生在“解锁”和之后对同一把锁的加锁之间。读线程不加锁,就和写线程之间没有任何边,还是数据竞争。
- 做法
- 读写都用同一把锁;读多写少用
ReadWriteLock,或者把状态做成不可变对象,用一个 volatile 引用整体替换。
- 现象
- 在构造器里注册监听器、启动线程、把
this放进静态集合,别的线程偶尔看到 final 字段是 null 或 0。 - 根源
- final 的保证是“构造器结束后才拿到引用的线程,一定看到 final 字段的初始值”。构造器还没结束就把
this交出去,这个保证就不成立。 - 做法
- 构造器里只赋值;注册、启动线程放到构造完成之后(工厂方法、
@PostConstruct、start()方法)。
- 现象
- 循环里加一句
System.out.println或Thread.sleep,停止标志就能被看到了,于是以为问题解决。 - 根源
println内部有synchronized,会妨碍 JIT 把读提到循环外,顺带让问题不再出现。但规范没有给你任何保证,换个日志框架、换个 JDK 版本、换到 ARM 上,可能又回来了。- 做法
- 先用 happens-before 推一遍有没有边;没有边就是数据竞争,用 volatile、锁或并发工具把边建起来,别依赖巧合。
使用规范
局部变量、ThreadLocal / ScopedValue、final 字段组成的不可变对象,天然没有数据竞争。
所有可见性问题
ConcurrentHashMap、AtomicLong、LongAdder、BlockingQueue、线程池,它们的文档里写明了 happens-before。
自己写同步写错
“一个写、多个读”的布尔、引用用 volatile;读改写用 CAS 或锁。
坑 1、坑 3
静态初始化器里赋值;存进 volatile 字段或 AtomicReference;存进 final 字段;存进由锁保护的字段或并发容器。
别的线程看到半初始化的对象(坑 2、坑 5)
用静态内部类或枚举;Spring 里交给容器管理。
坑 2
OpenJDK 的并发压力测试框架,专门跑 ② 那种实验,会把各种结果出现的次数统计出来。普通单元测试几乎测不出数据竞争。
“我跑了一万遍都没问题”
更深一层
volatile 在 CPU 上插了什么
JSR-133 的实现指南把屏障分成四种:LoadLoad、LoadStore、StoreStore、StoreLoad。volatile 写之前要 StoreStore、之后要 StoreLoad;volatile 读之后要 LoadLoad 和 LoadStore。
- x86 本来只会把“先写后读”重排,所以只有 StoreLoad 需要真指令:HotSpot 在 volatile 写后加一条带
lock前缀的指令,volatile 读不需要额外指令。这就是“volatile 读几乎免费”。 - ARM 四种重排都可能,HotSpot 用
ldar/stlr这类自带获取、释放语义的读写指令。所以同一段有数据竞争的代码,在 ARM 上更容易出事(② 的实验机就是 ARM)。
VarHandle:比 volatile 更细的几档(JDK 9)
对同一个变量可以按需选内存顺序,从弱到强:
- plain:普通读写,没有任何保证。
- opaque:保证最终能看到、不会被优化掉,但不保证和其他变量的顺序。
- release / acquire:写之前的操作不会排到写后面,读之后的不会排到读前面,“消息传递”够用了,比 volatile 便宜(不需要 StoreLoad)。
- volatile:最强,全局一个顺序。
业务代码一般用不到,JDK 的并发类、Netty 等底层库在用,也是替代 sun.misc.Unsafe 的官方方式。
为什么 String 能随便在线程之间传
String 存内容的字段是 final,构造完成后不再修改(缓存哈希值的字段是例外,但它每次重算出来都一样),加上 final 字段的初始化保证,任何线程拿到它都能看到完整的内容,不需要任何同步。这就是“不可变对象天然线程安全”在 JMM 里的根据。
版本差异:JMM 自 JDK 5 起基本没变
- JDK 5(JSR 133)重新定义了 JMM:修好了旧模型下 final 字段可能被看到变化、volatile 不能阻止与普通读写重排等问题,双重检查锁从此“加 volatile 就对”。
- JDK 9 加了 VarHandle 的几种访问模式,规范的核心规则没变。
- JDK 8、17、21、25 写并发代码的规则一样;虚拟线程(21)也遵守同一套 happens-before,
start、join规则同样适用。
和 JMM 同年加入的 java.util.concurrent 见 JDK 页的“地基”。
面试官会怎么问
volatile 和 synchronized 有什么区别?
volatile 只管可见性和有序性,不保证复合操作的原子性,不会阻塞;synchronized 三样都管,还提供互斥,有竞争时会阻塞。前者适合状态标志、一写多读的引用,后者适合需要“读改写”整体不被打断的地方。
← ④ 洞察 4
什么是 happens-before?举几条规则。
JMM 定义的“先后关系”:A happens-before B,则 A 的结果对 B 可见,且 A 不会被排到 B 之后。规则有程序顺序、同一把锁的解锁先于加锁、同一个 volatile 变量的写先于读、start 先于线程内操作、线程内操作先于 join 返回、传递性等。
← ④ 洞察 2
指令重排序是什么?为什么单线程感觉不到?
编译器和 CPU 为了性能调换没有依赖关系的读写顺序。它们遵守 as-if-serial:只保证本线程看到的结果和按顺序执行一样,所以单线程感觉不到;另一个线程同时观察时才会看到“不可能”的结果。
← ④ 洞察 1
下一圈
- 已做:JMM 下一圈:jcstress、CPU 缓存与伪共享,本页 ↺ 的预期在 ARM 上用 jcstress 真跑了一遍
- jcstress:怎么写一个并发压力测试,读懂它的结果表
- CPU 缓存一致性(MESI)、写缓冲区和失效队列
- C++ 的 memory_order 和 Java 的对照
- AQS、ConcurrentHashMap 源码里 volatile 和 CAS 是怎么配合的
- 伪共享和
@Contended:两个变量挤在同一个缓存行 - final 字段的冻结语义和反序列化(与 JDK 26 的 JEP 500 有关)
↺回到第⑤步:改 JmmDemo
预期是按 ④ 推出来的,没有在本机跑过。并发实验每次结果都会有波动,看趋势。
练习 1 · 先猜,再在循环里打日志
在 stop 模式的循环里加一句 System.out.print(""),不加 volatile,再跑。先猜能不能停下来。
预期:多半能停下来:print 里的 synchronized 让 JIT 不再把读提到循环外。但这是巧合,不是保证(坑 6)。
用到:坑 6、④ 洞察 1
练习 2 · volatile 计数
加一个模式:static volatile int count,两个线程各 count++ 100 万次,join 后打印。再换成 AtomicInteger 比一比。
预期:volatile 版每次都小于 200 万,而且每次不一样;AtomicInteger 版每次都是 200 万。
用到:坑 1
练习 3 · 只让一个变量是 volatile
sb 模式里,只把 x 改成 volatile 语义(vx),y 仍是普通数组,再跑 100 万次。先猜 r1=0、r2=0 还会不会出现。
预期:还会出现。线程 B 那边是普通写 y、普通读 x,它自己的“先写后读”照样可以被重排;要禁止这种结果,两边都得是 volatile。
用到:④ 洞察 2、第三圈屏障
1. 用一句话说出 Java 内存模型的精华。
编译器和 CPU 会重排、会延迟写,JMM 不禁止这些,只规定 happens-before:用 volatile、锁、start / join 等建立先后关系的地方一定看得到、顺序对;没有数据竞争的程序,结果和按顺序执行一样。
← 开头 + ④ 洞察 3
2. “写了就看得到、按顺序执行”这个朴素假设,被哪两个实验打破了?
不加 volatile 的停止标志,工作线程 3 秒都没看到(JIT 把读提到了循环外);两个线程都先写后读,100 万次里有 29% 两个都读到 0(写还在写缓冲区里)。
← ②
3. 双重检查锁不加 volatile 会拿到半初始化对象,根源是 ④ 的哪一条?
洞察 2:第一次检查不加锁,和线程 1 写 instance 之间没有 happens-before;而“写 instance”可以被排到构造器之前(洞察 1)。加了 volatile,写 instance 之前的构造器写入,对读到它的线程一定可见。
← ④ 洞察 1、2 + ⑤
4. 预测:线程 1 在 synchronized(lockA) 里写 x = 1;线程 2 在 synchronized(lockB) 里读 x。线程 2 一定能看到 1 吗?
不一定。锁规则只连接同一把锁的解锁和加锁,lockA 和 lockB 之间没有边,这仍然是数据竞争。
← ④ 洞察 2 锁规则
5. 预测:主线程写好一个普通的 HashMap,再 new Thread(...).start(),新线程里读这个 Map,能看到全部数据吗?如果新线程是在 Map 写完之前就启动了呢?
先写完再 start:能,start 先于新线程里的所有操作。先 start 再写:不能保证,之后的写和新线程之间没有边,而且 HashMap 本身也不是线程安全的。
← ④ 洞察 2 线程启动规则
6. 预测:② 的 sb 实验换到一台 x86 机器上,普通变量时 r1=0、r2=0 还会出现吗?
会。x86 的 TSO 唯一允许的重排就是“先写后读”,而写缓冲区正是造成这个结果的原因。比例可能和 ARM 上不同,但不会是 0;只有两边都用 volatile 才会是 0。
← ③ 硬件模型 + 第三圈