文章目录
前言
JVM内存模型是并发编程的理论基础。为什么多线程下变量修改对其他线程不可见?为什么指令会重排序?volatile如何保证可见性?happen-before原则是什么? 理解主内存与工作内存的交互、指令重排序的本质、volatile的内存屏障、happen-before的传递性,才能掌握Java并发的本质。
摘要
从"多线程下变量修改不可见"的经典bug出发,剖析JVM内存模型的核心原理。通过主内存与工作内存的隔离机制、指令重排序的优化策略、volatile的禁止重排与强制刷新、happen-before的8大规则,揭秘Java内存可见性与有序性的完整保证。配合详细时序图与代码示例,给出并发编程的最佳实践。
一、从变量修改不可见说起
周四上午,哈吉米遇到一个诡异的bug:
场景1:循环停不下来
public class VisibilityTest {
private static boolean flag = false;
public static void main(String[] args) throws Exception {
// 线程1:等待flag变为true
new Thread(() -> {
System.out.println("线程1启动,等待flag=true...");
while (!flag) {
// 死循环,等待flag变为true
}
System.out.println("线程1结束"); // 永远执行不到
}).start();
// 主线程:1秒后设置flag=true
Thread.sleep(1000);
System.out.println("主线程:设置flag=true");
flag = true;
}
}
// 输出:
线程1启动,等待flag=true...
主线程:设置flag=true
// 线程1永远不会结束!
哈吉米:“为什么线程1看不到flag=true?明明主线程已经设置了!”
南北绿豆:“这是可见性问题——线程1使用的是自己工作内存的副本,看不到主内存的修改。”
哈吉米:“工作内存?主内存?”
阿西噶阿西:“来,我给你讲讲JMM(Java Memory Model)。”
场景2:加了volatile就好了
private static volatile boolean flag = false; // 加volatile
// 运行结果:
线程1启动,等待flag=true...
主线程:设置flag=true
线程1结束 ← 正常结束了
哈吉米:“加了volatile就能看到了?volatile做了什么?”
南北绿豆:“volatile保证了可见性和有序性。”
二、JMM核心原理
2.1 主内存与工作内存
南北绿豆:“JMM定义了线程和内存的交互方式。”
内存模型结构:
主内存(Main Memory):
所有线程共享
存储变量的主副本
工作内存(Working Memory):
每个线程私有
存储变量的工作副本
交互过程:
线程读取变量:主内存 → 工作内存 → 线程
线程修改变量:线程 → 工作内存 → 主内存
内存模型示意图:
可见性问题原因:
线程1:
1. 从主内存读取flag=false到工作内存
2. 在while循环中一直使用工作内存的flag
3. 不再从主内存读取
线程2:
1. 设置flag=true(修改工作内存)
2. 刷新到主内存
问题:
线程1的工作内存中flag=false
看不到主内存的flag=true
→ 可见性问题
哈吉米:“原来每个线程有自己的工作内存,不同步。”
2.2 8种内存交互操作
JMM定义的8种原子操作:
| 操作 | 作用域 | 说明 |
|---|---|---|
| lock | 主内存 | 锁定变量,标识为线程独占 |
| unlock | 主内存 | 解锁变量 |
| read | 主内存 | 读取变量,传输到工作内存 |
| load | 工作内存 | 将read的值放入工作内存副本 |
| use | 工作内存 | 将工作内存的值传给执行引擎 |
| assign | 工作内存 | 将执行引擎的值赋给工作内存副本 |
| store | 工作内存 | 将工作内存的值传输到主内存 |
| write | 主内存 | 将store的值写入主内存变量 |
变量读写流程:
8种操作的规则:
规则:
1. read和load必须成对出现
2. store和write必须成对出现
3. 不允许线程丢弃assign操作(修改了必须同步回主内存)
4. 不允许线程无原因地同步数据到主内存(没有assign)
5. 新变量只能在主内存中诞生
6. 一个变量同一时刻只允许一个线程lock
7. lock操作会清空工作内存中的变量副本
8. unlock前必须先将变量同步回主内存
阿西噶阿西:“这8种操作定义了线程和内存的交互规范。”
三、三大特性
3.1 原子性(Atomicity)
定义:一个操作或多个操作,要么全部执行,要么全部不执行。
JMM保证的原子性操作:
基本类型的读写(天然原子性):
int i = 1; // 原子性
i = 2; // 原子性
long/double的读写(非原子性):
long l = 1L; // 非原子性(32位JVM,分两次写)
解决:加volatile
volatile long l = 1L; // 原子性
非原子性示例:
public class AtomicTest {
private static int count = 0;
public static void main(String[] args) throws Exception {
// 10个线程,每个线程+1000次
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // 非原子操作
}
}).start();
}
Thread.sleep(3000);
System.out.println("count = " + count);
// 期望:10000
// 实际:9856(每次不一样,都<10000)
}
}
count++为什么不是原子性?
count++分解为3步:
1. 读取count的值(load)
2. 加1(add)
3. 写回count(store)
并发问题:
线程1:读取count=100
线程2:读取count=100
线程1:计算100+1=101,写回
线程2:计算100+1=101,写回
结果:
count=101(丢失了一次+1)
解决方案:
// 方案1:synchronized
private static int count = 0;
public static synchronized void increment() {
count++;
}
// 方案2:AtomicInteger
private static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子操作
// 方案3:Lock
private static Lock lock = new ReentrantLock();
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
南北绿豆:“volatile只保证可见性,不保证原子性,count++要用锁或原子类。”
3.2 可见性(Visibility)
定义:一个线程修改了变量,其他线程能立即看到。
可见性问题原因:
原因1:工作内存缓存
线程使用工作内存的副本,不读主内存
原因2:CPU缓存
变量缓存在CPU缓存(L1、L2、L3)
其他CPU看不到
原因3:编译器优化
JIT编译器优化,将变量缓存在寄存器
volatile保证可见性:
private volatile boolean flag = false;
// volatile的效果:
// 线程1修改flag=true
// → 立即刷新到主内存
// → 通知其他线程的工作内存失效
// → 其他线程重新从主内存读取
volatile内存语义:
写volatile变量:
1. 修改工作内存的volatile变量
2. 立即刷新到主内存
3. 使其他CPU缓存失效(MESI协议)
读volatile变量:
1. 使工作内存的副本失效
2. 从主内存重新读取
volatile时序图:
其他保证可见性的方式:
1. synchronized
退出synchronized时,修改刷新到主内存
进入synchronized时,从主内存读取
2. final
final字段在构造方法结束后,对所有线程可见
3. Lock
unlock时刷新,lock时读取
阿西噶阿西:“volatile是轻量级的同步机制,只保证可见性,不保证原子性。”
3.3 有序性(Ordering)
定义:程序执行的顺序按照代码的先后顺序执行。
指令重排序:
为什么会重排序?
1. 编译器优化重排序
编译器在不改变单线程语义的情况下,调整指令顺序
2. 指令级并行重排序
CPU的指令流水线,乱序执行
3. 内存系统重排序
写缓冲区(Store Buffer)导致的乱序
重排序示例:
// 代码顺序
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
// 可能的执行顺序(重排序)
int b = 2; // 2
int a = 1; // 1
int c = a + b; // 3
// 单线程下,重排序不影响结果
多线程下的问题:
public class ReorderTest {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100000; i++) {
x = 0; y = 0; a = 0; b = 0;
// 线程1
Thread t1 = new Thread(() -> {
a = 1; // 1
x = b; // 2
});
// 线程2
Thread t2 = new Thread(() -> {
b = 1; // 3
y = a; // 4
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("第" + i + "次:x=0, y=0(发生重排序)");
break;
}
}
}
}
// 可能的输出:
第1234次:x=0, y=0(发生重排序)
// 原因:
// 线程1:指令2在指令1之前执行(x=b先于a=1)
// 线程2:指令4在指令3之前执行(y=a先于b=1)
// 结果:x=0, y=0
重排序时序图:
哈吉米:“指令重排序在多线程下会出问题!”
南北绿豆:“对!所以需要禁止重排序。”
四、volatile详解
4.1 volatile的两大特性
特性1:保证可见性
private volatile boolean flag = false;
// 线程1写
flag = true;
// → 立即刷新到主内存
// → 通知其他线程工作内存失效
// 线程2读
while (!flag) { }
// → 从主内存重新读取
// → 能看到flag=true
特性2:禁止指令重排序
public class Singleton {
private volatile static Singleton instance; // 必须加volatile
public static Singleton getInstance() {
if (instance == null) { // 1
synchronized (Singleton.class) {
if (instance == null) { // 2
instance = new Singleton(); // 3
}
}
}
return instance;
}
}
// 为什么要加volatile?
// new Singleton()分3步:
// 1. 分配内存
// 2. 初始化对象
// 3. 引用指向内存
//
// 可能重排序为:
// 1. 分配内存
// 3. 引用指向内存(对象还没初始化)
// 2. 初始化对象
//
// 问题:
// 线程1:执行到步骤3,instance!=null,但对象未初始化
// 线程2:判断instance!=null,直接返回
// 线程2拿到未初始化的对象 → 空指针异常
//
// volatile禁止重排序:
// 保证2在3之前执行
4.2 volatile的实现原理
内存屏障(Memory Barrier):
内存屏障作用:
1. 禁止重排序
2. 强制刷新缓存
volatile写操作:
volatile写前后插入内存屏障:
普通写 ↓
普通写 ↓
StoreStore屏障 ← 禁止上面的写与volatile写重排序
volatile写 ← volatile变量写
StoreLoad屏障 ← 禁止volatile写与下面的读重排序
普通读 ↓
volatile读操作:
volatile读前后插入内存屏障:
volatile读 ← volatile变量读
LoadLoad屏障 ← 禁止volatile读与下面的读重排序
普通读 ↓
普通读 ↓
LoadStore屏障 ← 禁止volatile读与下面的写重排序
普通写 ↓
4种内存屏障:
| 屏障类型 | 说明 |
|---|---|
| LoadLoad | 禁止读-读重排序 |
| StoreStore | 禁止写-写重排序 |
| LoadStore | 禁止读-写重排序 |
| StoreLoad | 禁止写-读重排序(最耗性能) |
volatile汇编指令:
Java代码:
volatile int v = 1;
编译后的汇编:
mov dword ptr [rsp+4], 1
lock addl $0x0, (%rsp) ← lock前缀指令
lock前缀的作用:
1. 锁定缓存行,其他CPU无法访问
2. 写操作立即刷新到主内存
3. 使其他CPU缓存失效(MESI协议)
南北绿豆:“volatile通过内存屏障和lock前缀指令,保证可见性和有序性。”
4.3 volatile的使用场景
适用场景:
场景1:状态标志
private volatile boolean flag = false;
场景2:双重检查锁(DCL)
private volatile static Singleton instance;
场景3:独立观察
private volatile long count = 0;
不适用场景:
场景1:复合操作
count++; // 不适用(非原子)
场景2:依赖当前值的操作
if (count > 0) { // 不适用
count--;
}
volatile vs synchronized:
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证 | ✅ 保证 |
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性 | ✅ 部分保证 | ✅ 保证 |
| 阻塞 | ❌ 不阻塞 | ✅ 可能阻塞 |
| 性能 | 高 | 低 |
| 适用 | 读多写少 | 复合操作 |
哈吉米:“volatile是轻量级同步,适合简单的标志位。”
五、happen-before原则
5.1 什么是happen-before?
定义:如果操作A happen-before 操作B,那么A的结果对B可见。
阿西噶阿西:“happen-before是JMM定义的可见性规则。”
8大happen-before规则:
规则1:程序顺序规则
int a = 1; // A
int b = 2; // B
// A happen-before B
// 在单线程内,A的结果对B可见
规则2:volatile变量规则
volatile boolean flag = false;
// 线程1
flag = true; // A(volatile写)
// 线程2
if (flag) { // B(volatile读)
doSomething();
}
// A happen-before B
// 线程1的修改对线程2可见
规则3:锁规则
synchronized (lock) {
int a = 1; // A
} // unlock
synchronized (lock) { // lock
int b = a; // B(能看到a=1)
}
// unlock happen-before lock
// A的结果对B可见
规则4:线程启动规则
int a = 1; // A
Thread t = new Thread(() -> {
int b = a; // B(能看到a=1)
});
t.start();
// A happen-before B
// 主线程的修改对子线程可见
规则5:线程终止规则
Thread t = new Thread(() -> {
int a = 1; // A
});
t.start();
t.join();
int b = a; // B(能看到a=1,如果a是共享变量)
// A happen-before B
// 子线程的结果对主线程可见
规则6:线程中断规则
// 线程1
thread.interrupt(); // A
// 线程2
if (Thread.interrupted()) { // B(能看到中断)
doSomething();
}
// A happen-before B
规则7:对象终结规则
// 构造方法
public User() {
this.name = "alice"; // A
}
// finalize方法
protected void finalize() {
String n = this.name; // B(能看到name="alice")
}
// A happen-before B
// 构造方法的修改对finalize可见
规则8:传递性
int a = 1; // A
volatile boolean flag = false;
flag = true; // B(volatile写)
// 线程2
if (flag) { // C(volatile读)
int b = a; // D(能看到a=1)
}
// A happen-before B(程序顺序)
// B happen-before C(volatile规则)
// → A happen-before C(传递性)
// → A happen-before D
happen-before传递链:
南北绿豆:“happen-before是可见性的理论基础,8大规则覆盖了所有并发场景。”
六、as-if-serial语义
6.1 单线程的重排序限制
as-if-serial语义:
定义:
不管怎么重排序,单线程的执行结果不能改变
示例:
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
// 可以重排序:
int b = 2; // 2
int a = 1; // 1
int c = a + b; // 3
// 不能重排序:
int c = a + b; // 3(错误,a和b还没赋值)
int a = 1; // 1
int b = 2; // 2
数据依赖性:
有数据依赖:
a = 1;
b = a + 1; // 依赖a,不能重排序到a之前
无数据依赖:
a = 1;
b = 2; // 不依赖a,可以重排序
哈吉米:“as-if-serial保证单线程语义不变,但多线程下会有问题。”
七、JMM总结
7.1 核心要点
南北绿豆总结:
- JMM定义:线程和主内存的交互规范
- 主内存与工作内存:线程有私有的工作内存副本
- 三大特性:原子性、可见性、有序性
- volatile:保证可见性和有序性,不保证原子性
- happen-before:定义可见性规则,8大规则
- 内存屏障:禁止重排序、强制刷新缓存
7.2 面试高频问题
阿西噶阿西:
问题1:什么是JMM?
JMM(Java Memory Model):
Java内存模型,定义了线程和主内存的交互规范
核心概念:
- 主内存:所有线程共享,存储变量主副本
- 工作内存:线程私有,存储变量工作副本
交互过程:
线程读变量:主内存 → 工作内存 → 线程
线程写变量:线程 → 工作内存 → 主内存
问题:
工作内存的副本可能不同步
→ 可见性问题、有序性问题
问题2:volatile的作用是什么?原理是什么?
作用:
1. 保证可见性
一个线程修改,其他线程立即可见
2. 禁止指令重排序
volatile变量的读写不会与其他操作重排序
原理:
1. 可见性实现:
- volatile写:立即刷新到主内存
- volatile读:从主内存重新读取
- lock前缀指令:使CPU缓存失效
2. 有序性实现:
- 插入内存屏障(LoadLoad、StoreStore等)
- 禁止屏障两边的指令重排序
注意:
volatile不保证原子性
count++ 需要用AtomicInteger或synchronized
问题3:happen-before原则有哪些?
8大规则:
1. 程序顺序规则
单线程内,前面的操作 happen-before 后面的操作
2. volatile变量规则
volatile写 happen-before volatile读
3. 锁规则
unlock happen-before lock
4. 线程启动规则
Thread.start() happen-before 线程内的操作
5. 线程终止规则
线程内的操作 happen-before Thread.join()
6. 线程中断规则
interrupt() happen-before 检测到中断
7. 对象终结规则
构造方法 happen-before finalize()
8. 传递性
A happen-before B,B happen-before C
→ A happen-before C
问题4:volatile和synchronized的区别?
volatile:
- 轻量级同步
- 保证可见性和有序性,不保证原子性
- 不阻塞线程
- 性能高
- 适用:状态标志、双重检查锁
synchronized:
- 重量级锁(JDK 6后优化了)
- 保证原子性、可见性、有序性
- 可能阻塞线程
- 性能低(相对volatile)
- 适用:复合操作、临界区
选择:
- 简单的标志位 → volatile
- 复合操作(i++) → synchronized或AtomicInteger
问题5:什么是内存屏障?
内存屏障(Memory Barrier):
CPU指令,用于禁止指令重排序和强制刷新缓存
4种屏障:
1. LoadLoad:禁止读-读重排序
2. StoreStore:禁止写-写重排序
3. LoadStore:禁止读-写重排序
4. StoreLoad:禁止写-读重排序(最耗性能)
volatile的内存屏障:
volatile写:
StoreStore屏障
volatile写
StoreLoad屏障
volatile读:
volatile读
LoadLoad屏障
LoadStore屏障
哈吉米:“掌握了JMM,并发编程的底层原理就清楚了。”
JVM内存模型与并发原理解析
2788

被折叠的 条评论
为什么被折叠?



