主题
JMM 内存模型
一句话:JMM 用 happens-before 给多线程程序定义「哪些写对哪些读可见、哪些重排不允许」,
volatile买的是可见性和有序性,不是原子性;双重检查锁必须靠它封住构造逸出。
适用 JDK 17 / JDK 21。本文只讲 JMM 的可见性、有序性和 DCL;锁的实现见 03-并发编程-AQS与锁,堆的分代布局见 01-JVM内存模型与GC。
为什么需要
单线程里「先写后读」看起来天经地义,多线程里却不成立。原因有三层:CPU 有缓存,写可能还留在 store buffer;编译器和 CPU 会重排没有依赖的指令;对象构造也可能被重排。
如果没有统一的契约,同一份 Java 代码在不同 CPU、不同 JIT 版本上可能表现不同。JMM 就是这份契约:它不规定硬件怎么做,只规定程序能看到什么结果。
核心机制
happens-before 的八条规则
如果 A happens-before B,那么 A 的结果对 B 可见,且 A 在 B 之前发生。常用规则:
| 规则 | 含义 |
|---|---|
| 程序顺序 | 同一线程内,前面的操作先于后面的操作 |
| 监视器锁 | 解锁先于后续对同一锁的加锁 |
| volatile | 对 volatile 变量的写先于后续对它的读 |
| 线程启动 | Thread.start() 先于新线程中的任何操作 |
| 线程终止 | 线程中的所有操作先于其他线程从 join() 返回 |
| 中断 | interrupt() 先于被中断线程检测到中断 |
| 终结器 | 对象构造完成先于 finalize() 开始 |
| 传递性 | A 先于 B、B 先于 C,则 A 先于 C |
happens-before 不等于「代码按时间顺序执行」。它只保证可见性和有序性;没有这条链的两个操作,即使墙上时钟一前一后,也不能假设对方能看到。
volatile 到底保证了什么
- 可见性:volatile 写会把值发布到主内存,volatile 读会从主内存重新读取;实现上通过内存屏障阻止读写重排。
- 有序性:volatile 写之前的操作不能被重排到写之后,volatile 读之后的操作不能被重排到读之前。
- 不保证原子性:
count++是读、加、写三步,两个线程各加一次仍可能只加一次。计数用AtomicInteger、LongAdder或锁。
volatile 适合「一个线程写、多个线程读」的状态标志、配置快照、DCL 的实例引用。它不适合需要「读-改-写」的复合操作。
DCL 为什么必须加 volatile
错误写法:
java
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}new Singleton() 在字节码层面至少分三步:分配内存、调用构造器初始化、把引用赋给 instance。编译器和 CPU 可以把「赋值」提前到「初始化」之前。线程 A 执行到赋值后被切走,线程 B 在第一次检查时看到 instance != null,直接返回一个字段还没初始化完的对象。
正确写法:
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
public static void main(String[] args) {
System.out.println(getInstance() != null); // true
}
}volatile 把「构造完成」和「引用发布」之间建立了 happens-before,B 要么看到 null 继续等锁,要么看到完整构造的对象。
LongAdder 不是 AtomicLong 的全面替代
AtomicLong 所有线程 CAS 同一个变量,竞争激烈时大量失败重试。LongAdder 继承 Striped64,维护一个 base 和一组 Cell,不同线程更新不同 Cell,求和时把 base 和所有 Cell 加起来。
代价是:
sum()不是原子快照,求和期间其他线程仍在更新,返回值是近似值。- 内存占用比单个
AtomicLong高。 - 依赖
sum()做条件判断、生成严格递增序列、实现 CAS 语义时,LongAdder不适用。
它适合统计计数、监控指标这类「高并发写、偶尔读总数」的场景,不适合 ID 生成和需要精确比较的计数器。
最小上手
bash
javac --release 17 Singleton.java && java Singleton
# true把 volatile 去掉,程序通常仍会打印 true,因为重排不是每次都发生;这正是并发 bug 难复现的原因。验证不能靠「跑几次没出问题」,要用 jcstress 这类专门工具构造交错。
生产动作
- 状态标志用 volatile:
volatile boolean running配合while (running)可以安全停止线程;但停止后仍需join()等线程退出。 - 复合操作加锁或原子类:
i++、check-then-act、put-if-absent不要只用 volatile;用AtomicInteger、ConcurrentHashMap.compute或synchronized。 - 安全发布:对象构造完成后通过 volatile 字段、锁、并发容器或
final字段发布;不要把this在构造器中传给其他线程。 - final 字段:正确构造且没有
this逸出时,final 字段的可见性由 JMM 保证,不需要额外 volatile;但 DCL 的引用本身仍建议 volatile,避免其他非 final 状态被提前看到。 - 并发测试:用
jcstress写交错测试,或用-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly观察屏障;不要用Thread.sleep碰运气。 - 不要用
System.out证明可见性:println内部有同步,可能掩盖问题;验证要基于共享变量和明确的同步点。
边界
- JMM 不是 JVM 内存布局:JMM 讲的是多线程可见性,堆/栈/元空间讲的是运行时区域,两者不是一回事。
- volatile 不是轻量锁:它不提供互斥,两个线程同时
count++仍会丢更新。 - happens-before 不是全序:没有同步关系的操作可以任意交错,不能靠「先启动线程」推断顺序。
- final 的安全发布有前提:构造器不能把
this逸出,否则 final 语义可能失效。 - LongAdder 的 sum 不是精确值:需要强一致计数时用
AtomicLong或加锁。
讲清楚
JMM 的核心是 happens-before:程序顺序、锁、volatile、线程启动/终止、中断、终结器和传递性,保证有关系的操作之间可见且有序。
volatile提供可见性和有序性,但不提供原子性,所以i++仍然要用原子类或锁。DCL 的错误不是「锁没加」,而是对象引用可能先于构造完成被看到;volatile 在构造和发布之间建立 happens-before,才让第一次无锁检查安全。
LongAdder用多个 Cell 分散 CAS 竞争,适合高并发统计,但sum()是弱一致快照,不能拿来做严格条件判断。