Java内存模型(JMM)
一个让无数候选人翻车的面试题
候选人小李在字节面试中遇到了这个问题:
"我有两个线程,一个线程修改变量a,一个线程读取变量a,能保证读到最新值吗?"
小李说:"能,因为Java内存模型保证了可见性。"
面试官追问:"怎么保证的?"
小李停顿了两秒:"用volatile..."
面试官:"volatile是怎么保证可见性的?底层原理是什么?"
小李开始擦汗。
这个场景在面试中太常见了。很多同学知道"volatile保证可见性"、"synchronized保证原子性和可见性",但被追问到底层原理时就懵了。
今天这篇文章,带你把JMM彻底搞清楚。
为什么需要JMM
物理世界的乱序问题
现代CPU为了提升性能,做了很多"小动作":
- CPU指令重排序:CPU会根据依赖关系调整指令执行顺序
- CPU缓存:每个CPU核心有自己的L1、L2缓存,数据可能还在缓存里没写回主内存
- 编译器优化:JIT编译器会重排代码以提升性能
public class ReorderingDemo {
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; // 指令1
flag = true; // 指令2
}
public void reader() {
if (flag) { // 指令3
int r = a; // 指令4
System.out.println(r);
}
}
}
这段代码看起来没问题吧?但实际执行时,CPU可能重排序成:
public void writer() {
flag = true; // 先执行这条
a = 1; // 后执行这条
}
如果线程A执行writer,线程B执行reader,线程B可能看到flag=true,但a=0。
CPU缓存导致的可见性问题
现代CPU架构:
graph LR
subgraph CPU
C1[Core 1]
C2[Core 2]
C3[Core 3]
C4[Core 4]
end
C1 --- L1_1[L1 Cache]
C2 --- L1_2[L1 Cache]
C3 --- L1_3[L1 Cache]
C4 --- L1_4[L1 Cache]
L1_1 --- L2_1[L2 Cache]
L1_2 --- L2_2[L2 Cache]
L1_3 --- L2_3[L2 Cache]
L1_4 --- L2_4[L2 Cache]
L2_1 --- L3[L3 Cache]
L2_2 --- L3
L2_3 --- L3
L2_4 --- L3
L3 --- MM[主内存]
每个CPU核心有自己的缓存(L1、L2),所有核心共享L3缓存和主内存。
当Core 1修改了变量a,写入自己的L1缓存后,Core 2可能还在使用旧的L1缓存中的a值。这就是可见性问题。
编译器的"自作聪明"
JIT编译器也会优化代码:
public class CompilerOptimization {
private int x = 0;
private int y = 0;
public void method() {
x = 1; // A线程写入
y = 2; // A线程写入
// B线程读取
if (y == 2) {
System.out.println(x); // 可能打印0!
}
}
}
编译器可能认为x和y没有依赖关系,把y=2重排到x=1前面。
JMM是什么
JMM的定义
Java Memory Model(JMM)是Java语言规范的一部分(JSR-133),它定义了:
- 变量(字段、静态字段、数组元素)的访问规则
- 多线程环境下,线程间如何"看到"这些变量的变化
- 哪些重排序是允许的,哪些是不允许的
JMM的核心目标:让程序员编写正确的多线程程序变得可行。
JMM的抽象模型
JMM将内存分为两部分:
- 主内存(Main Memory):所有变量存储的地方,对所有线程可见
- 工作内存(Working Memory):每个线程私有的内存区域,存储主内存中变量的副本
graph TB
subgraph MainMemory[主内存]
VAR1[变量a]
VAR2[变量b]
VAR3[变量c]
end
subgraph Thread1[线程1工作内存]
COPY1_a[变量a副本]
COPY1_b[变量b副本]
end
subgraph Thread2[线程2工作内存]
COPY2_a[变量a副本]
COPY2_c[变量c副本]
end
Thread1 <-->|read/write| MainMemory
Thread2 <-->|read/write| MainMemory
线程不能直接操作主内存,必须通过工作内存:
- read:从主内存读取变量到工作内存
- load:将read的值加载到工作内存的变量副本
- use:将工作内存中的变量值传递给执行引擎
- assign:将执行引擎的值赋给工作内存的变量
- store:将工作内存的变量值传送到主内存
- write:将store的值写入主内存的变量
as-if-serial语义
JMM的核心原则之一:as-if-serial语义。
对于单个线程来说,无论怎么重排序,程序的执行结果不能改变。
double pi = 3.14; // A
double r = 10; // B
double area = pi * r * r; // C
无论A和B怎么重排序,只要C在它们之后执行,结果就是正确的。
但是!如果有多个线程,情况就复杂了。
happens-before原则
什么是happens-before
happens-before(先行发生)是JMM最核心的概念。它定义了:
- 可见性:如果操作A happens-before 操作B,那么A对B可见
- 有序性:如果操作A happens-before 操作B,那么A在B之前执行
注意:happens-before不是指时间上的先后,而是指逻辑上的先后。
8条happens-before规则
JMM定义了8条happens-before规则:
1. 程序顺序规则(Program Order Rule)
在同一线程内,每个操作happens-before该线程中后续的所有操作。
int a = 1; // A
int b = 2; // B
int c = a + b; // C
// A HB B, B HB C
// 也就是说,A的结果对B可见,B的结果对C可见
2. 监视器锁规则(Monitor Lock Rule)
对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
synchronized (lock) {
x = 10; // A线程写入
} // 解锁
synchronized (lock) {
int r = x; // B线程读取
} // 加锁
// 解锁 HB 加锁,所以B一定能读到A写入的值
3. volatile变量规则(Volatile Variable Rule)
对volatile变量的写操作,happens-before后续对这个volatile变量的读操作。
volatile boolean flag = false;
Thread A:
flag = true; // 写
Thread B:
while (!flag) { // 读
Thread.sleep(100);
}
System.out.println("done");
// 写 HB 读,所以B一定能读到true
4. 线程启动规则(Thread Start Rule)
Thread.start()调用,happens-before被启动线程中的任何操作。
Thread A:
x = 10;
thread.start(); // 启动B
Thread B:
int r = x; // B一定能读到x=10
// start() HB B中的操作
5. 线程终止规则(Thread Termination Rule)
线程中的所有操作,happens-before其他线程检测到该线程终止。
Thread A:
x = 10;
thread.join(); // 等待B结束
// A中join()之前的操作 HB join()返回
// 所以join()返回后,一定能看到B的所有操作结果
6. 传递性规则(Transitivity)
如果A happens-before B,B happens-before C,那么A happens-before C。
7. volatile数组规则(Volatile Array Rule)
对volatile数组的引用写入,happens-before后续对数组元素的读取。
volatile int[] arr = new int[10];
Thread A:
arr[0] = 100; // 写入数组元素
arr = new int[20]; // 写入数组引用
Thread B:
int[] localArr = arr; // 读取数组引用
int x = localArr[0]; // 读取数组元素
// arr的写 HB arr的读
// 但arr[0]的写不一定 HB arr[0]的读
// 关键在于:先读引用还是先写引用
8. Thread.interrupt()规则
对线程interrupt()的调用,happens-before被中断线程检测到中断事件。
Thread B:
while (!Thread.interrupted()) {
// 执行业务逻辑
}
// 检测到中断后,可以在这里处理
Thread A:
thread.interrupt(); // 中断B
// interrupt() HB B检测到中断
❌ 错误理解:happens-before不是时间先后
很多同学误以为happens-before是"先执行的操作先发生",这是错的。
// 线程A:
a = 1; // 操作1
b = 2; // 操作2
// 线程B:
if (b == 2) {
System.out.println(a); // 可能打印0!
}
操作1和操作2在时间上:1先于2。
但由于没有happens-before关系,线程B可能看到a=0。
正确的理解是:happens-before定义的是可见性和有序性,不是时间顺序。
volatile的底层原理
可见性保证
volatile通过内存屏障(Memory Barrier)实现可见性:
graph LR
subgraph WriteThread[写线程]
W1[业务代码]
W2[Store屏障<br/>刷新到主内存]
W3[后续业务代码]
end
subgraph ReadThread[读线程]
R1[业务代码]
R2[Load屏障<br/> invalidate缓存]
R3[读取主内存最新值]
end
W2 -->|数据| R2
volatile写的原理:
- 在写操作后插入Store屏障
- 将数据刷新到主内存
- 其他CPU核心的缓存行失效
volatile读的原理:
- 在读操作前插入Load屏障
- invalidate其他CPU核心的缓存
- 从主内存重新读取最新值
禁止重排序
volatile还能防止重排序。编译器/JIT会在volatile读写前后插入内存屏障:
volatile boolean ready = false;
int result = 0;
Thread A:
result = 42; // 普通写
ready = true; // volatile写(有Store屏障)
Thread B:
while (!ready) { // volatile读(有Load屏障)
Thread.sleep(100);
}
System.out.println(result); // 一定能打印42
生产中的JMM问题
问题1:双重检查锁定的单例模式
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
这个单例模式在早期Java中是错误的!instance = new Singleton()不是原子操作:
- 分配内存
- 调用构造函数
- 将引用赋值给instance
如果发生重排序,步骤3可能在步骤2之前执行,导致其他线程看到未构造完成的对象。
解决方案:使用volatile
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile的禁止重排序保证了:instance引用写入前,对象完全构造完成。
问题2:long/double的非原子性
在JMM中,对非volatile的long和double变量的读写不是原子的:
public class LongDemo {
private long value = 0; // 非volatile
public void writer() {
value = 0x12345678ABCDL; // 可能读到中间状态!
}
public long reader() {
return value; // 可能读到"撕裂"的值
}
}
在32位JVM上,这个赋值可能分两次完成,高32位和低32位分别写入,中间状态可能被其他线程看到。
解决方案:使用volatile或synchronized
private volatile long value = 0; // volatile long
// 或者
private synchronized void setValue(long v) {
value = v;
}
问题3:竞态条件
public class Counter {
private long count = 0;
public void increment() {
count++; // 不是原子操作!
}
public long getCount() {
return count;
}
}
count++分解为三步:
- 读取count
- 加1
- 写回count
多线程环境下会丢失更新。
解决方案:使用原子变量或锁
public class Counter {
private AtomicLong count = new AtomicLong(0);
public void increment() {
count.incrementAndGet();
}
public long getCount() {
return count.get();
}
}
面试中的高频追问
追问1:synchronized和volatile的区别?
synchronized既保证原子性又保证可见性,volatile只保证可见性和有序性。
追问2:为什么long/double需要volatile才能保证原子性?
因为long/double是64位,在32位JVM上需要两次写操作。volatile强制每次读/写都直接操作主内存,避免"撕裂"。
追问3:final字段能保证线程安全吗?
public class FinalDemo {
private final int x;
private final int[] arr;
public FinalDemo() {
x = 10;
arr = new int[]{1, 2, 3};
}
}
构造函数中写入final字段的值,对其他线程可见(因为final字段不会变)。但引用类型的字段,其引用是final的,但内容不是。
追问4:String的intern()是线程安全的吗?
String s1 = new String("hello");
String s2 = s1.intern();
intern()方法将字符串放入字符串常量池,在JDK7之前字符串常量池在Perm区,intern()可能有并发问题。JDK7之后在堆中,有JVM保证线程安全。
【学习小结】
- JMM存在的原因:CPU缓存和编译器优化导致可见性/有序性问题,需要语言层面规范
- 核心概念:主内存 vs 工作内存,read/write操作
- happens-before:定义可见性和有序性的规则,不是时间顺序
- 8条规则:程序顺序、监视器锁、volatile、线程启动/终止、传递性、volatile数组、interrupt
- volatile原理:内存屏障,刷新缓存 + invalidate缓存
- 生产注意:单例模式用volatile long/double读写用volatile或锁
延伸阅读: