Java 内存模型(JMM)深度解析
JMM(Java Memory Model)描述 Java 程序中各种变量(共享内存)的访问规则,核心目标是在不牺牲并行性能的前提下,保证多线程程序的正确性。它定义了一个抽象模型:每个线程有私有工作内存,共享变量存放在主内存,线程间通过主内存传递值。
抽象内存结构
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 线程 A │ │ 线程 B │ │ 线程 C │
│ 工作内存 │ │ 工作内存 │ │ 工作内存 │
│(副本+私有变量)│ │(副本+私有变量)│ │(副本+私有变量)│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ ┌─────────────┴─────────────┐ │
└──────┤ 主内存 ├──────┘
│ (共享变量:实例字段、静态字段) │
└───────────────────────────┘线程对共享变量的操作必须经过工作内存:读取先把主内存的值拷贝到工作内存,写入先写到工作内存再同步回主内存。这个"拷贝—回写"过程本身是不确定的,如果不加约束,就会出现数据不一致。
可见性、原子性、有序性
JMM 围绕三个性质展开,所有并发问题都能归到其中之一:
| 性质 | 问题描述 | 解决手段 |
|---|---|---|
| 可见性 | 线程 A 修改的值线程 B 看不到 | volatile、synchronized、final |
| 原子性 | 复合操作(如 i++)不是一条指令 | synchronized、Lock、原子类 |
| 有序性 | 编译器/CPU 可能重排指令 | volatile、happens-before |
happens-before 规则
happens-before 是 JMM 定义的一组分隔符规则:如果 A happens-before B,那么 A 的执行结果对 B 可见,且 A 的指令顺序在 B 之前(重排也不可违背)。核心规则有 8 条:
| 规则 | 内容 |
|---|---|
| 程序次序规则 | 单线程内,按程序书写顺序,前面的操作 happens-before 后面的 |
| 管程锁定规则 | 对一个锁的解锁 happens-before 对该锁的后续加锁 |
| volatile 变量规则 | 对一个 volatile 的写 happens-before 对该 volatile 的读 |
| 线程启动规则 | 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 |
两个操作之间只要存在 happens-before 关系,就不需要任何同步手段也能保证可见性与有序性;不存在该关系时,JVM 可以对它们任意重排。
volatile 内存语义
volatile 修饰的变量具有两个语义:
- 可见性:写入 volatile 变量后,会立即把工作内存的值刷新到主内存;读取 volatile 变量前,会先从主内存加载最新值。相当于写操作后、读操作前插入内存屏障。
- 有序性:禁止对 volatile 变量读写操作与其周围指令进行重排(插入 LoadLoad / LoadStore / StoreStore / StoreLoad 屏障)。
volatile boolean running = true;
// 线程 A:修改后对其他线程立即可见
running = false;
// 线程 B:能立刻读到 false,退出循环
while (running) { }注意:volatile 只保证可见性与有序性,不保证原子性。volatile int count 的 count++ 仍是非原子的(读-改-写三步),多线程累加依然会丢值。
典型用法
- 状态标志位(开关、中断标记)
- 双重检查锁(DCL)中的单例实例字段
- 与 CAS 结合实现无锁并发(Atomic 类内部依赖 volatile)
DCL 为什么要 volatile
private static volatile Singleton instance;
public static Singleton get() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // ①
}
}
}
return instance;
}new Singleton() 的字节码包含三步:分配内存、初始化对象、把引用赋给变量。若不加 volatile,②(引用赋值)可能被重排到①(初始化)之前,另一个线程就会拿到未初始化完成的对象。volatile 禁止了这次重排。
synchronized 内存语义
synchronized 基于 Monitor 锁,其内存语义与 volatile 形成互补:
- 加锁:清空线程工作内存中涉及共享变量的值,从主内存重新加载(获取最新值)
- 解锁:把工作内存中修改的共享变量刷新到主内存
加锁(monitorenter):读主内存 → 清空工作内存 → 重读
解锁(monitorexit): 工作内存 → 写回主内存因此 synchronized 既能保证原子性(同一时刻只有一个线程执行临界区),也能保证可见性(解锁 happens-before 后续加锁)。
final 语义
final 字段的内存语义:构造函数的 final 写操作,与随后把构造好的对象引用赋值给变量之间,禁止重排(编译器保证)。
public class FinalDemo {
private final int x;
public FinalDemo(int v) { x = v; } // final 写
}- 只要在构造函数中为 final 字段正确赋值(未发生 this 逃逸),其他线程看到该对象时,必然能看到 final 字段的正确值
- final 引用类型字段的引用保证可见,但其指向对象的内部字段不保证(除非那些字段也是 final)
- 使用场景:不可变对象(String、Integer、自定义 immutable 类)的发布
内存屏障与重排序
JVM 在编译期与运行期(JIT)都可能重排指令,通过内存屏障(Memory Barrier)限制重排:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 屏障前的读操作先于屏障后的读操作 |
| LoadStore | 屏障前的读先于屏障后的写 |
| StoreStore | 屏障前的写先于屏障后的写 |
| StoreLoad | 屏障前的写先于屏障后的读(最强,volatile 写后插入) |
volatile 写 = 前插 StoreStore + 后插 StoreLoad;volatile 读 = 后插 LoadLoad + LoadStore。这些屏障由 JVM 在生成代码时自动插入,对程序员透明,理解它们有助于读懂 JMM 的设计取舍。
常见面试点
- volatile 能保证原子性吗? 不能。它只保证可见性与有序性,复合操作仍需加锁或原子类。
- happens-before 与时间先后有区别吗? 有。happens-before 是 JMM 的可见性保证,不是执行时间顺序;两个操作无 happens-before 关系时,不保证可见性。
- 为什么 DCL 需要 volatile? 防止 new 对象的引用赋值被重排到初始化之前,避免发布未完全初始化的对象。
- final 字段一定安全发布吗? 仅当构造函数未发生 this 逃逸(未把 this 传递给其他线程)时保证。