Java 内存模型(JMM)
问题
什么是 Java 内存模型(JMM)?happens-before 规则有哪些?JMM 如何保证可见性和有序性?
答案
JMM 概述
Java 内存模型(Java Memory Model,JMM)是 JVM 规范中定义的一套多线程读写共享变量的规则,它屏蔽了不同硬件和操作系统的内存访问差异,为 Java 开发者提供一致的内存可见性保证。
- JVM 内存结构 = 运行时数据区划分(堆、栈、方法区等),参考 JVM 内存结构
- JMM = 多线程并发时的内存访问规则(可见性、有序性、原子性)
两者是不同层面的概念,不要混淆。
主内存与工作内存
JMM 将内存抽象为两层:
| 概念 | 说明 | 对应硬件 |
|---|---|---|
| 主内存 | 所有线程共享的变量存储区 | 内存(RAM) |
| 工作内存 | 每个线程私有的变量副本 | CPU 缓存、寄存器 |
交互操作(8 种原子操作):
| 操作 | 作用域 | 说明 |
|---|---|---|
lock | 主内存 | 将变量标识为线程独占 |
unlock | 主内存 | 释放变量的独占状态 |
read | 主内存 | 从主内存读取变量到工作内存 |
load | 工作内存 | 将 read 的值放入工作副本 |
use | 工作内存 | 将工作副本传给执行引擎 |
assign | 工作内存 | 将执行引擎的结果赋值给工作副本 |
store | 工作内存 | 将工作副本传送到主内存 |
write | 主内存 | 将 store 的值写入主内存变量 |
三大特性
1. 原子性(Atomicity)
不可中断的操作,要么全部执行完成,要么完全不执行。
// i++ 不是原子操作!包含三步:read → add → write
private int count = 0;
// 线程不安全
public void increment() {
count++; // 实际是 temp = count; temp = temp + 1; count = temp;
}
// 解决方案1:synchronized
public synchronized void incrementSafe() {
count++;
}
// 解决方案2:AtomicInteger(CAS)
private AtomicInteger atomicCount = new AtomicInteger(0);
public void incrementAtomic() {
atomicCount.incrementAndGet();
}
JMM 保证的原子操作:基本类型的读写(long 和 double 在 32 位 JVM 上可能不保证,但 HotSpot 实际已保证)。
2. 可见性(Visibility)
一个线程对共享变量的修改,其他线程能立即看到。
public class VisibilityProblem {
// 没有 volatile,线程 B 可能永远看不到 flag 的变化
private boolean flag = false;
// 线程 A
public void writer() {
flag = true; // 修改后可能只在工作内存中,未刷新到主内存
}
// 线程 B
public void reader() {
while (!flag) {
// 可能永远循环,因为读的是工作内存的旧值
}
System.out.println("flag changed!");
}
}
保证可见性的方式:
| 方式 | 原理 |
|---|---|
volatile | 读写直接操作主内存,禁止缓存优化 |
synchronized | 加锁时清空工作内存,解锁时刷新到主内存 |
final | 初始化完成后对其他线程可见(构造函数不泄漏 this) |
Lock | 与 synchronized 类似的内存语义 |
3. 有序性(Ordering)
程序执行的顺序按照代码的先后顺序执行。
// 指令重排序示例
int a = 1; // 语句1
int b = 2; // 语句2
int c = a + b; // 语句3
// 编译器可能重排为: 语句2 → 语句1 → 语句3(结果一致,但顺序不同)
// 对单线程无影响,但多线程下可能出问题
三种重排序:
| 类型 | 说明 |
|---|---|
| 编译器重排序 | 编译器优化,不改变单线程语义的前提下重排 |
| 指令级重排序 | CPU 流水线乱序执行 |
| 内存系统重排序 | 写缓冲区/缓存导致的乱序 |
JMM 通过内存屏障(Memory Barrier)来禁止特定类型的重排序。
happens-before 规则
happens-before 是 JMM 的核心,定义了操作之间的可见性保证。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。
happens-before 描述的是可见性保证,不是时间顺序。A happens-before B 意味着 A 的结果一定对 B 可见,但 A 不一定在 B 之前执行(允许重排序,只要结果一致)。
8 大 happens-before 规则:
| 规则 | 说明 |
|---|---|
| 程序次序规则 | 同一线程内,按照代码顺序,前面的操作 happens-before 后面的 |
| 锁定规则 | unlock 操作 happens-before 后续对同一个锁的 lock 操作 |
| volatile 变量规则 | 对 volatile 变量的写 happens-before 后续对该变量的读 |
| 线程启动规则 | Thread.start() happens-before 该线程中的每个动作 |
| 线程终止规则 | 线程的所有操作 happens-before 其他线程调用该线程的 join() 返回 |
| 线程中断规则 | interrupt() 调用 happens-before 被中断线程检测到中断事件 |
| 对象终结规则 | 对象的构造函数执行完成 happens-before finalize() 开始 |
| 传递性规则 | 如果 A happens-before B,B happens-before C,则 A happens-before C |
public class HappensBeforeExample {
private int value = 0;
private volatile boolean flag = false;
// 线程 A
public void writer() {
value = 42; // 步骤1
flag = true; // 步骤2(volatile 写)
}
// 线程 B
public void reader() {
if (flag) { // 步骤3(volatile 读)
// happens-before 保证:
// 步骤1 hb 步骤2(程序次序规则)
// 步骤2 hb 步骤3(volatile 变量规则)
// 步骤1 hb 步骤3(传递性)
// 因此 value 一定为 42
System.out.println(value); // 一定输出 42
}
}
}
内存屏障
JMM 通过内存屏障指令来实现 happens-before 语义:
| 屏障类型 | 说明 |
|---|---|
| LoadLoad | Load1; LoadLoad; Load2 → Load1 的读在 Load2 之前完成 |
| StoreStore | Store1; StoreStore; Store2 → Store1 的写在 Store2 之前刷新到主内存 |
| LoadStore | Load1; LoadStore; Store2 → Load1 的读在 Store2 写之前完成 |
| StoreLoad | Store1; StoreLoad; Load2 → Store1 的写刷新到主内存后才执行 Load2 的读 |
volatile 的内存屏障策略:
volatile 写操作:
StoreStore 屏障 ← 禁止上面的普通写与下面的 volatile 写重排
[volatile 写]
StoreLoad 屏障 ← 禁止 volatile 写与下面的 volatile 读/写重排
volatile 读操作:
[volatile 读]
LoadLoad 屏障 ← 禁止 volatile 读与下面的普通读重排
LoadStore 屏障 ← 禁止 volatile 读与下面的普通写重排
volatile 深入
volatile 是 JMM 提供的最轻量级的同步机制:
public class VolatileExample {
// volatile 保证可见性和有序性,但不保证原子性
private volatile boolean running = true;
private volatile int count = 0;
// ✅ 适合:状态标志位
public void stop() {
running = false; // 其他线程立即可见
}
public void run() {
while (running) {
// 每次读取都从主内存刷新
}
}
// ❌ 不适合:复合操作(不保证原子性)
public void increment() {
count++; // 仍然线程不安全!
}
}
volatile 适用场景:
- 状态标志位:一个线程写,多个线程读
- 双重检查锁定(DCL)单例中的 instance 变量
- 配合 CAS 使用(如 AtomicInteger 内部)
public class Singleton {
// 必须用 volatile,防止指令重排导致获取到未初始化完成的对象
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
// new Singleton() 分为三步:
// 1. 分配内存
// 2. 初始化对象
// 3. 将 instance 指向分配的内存
// 没有 volatile 时,步骤 2 和 3 可能重排
// 导致其他线程看到未初始化完成的对象
instance = new Singleton();
}
}
}
return instance;
}
}
synchronized 的内存语义
synchronized 进入(monitorenter):
1. 清空工作内存中共享变量的副本
2. 从主内存重新读取(保证可见性)
synchronized 退出(monitorexit):
1. 将工作内存中修改的共享变量刷新到主内存
2. 释放锁(unlock happens-before 后续 lock)
因此 synchronized 同时保证了原子性、可见性和有序性。
final 的内存语义
final 字段的特殊保证:
public class FinalFieldExample {
private final int x;
private int y;
public FinalFieldExample() {
x = 42; // final 字段写入
y = 100; // 普通字段写入
}
// 构造函数结束后,final 字段 x 对其他线程可见
// 但普通字段 y 不一定对其他线程可见
}
final 的保证:
- 构造函数中对 final 字段的写入,与该对象引用赋值给其他变量,不会重排序
- 首次读取包含 final 字段的对象引用,与随后读取该 final 字段,不会重排序
构造函数中不能让 this 引用逃逸。如果在构造函数中将 this 传递给其他线程(如注册监听器),其他线程可能看到未初始化完成的 final 字段。
常见面试问题
Q1: JMM 的三大特性是什么?如何保证?
答案:
| 特性 | 含义 | 保证方式 |
|---|---|---|
| 原子性 | 操作不可中断 | synchronized、Lock、CAS(AtomicXxx) |
| 可见性 | 一个线程的修改对其他线程可见 | volatile、synchronized、final、Lock |
| 有序性 | 禁止指令重排 | volatile、synchronized、happens-before 规则 |
synchronized 是万能的,同时保证三者;volatile 只保证可见性和有序性,不保证原子性。
Q2: volatile 为什么不能保证原子性?
答案:
volatile 保证每次读写都直接操作主内存,但 i++ 这样的操作包含读→改→写三步:
- 线程 A 读 i = 0
- 线程 B 读 i = 0
- 线程 A 写 i = 1
- 线程 B 写 i = 1(覆盖了 A 的写入)
虽然每一步的读和写都是最新值,但整个复合操作不是原子的。需要用 synchronized 或 AtomicInteger 来保证。
Q3: 为什么 DCL 单例需要 volatile?
答案:
new Singleton() 在字节码层面分为三步:
- 分配内存空间
- 调用构造函数初始化对象
- 将引用指向分配的内存
没有 volatile 时,步骤 2 和 3 可能被指令重排序。如果线程 A 执行了步骤 1 和 3(但还没执行 2),线程 B 在第一次 if (instance == null) 检查时发现 instance 不为 null,直接返回一个未初始化完成的对象。
volatile 通过内存屏障禁止了这种重排序。
Q4: happens-before 和 as-if-serial 有什么区别?
答案:
- as-if-serial:保证单线程内,无论怎么重排序,执行结果不变。编译器和 CPU 可以自由重排没有数据依赖的指令
- happens-before:保证多线程间的可见性关系。如果 A happens-before B,则 A 的结果对 B 可见
as-if-serial 关注单线程语义,happens-before 关注多线程可见性。
Q5: synchronized 和 volatile 的区别?
答案:
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证 | ✅ 保证 |
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性 | ✅ 保证 | ✅ 保证 |
| 阻塞 | 不会阻塞 | 会阻塞 |
| 使用范围 | 变量 | 方法、代码块 |
| 性能 | 更轻量 | 相对重量级 |
| 适用场景 | 状态标志、DCL | 复合操作、临界区 |
Q6: 什么是伪共享(False Sharing)?如何避免?
答案:
CPU 缓存以缓存行(Cache Line,通常 64 字节)为单位加载数据。如果两个线程修改的变量位于同一个缓存行,一个线程的写入会导致另一个线程的缓存行失效,引起频繁的缓存同步,称为伪共享。
// 伪共享示例
public class FalseSharing {
volatile long x; // x 和 y 可能在同一缓存行
volatile long y;
}
// 解决方案:填充缓存行
public class PaddedAtomicLong {
// @Contended 注解(JDK 8+)自动填充,需要 -XX:-RestrictContended
@sun.misc.Contended
volatile long value;
}
JDK 8 的 LongAdder 就使用了 @Contended 注解来避免伪共享,从而比 AtomicLong 在高竞争下性能更好。
Q7: final 字段为什么能保证可见性?
答案:
JMM 对 final 字段有特殊规则:
- 写 final 域的重排序规则:在构造函数内写 final 字段,与之后将对象引用赋给其他变量,这两个操作之间会插入 StoreStore 屏障,禁止重排
- 读 final 域的重排序规则:首次读对象引用,与读该对象的 final 字段,之间会插入 LoadLoad 屏障
前提是构造函数中 this 不能逃逸。
Q8: 什么是内存屏障?volatile 使用了哪些内存屏障?
答案:
内存屏障是 CPU 指令,用于控制指令的执行顺序和内存可见性。分为四种:LoadLoad、StoreStore、LoadStore、StoreLoad。
volatile 写操作前插入 StoreStore 屏障,后插入 StoreLoad 屏障;volatile 读操作后插入 LoadLoad 和 LoadStore 屏障。StoreLoad 是最重的屏障,它要求之前所有的写操作都刷新到主内存后,才能执行后续的读操作。