Java 内存模型(JMM)
为什么需要内存模型
Section titled “为什么需要内存模型”现代 CPU 为了提高性能,引入了三个层面的优化,但都可能打破多线程程序的直觉:
1. CPU 缓存(Cache) 每个 CPU 核心有自己的 L1/L2 缓存 → 导致不同核心对同一内存地址的值看法不一致(可见性问题)
2. 写缓冲区(Store Buffer) CPU 将写操作先放入写缓冲区,延迟刷入内存 → 其他核心可能看不到最新写入(可见性问题)
3. 指令重排(Reordering) 编译器和 CPU 会对无数据依赖的指令重排执行顺序以提高效率 → 从其他线程视角看,代码执行顺序与源码不符(有序性问题)Java 内存模型(JMM) 是 Java 规范层面对内存可见性和操作有序性的定义,屏蔽了不同 CPU 架构的差异,为开发者提供一套统一的并发编程规范。
主内存与工作内存
Section titled “主内存与工作内存”JMM 的抽象模型:
┌─────────────────────────────────────┐│ 主内存(Main Memory) ││ 共享变量(实例字段、静态字段、数组元素) │└─────────────┬───────┬───────────────┘ │ read │ write ▼ ▼┌─────────────────┐ ┌─────────────────┐│ 线程 A 工作内存 │ │ 线程 B 工作内存 ││ 变量 x 的副本 │ │ 变量 x 的副本 │└─────────────────┘ └─────────────────┘
线程只能直接操作工作内存中的变量副本工作内存是 JMM 的抽象概念,对应 CPU 寄存器+缓存+写缓冲区的综合线程对共享变量的操作分为 8 个原子动作(lock/unlock/read/load/use/assign/store/write),JMM 规定了这 8 个动作的执行规则,确保内存一致性。
happens-before 规则
Section titled “happens-before 规则”happens-before(先行发生)是 JMM 提供的可见性保证机制。如果 A happens-before B,则 A 的所有操作结果对 B 可见,且 A 的执行顺序在 B 之前。
JMM 内置了以下 happens-before 规则(无需额外同步即可保证):
1. 程序顺序规则(Program Order Rule) 同一线程内,前面的操作 happens-before 后面的操作 (单线程内代码的表现顺序)
2. 监视器锁规则(Monitor Lock Rule) 对一个锁的 unlock happens-before 后续对同一个锁的 lock ↳ 保证:加锁后能看到上次解锁前的所有写入
3. volatile 变量规则(Volatile Variable Rule) 对 volatile 变量的写 happens-before 后续对该变量的读 ↳ 保证:volatile 写后的读能看到最新值
4. 线程启动规则(Thread Start Rule) Thread.start() happens-before 被启动线程的任何操作 ↳ 保证:主线程在 start() 前写入的数据,子线程能看到
5. 线程终止规则(Thread Termination Rule) 线程的所有操作 happens-before 其他线程检测到该线程终止 (Thread.join() 返回 或 Thread.isAlive() 返回 false)
6. 线程中断规则(Thread Interruption Rule) interrupt() 调用 happens-before 被中断线程检测到中断 (InterruptedException 或 isInterrupted())
7. 对象终结规则(Finalizer Rule) 对象构造函数结束 happens-before finalize() 方法开始
8. 传递性规则(Transitivity) 如果 A hb B,B hb C,则 A hb Cvolatile 关键字
Section titled “volatile 关键字”volatile 的两大语义
Section titled “volatile 的两大语义”1. 可见性:对 volatile 变量的写操作会立即刷新到主内存;对 volatile 变量的读操作会从主内存重新加载(跳过 CPU 缓存)。
2. 禁止指令重排:通过插入内存屏障(Memory Barrier)实现:
- 写 volatile 前:插入 StoreStore 屏障(前面的普通写不能重排到 volatile 写之后)
- 写 volatile 后:插入 StoreLoad 屏障(volatile 写不能重排到后面的读写之前)
- 读 volatile 前:插入 LoadLoad 屏障
- 读 volatile 后:插入 LoadStore 屏障
volatile 写的内存屏障效果(以 JDK HotSpot 实现为例):
普通写 a = 1;[StoreStore 屏障] ← 确保 a=1 已刷新volatile 写 v = 2;[StoreLoad 屏障] ← 确保 v=2 对后续读可见
volatile 读:volatile 读 x = v;[LoadLoad 屏障] ← 后续读都能看到最新值[LoadStore 屏障] ← 后续写依赖最新读普通读 b = a;volatile 不能保证原子性
Section titled “volatile 不能保证原子性”// 错误示例:以为 volatile 能保证线程安全的计数器volatile int count = 0;
void increment() { count++; // 这不是原子操作! // 实际是:读count → 加1 → 写count,三步操作 // 两个线程同时读到相同的值,分别+1,写回相同的值,丢失一次更新}volatile 适合的场景:一写多读(只有一个线程写,多个线程读)或写操作不依赖当前值(如状态标志位)。
double-checked locking(DCL)
Section titled “double-checked locking(DCL)”单例模式中最经典的并发问题:
错误的 DCL(未使用 volatile)
Section titled “错误的 DCL(未使用 volatile)”// 错误!可能返回未初始化完成的对象class Singleton { private static Singleton instance; // ← 缺少 volatile
public static Singleton getInstance() { if (instance == null) { // 第一次检查(无锁) synchronized (Singleton.class) { if (instance == null) { // 第二次检查(有锁) instance = new Singleton(); } } } return instance; }}问题根源:new Singleton() 在字节码层面分三步:
- 分配内存空间
- 初始化对象(执行构造方法)
- 将引用指向内存地址
JIT/CPU 可能将步骤 2 和 3 重排为:先赋值引用,再初始化对象。
线程A: 1. 分配内存 3. 将 instance 指向内存地址(重排,先于步骤2) ← 线程B 此时检查 instance != null,直接返回 2. 初始化对象(还没执行!)
线程B: 拿到了 instance,但对象还没初始化完成 → 访问字段时可能 NPE 或看到默认值正确的 DCL(使用 volatile)
Section titled “正确的 DCL(使用 volatile)”class Singleton { private static volatile Singleton instance; // ← 必须 volatile
public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // volatile 写禁止步骤3重排到步骤2之前 } } } return instance; }}volatile 如何修复:volatile 写的 StoreStore 屏障确保对象初始化(步骤2)一定发生在引用赋值(步骤3)之前,其他线程通过 volatile 读能看到完整初始化的对象。
更优雅的替代方案:静态内部类
Section titled “更优雅的替代方案:静态内部类”class Singleton { private Singleton() {}
private static class Holder { static final Singleton INSTANCE = new Singleton(); // 类加载时初始化,JVM保证线程安全 }
public static Singleton getInstance() { return Holder.INSTANCE; }}利用 JVM 类加载机制的线程安全性(类初始化使用 <clinit> 锁,保证只执行一次),既懒加载又线程安全,是推荐做法。
final 的内存语义
Section titled “final 的内存语义”final 字段的写入有特殊的内存保证:
规则: 构造函数中对 final 字段的写入 happens-before 其他线程读取该对象的 final 字段
效果: 只要对象是正确构造的(引用没有在构造过程中逸出), 任何线程都能看到 final 字段的正确初始值,无需同步。class ImmutablePoint { final int x; final int y;
ImmutablePoint(int x, int y) { this.x = x; // ← JMM 保证:其他线程读到的 x 一定是构造完成后的值 this.y = y; }}
// 只要 point 引用安全发布(如 volatile 写或加锁),// 任何读取 point.x / point.y 的线程都能看到正确值引用逸出(Escape during Construction)——final 保证的例外:
// 危险!在构造函数中将 this 传递给其他线程class BadExample { final int value; static BadExample instance;
BadExample(int v) { instance = this; // ← this 在构造完成前就逸出了 this.value = v; // 其他线程可能看到 value = 0(默认值) }}指令重排的边界
Section titled “指令重排的边界”禁止重排的场景(JMM 规定的 as-if-serial 语义): • 有数据依赖关系的操作(如:写 a → 读 a,不能重排) • volatile 读/写的前后
允许重排的场景(单线程结果不变): int a = 1; // ① 无数据依赖 int b = 2; // ② 可以重排为 ②①,单线程结果不变 int c = a + b; // ③ 依赖 ① 和 ②,不能排在 ①② 之前x86 的特殊性:x86 CPU 的内存模型(TSO,Total Store Order)本身就比较强,只允许 StoreLoad 重排。大多数 Java volatile 的内存屏障在 x86 上只有 StoreLoad(MFENCE 或 LOCK ADD)是真正的开销,其他屏障在 x86 上是空操作。这就是为什么在 x86 上 Java 程序的并发问题往往比在 ARM 上更难复现。
Q:volatile 能保证原子性吗?
不能。volatile 只保证可见性(写后对其他线程立即可见)和有序性(禁止相关指令重排)。count++ 这样的复合操作不是原子的,需要使用 AtomicInteger 或 synchronized。
Q:happens-before 和时间先后有什么区别?
时间先后是物理概念,是 Wall Clock 时间上的先后;happens-before 是逻辑概念,是 JMM 对可见性的保证。A 在时间上先于 B 执行,不代表 B 能看到 A 的修改;只有 A happens-before B,才能保证 B 看到 A 的所有写入。
Q:DCL 单例为什么需要 volatile?
new Singleton() 在指令层面是分配内存→初始化→赋值引用三步,JIT/CPU 可能将赋值引用重排到初始化之前。未使用 volatile 时,另一个线程可能看到 instance != null 但对象未初始化完成。volatile 的 StoreStore 屏障禁止了这种重排。
Q:说几个 happens-before 规则?
程序顺序规则(同线程前 hb 后)、监视器锁规则(unlock hb 后续 lock)、volatile 规则(volatile 写 hb 后续读)、线程启动规则(start() hb 子线程操作)、线程终止规则(线程操作 hb join() 返回)、传递性(A hb B,B hb C → A hb C)。
Q:为什么说 JMM 是对 CPU 内存模型的封装?
不同 CPU 架构(x86、ARM、POWER)的内存模型(允许的重排类型)不同,如果 Java 直接暴露 CPU 模型,开发者就需要针对不同硬件写不同的并发代码。JMM 通过规定 happens-before 规则和内存屏障,将这些差异屏蔽,开发者只需基于 JMM 规范编写代码,JVM 负责在不同平台插入适当的内存屏障指令。