跳到主要内容

Java 内存模型(JMM)

问题

什么是 Java 内存模型(JMM)?happens-before 规则有哪些?JMM 如何保证可见性和有序性?

答案

JMM 概述

Java 内存模型(Java Memory Model,JMM)是 JVM 规范中定义的一套多线程读写共享变量的规则,它屏蔽了不同硬件和操作系统的内存访问差异,为 Java 开发者提供一致的内存可见性保证。

JMM vs JVM 内存结构
  • JVM 内存结构 = 运行时数据区划分(堆、栈、方法区等),参考 JVM 内存结构
  • JMM = 多线程并发时的内存访问规则(可见性、有序性、原子性)

两者是不同层面的概念,不要混淆。

主内存与工作内存

JMM 将内存抽象为两层:

概念说明对应硬件
主内存所有线程共享的变量存储区内存(RAM)
工作内存每个线程私有的变量副本CPU 缓存、寄存器

交互操作(8 种原子操作):

操作作用域说明
lock主内存将变量标识为线程独占
unlock主内存释放变量的独占状态
read主内存从主内存读取变量到工作内存
load工作内存将 read 的值放入工作副本
use工作内存将工作副本传给执行引擎
assign工作内存将执行引擎的结果赋值给工作副本
store工作内存将工作副本传送到主内存
write主内存将 store 的值写入主内存变量

三大特性

1. 原子性(Atomicity)

不可中断的操作,要么全部执行完成,要么完全不执行。

AtomicityProblem.java
// 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 保证的原子操作:基本类型的读写(longdouble 在 32 位 JVM 上可能不保证,但 HotSpot 实际已保证)。

2. 可见性(Visibility)

一个线程对共享变量的修改,其他线程能立即看到

VisibilityProblem.java
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)

程序执行的顺序按照代码的先后顺序执行。

ReorderingExample.java
// 指令重排序示例
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 不等于时间先后

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
HappensBeforeExample.java
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 语义:

屏障类型说明
LoadLoadLoad1; LoadLoad; Load2 → Load1 的读在 Load2 之前完成
StoreStoreStore1; StoreStore; Store2 → Store1 的写在 Store2 之前刷新到主内存
LoadStoreLoad1; LoadStore; Store2 → Load1 的读在 Store2 写之前完成
StoreLoadStore1; StoreLoad; Load2 → Store1 的写刷新到主内存后才执行 Load2 的读

volatile 的内存屏障策略:

volatile 写操作:
StoreStore 屏障 ← 禁止上面的普通写与下面的 volatile 写重排
[volatile 写]
StoreLoad 屏障 ← 禁止 volatile 写与下面的 volatile 读/写重排

volatile 读操作:
[volatile 读]
LoadLoad 屏障 ← 禁止 volatile 读与下面的普通读重排
LoadStore 屏障 ← 禁止 volatile 读与下面的普通写重排

volatile 深入

volatile 是 JMM 提供的最轻量级的同步机制:

VolatileExample.java
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 适用场景:

  1. 状态标志位:一个线程写,多个线程读
  2. 双重检查锁定(DCL)单例中的 instance 变量
  3. 配合 CAS 使用(如 AtomicInteger 内部)
DCLSingleton.java
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 字段的特殊保证:

FinalFieldExample.java
public class FinalFieldExample {
private final int x;
private int y;

public FinalFieldExample() {
x = 42; // final 字段写入
y = 100; // 普通字段写入
}
// 构造函数结束后,final 字段 x 对其他线程可见
// 但普通字段 y 不一定对其他线程可见
}

final 的保证

  1. 构造函数中对 final 字段的写入,与该对象引用赋值给其他变量,不会重排序
  2. 首次读取包含 final 字段的对象引用,与随后读取该 final 字段,不会重排序
final 语义生效的前提

构造函数中不能让 this 引用逃逸。如果在构造函数中将 this 传递给其他线程(如注册监听器),其他线程可能看到未初始化完成的 final 字段。


常见面试问题

Q1: JMM 的三大特性是什么?如何保证?

答案

特性含义保证方式
原子性操作不可中断synchronizedLockCAS(AtomicXxx)
可见性一个线程的修改对其他线程可见volatilesynchronizedfinalLock
有序性禁止指令重排volatilesynchronized、happens-before 规则

synchronized 是万能的,同时保证三者;volatile 只保证可见性和有序性,不保证原子性。

Q2: volatile 为什么不能保证原子性?

答案

volatile 保证每次读写都直接操作主内存,但 i++ 这样的操作包含读→改→写三步:

  1. 线程 A 读 i = 0
  2. 线程 B 读 i = 0
  3. 线程 A 写 i = 1
  4. 线程 B 写 i = 1(覆盖了 A 的写入)

虽然每一步的读和写都是最新值,但整个复合操作不是原子的。需要用 synchronizedAtomicInteger 来保证。

Q3: 为什么 DCL 单例需要 volatile?

答案

new Singleton() 在字节码层面分为三步:

  1. 分配内存空间
  2. 调用构造函数初始化对象
  3. 将引用指向分配的内存

没有 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 的区别?

答案

特性volatilesynchronized
原子性❌ 不保证✅ 保证
可见性✅ 保证✅ 保证
有序性✅ 保证✅ 保证
阻塞不会阻塞会阻塞
使用范围变量方法、代码块
性能更轻量相对重量级
适用场景状态标志、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 字段有特殊规则:

  1. 写 final 域的重排序规则:在构造函数内写 final 字段,与之后将对象引用赋给其他变量,这两个操作之间会插入 StoreStore 屏障,禁止重排
  2. 读 final 域的重排序规则:首次读对象引用,与读该对象的 final 字段,之间会插入 LoadLoad 屏障

前提是构造函数中 this 不能逃逸。

Q8: 什么是内存屏障?volatile 使用了哪些内存屏障?

答案

内存屏障是 CPU 指令,用于控制指令的执行顺序和内存可见性。分为四种:LoadLoad、StoreStore、LoadStore、StoreLoad。

volatile 写操作前插入 StoreStore 屏障,后插入 StoreLoad 屏障;volatile 读操作后插入 LoadLoad 和 LoadStore 屏障。StoreLoad 是最重的屏障,它要求之前所有的写操作都刷新到主内存后,才能执行后续的读操作。

相关链接