什么是 JVM 内存模型(JMM)?
简化版
JMM(Java Memory Model,Java 内存模型)是一套并发编程规范。它规定多线程读写共享变量时,一个线程的写入何时对其他线程可见、指令能否被重排序、以及如何获得原子性。它是语义约束,不是堆、栈、方法区那种物理内存区域。
详细版
先区分两个极易混淆的概念:JVM 的堆、栈、方法区回答的是「数据运行时存在哪里」;JMM 回答的是「多个线程同时读写数据时,程序可以看到什么结果」。JMM 不是一块真实内存,而是 Java 语言对并发行为的约束。
它要解决三个问题:
- 原子性:一个操作不能只做一半就被别的线程打断。基本类型的单次读写通常是原子的,但
count++含「读—改—写」三步,不是原子操作。 - 可见性:线程 A 的写入何时能被线程 B 读到。普通字段没有跨线程立即可见的保证,加锁/解锁、
volatile读写会建立可见性。 - 有序性:线程观察到的操作顺序。JMM 不禁止重排优化,但要求重排不能破坏已建立的同步语义。
分析并发正确性的核心工具是 happens-before:如果操作 A happens-before B,就能推出 A 的结果对 B 可见、且在内存语义上先于 B。工具选型上,synchronized 三者(原子、可见、有序)都保证;volatile 只保证可见性和一定的有序性;单变量原子更新用 AtomicInteger 等原子类。
完整版教学
一、为什么需要 JMM
现代 CPU 为了性能有寄存器、多级缓存和乱序执行;编译器也会在不改变单线程结果的前提下重排指令。于是会出现两类「反直觉」现象:
- 线程 A 改了字段,线程 B 却仍从自己的缓存读到旧值(可见性问题);
- 线程 B 观察到 A 的两次写入顺序和源码不一致(有序性问题)。
如果没有统一规范,开发者就得针对每种 CPU 架构写不同的同步代码。JMM 的价值,就是在语言层面给出一套与硬件无关的并发语义,让「正确的并发程序」有明确定义。
二、三大问题详解
原子性:i++ 看似一步,实际是「读 i、加 1、写回」。两个线程同时 i++,可能都读到同一个旧值,各自加一写回,结果只加了一次。想要原子性,要么加锁,要么用 AtomicInteger 的 CAS。
可见性:普通变量的修改可能停留在某个线程的工作内存/CPU 缓存里,不保证马上刷回主内存、也不保证别的线程马上失效自己的缓存。volatile、synchronized、final(正确构造下)都能建立可见性。
有序性:单线程内「看起来是顺序执行」(as-if-serial),但多线程下,别的线程可能看到被重排后的顺序。经典反例是双重检查锁(DCL)里不给实例加 volatile,可能拿到「构造还没完成」的对象。
三、happens-before:并发分析的钥匙
happens-before 不是说两个线程在物理时间上严格排队,而是 Java 给出的可见性保证:A happens-before B ⇒ A 的结果对 B 可见。面试最常用的规则:
- 程序顺序规则:同一线程内,前面的操作 happens-before 后面的操作。
- 锁规则:对同一把锁的解锁 happens-before 后续对该锁的加锁。
- volatile 规则:对
volatile字段的写 happens-before 后续对它的读。 - 线程启动规则:
Thread.start()之前的操作,对新线程可见。 - 线程终止规则:线程内的操作,对
join()成功返回后的线程可见。 - 传递性:A happens-before B、B happens-before C ⇒ A happens-before C。
看一个安全发布的例子:
class ConfigHolder {
private Config config;
private volatile boolean ready;
void init() {
config = loadConfig(); // 普通写
ready = true; // volatile 写
}
Config get() {
if (ready) { // volatile 读
return config;
}
return null;
}
}
ready = true(volatile 写)happens-before 另一个线程读到 ready == true(volatile 读);再结合程序顺序规则和传递性,那个线程也一定能看到已经赋值好的 config。如果 ready 不是 volatile,这条推导就不成立,可能读到 config == null 或半初始化的对象。
四、工具怎么选
- synchronized:靠同一把锁的释放/获取,同时提供互斥、可见性、有序性,适合「多个字段作为整体变更」。
- volatile:只保证可见性和禁止重排,不保证复合操作原子性。适合状态开关、一次性初始化完成标记、不可变配置引用。
- 原子类(AtomicInteger 等):单变量的 CAS 更新,适合计数器、累加。
- 不可变对象 / final:对象一旦构造完成就不再变,天然线程安全。
说白了,别把 volatile 当「轻量级锁」。凡是依赖旧值的逻辑(余额扣减、计数累加、检查后执行)都需要原子类或锁。
五、volatile、锁与 final 的边界
volatile 写与随后读到该值的 volatile 读之间建立 happens-before,因此常用于发布状态和停止标志。它不把“读—改—写”合成一个不可分割动作:
volatile int count;
count++; // 读 count、加一、写回,仍可能丢失更新
两个线程各执行 10_000 次 count++,理论期望是 20_000,实际结果可能更小。需要原子计数时应使用锁、AtomicInteger 或适合高竞争统计的 LongAdder。
正确构造的对象还享有 final 字段的特殊初始化安全语义:构造函数内对 final 字段的写,在对象没有从构造期间逸出的前提下,可被其他线程正确观察。但这不等于 final 引用指向的可变对象自动线程安全。
六、用 happens-before 推导发布是否安全
| 机制 | 原子性 | 可见性/有序性 | 典型用途 |
|---|---|---|---|
synchronized/Lock | 临界区互斥 | 解锁到后续加锁建立顺序 | 复合状态保护 |
volatile | 单次读写具备相应保证 | volatile 写到后续读 | 状态标志、不可变快照发布 |
| 原子类 | 单变量复合原子更新 | 具备 volatile 类内存效果 | 计数器、CAS 状态机 |
| 不可变对象 | 构造后不再改变 | 仍需正确发布引用 | 配置快照、值对象 |
以配置发布为例:线程 A 先写普通字段 config,再写 volatile ready=true;线程 B 读到 ready=true 后再读 config。程序顺序规则、volatile 规则和传递性构成完整 happens-before 链,因此 B 能看到构造完成的配置。
A: 写 config → volatile 写 ready
↓ happens-before
B: volatile 读 ready → 读 config
happens-before 是内存语义上的偏序关系,不等于操作在墙上时钟中紧挨发生,也不代表没有 happens-before 就必然每次出错。
判断并发代码时不要只问“变量在哪块内存”,而要逐条找出写入到读取之间是否存在可证明的 happens-before 链。
七、常见误区与追问
- 误区:
volatile能让count++变成原子操作。 它保证相关可见性和顺序,不会把多个读写步骤自动合并。 - 误区:happens-before 就是现实时间上的先后。 它描述 JMM 保证的可见性与排序关系,是程序语义上的偏序。
- 误区:JMM 的主内存就是物理内存,工作内存就是 CPU 缓存。 这是规范抽象,不能与某一级硬件缓存机械对应。
- 追问:
synchronized为什么保证可见性? 对同一监视器的解锁 happens-before 后续加锁,锁边界要求相应内存效果。 - 追问:双重检查单例为什么要用
volatile? 它既安全发布实例,也禁止把引用发布重排到初始化完成之前。 - 追问:final 引用能保证集合不可变吗? 不能;final 只限制引用再次赋值,集合内容仍需同步或使用不可变实现。
- 追问:没有数据竞争是否就能按顺序一致理解程序? 正确同步、无数据竞争的程序可获得强而直观的顺序语义,这是使用同步规则的核心价值。
八、加强记忆
用“原、见、序”记住 JMM 处理原子性、可见性和有序性,再用 happens-before 把写入到读取的保证串起来。volatile 适合状态发布与可见性排序,却不会把 count++ 变成原子操作;锁适合保护带复合约束的状态,原子类适合单变量更新。final 字段的初始化安全也要求构造期间没有错误逸出,并不让其引用对象自动不可变。判断并发代码时画出程序顺序、锁、volatile、线程启动/终止等边,再利用传递性验证是否形成完整发布链。