从一段诡异的代码说起
java
public class VolatileDemo {
private static boolean flag = false; // 不加volatile
public static void main(String[] args) {
new Thread(() -> {
while (!flag) { // 线程B:死循环等待flag变成true
}
System.out.println("线程B退出");
}).start();
Thread.sleep(1000);
flag = true; // 线程A:1秒后将flag设为true
System.out.println("线程A已设置flag=true");
}
}
你猜结果是什么? 在server模式下,线程B永远不会退出------明明线程A已经把flag改成了true,线程B却看不见。这不是CPU缓存的问题吗?但JVM内存模型中的"本地内存"又是什么?
本文核心问题:
- Java内存模型(JMM)到底是什么?和JVM内存结构是一回事吗?
- 可见性问题是怎么发生的?底层原因是什么?
- volatile为什么能保证可见性?内存屏障做了什么?
- 指令重排序是什么?volatile如何禁止重排序?
- happens-before规则有哪些?怎么用?
- DCL单例为什么必须加volatile?
- volatile能保证原子性吗?和synchronized的区别?
- JMM和硬件内存模型的关系是什么?
读完本文你将彻底理解Java并发编程中最基础也最核心的可见性、有序性、原子性三大问题。
一、JMM到底是什么?和JVM内存结构是两码事
疑问:JMM和JVM运行时数据区(堆、栈、方法区)是什么关系?
回答:这是两个完全不同层面的概念,80%的人会搞混。
JVM内存结构(运行时数据区)------物理划分
这是你在JVM系列学到的:
JVM运行时数据区:
├── 线程私有
│ ├── 程序计数器
│ ├── 虚拟机栈(栈帧:局部变量表、操作数栈)
│ └── 本地方法栈
└── 线程共享
├── 堆(对象实例)
└── 方法区/元空间(类信息、常量)
关键词:内存区域物理划分、对象存在哪、GC发生在哪。
JMM(Java内存模型)------并发模型抽象
JMM是一个抽象规范,定义了一套规则:
JMM抽象模型:
├── 主内存(Main Memory)------ 所有线程共享,存放共享变量
├── 本地内存(Local Memory)------ 每个线程私有,存放共享变量的副本
└── 八个原子操作:lock/unlock/read/load/use/assign/store/write
关键词:并发访问规则、可见性保证、happens-before约束。
举一个例子彻底区分
java
public class User {
private String name = "张三"; // name存储在堆中(JVM内存结构视角)
}
// 线程A
user.setName("李四"); // JMM视角:线程A在本地内存中修改副本,再同步到主内存
// 线程B
System.out.println(user.getName()); // JMM视角:线程B从主内存读取(可能是旧值)
- JVM内存结构回答:name这个字符串对象存在堆里
- JMM回答:线程B能否看到线程A的修改、什么时候能看到
一句话总结:JVM内存结构管"东西放哪";JMM管"多个线程怎么读写同一个东西"。
二、可见性问题的真相------从CPU缓存到JMM抽象
疑问:为什么线程A改了flag,线程B看不见?到底是CPU缓存还是JMM的锅?
回答:三层递进关系------底层硬件CPU缓存 → JMM规范抽象 → Java代码行为。
你之前写的那篇「线程本地缓存?CPU缓存!」已经点到了核心------"线程没有本地内存",那是什么?
2.1 硬件层:CPU三级缓存
CPU 核心0 CPU 核心1
│ │
L1 缓存(32KB,私有) L1 缓存(32KB,私有)
│ │
L2 缓存(256KB,私有) L2 缓存(256KB,私有)
│ │
└──────── L3 缓存 ──────────┘
(共享)
│
主内存(RAM)
可见性问题的硬件根因 :每个CPU核心有自己的L1/L2高速缓存。线程A运行在核心0上,修改了变量flag,这个修改可能只停留在核心0的L1缓存里,还没有刷新到主内存。线程B运行在核心1上,从自己核心的L1缓存里读到的还是旧值。
这就是你文章中"线程本地内存其实是CPU缓存"的正确答案------JMM规范里说的"本地内存",在硬件上对应的是CPU的私有缓存。
2.2 JMM层:抽象模型
JMM把这个硬件事实抽象为"主内存"和"本地内存":
JMM抽象模型:
线程A 主内存 线程B
┌──────────┐ ┌──────────┐ ┌──────────┐
│ flag=true│ │ flag=false│ │ flag=false│(不可见)
│ (副本) │ │ (主内存) │ │ (副本) │
└──────────┘ └──────────┘ └──────────┘
- 线程A修改了flag,但还没有写回主内存
- 线程B从主内存读到的还是false
- JMM不保证一个线程的修改立即可见于其他线程,除非显式使用同步机制
2.3 Java代码层
java
private static boolean flag = false; // 没有volatile
// 线程A:flag = true; // 可能只写到CPU缓存,没到主内存
// 线程B:while(!flag){} // 可能只读CPU缓存,看不到线程A的修改
三层总结:
Java层: 没有volatile修饰 → JMM不保证可见性
JMM层: 允许线程在本地内存操作 → 不需要立即同步回主内存
硬件层: CPU缓存延迟刷新 → 其他核心看不到
三、volatile如何保证可见性?内存屏障的魔法
疑问:volatile到底做了什么,让flag的修改能被所有线程看见?
回答:volatile通过在指令序列中插入内存屏障(Memory Barrier),强制完成三件事。
3.1 volatile的读写语义
java
private static volatile boolean flag = false;
加上volatile后,JMM规定:
| 操作 | 语义 |
|---|---|
| volatile写 | 将当前线程本地内存中修改的值立即刷新到主内存 |
| volatile读 | 每次读取都从主内存重新加载,不使用本地内存的缓存值 |
3.2 内存屏障的种类和作用
JMM定义了四种内存屏障:
屏障类型 指令示例 作用
──────────────────────────────────────────────────
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的插入规则:
java
// volatile写之前插入 StoreStore 屏障
// 确保在写volatile之前,之前的普通写操作全部完成
storestore();
volatile变量 = 新值;
// volatile写之后插入 StoreLoad 屏障
// 确保本次volatile写对后续读可见
storeload();
// ====================
// volatile读之后插入 LoadLoad 屏障
// 确保后续普通读操作能读到最新值
int val = volatile变量;
loadload();
// volatile读之后插入 LoadStore 屏障
// 确保后续普通写操作不重排到volatile读之前
loadstore();
用底层术语讲 :内存屏障本质上是一条CPU指令(如x86的mfence、lfence、sfence),它强制CPU将写缓冲区的数据刷到缓存/内存,并使其他核心的缓存行失效。
3.3 缓存一致性协议(MESI)
volatile除了内存屏障,还依赖CPU的缓存一致性协议:
MESI四种状态:
M (Modified) : 该缓存行只在本核心,已被修改,需要写回主内存
E (Exclusive) : 该缓存行只在本核心,与主内存一致
S (Shared) : 该缓存行在多个核心,与主内存一致
I (Invalid) : 该缓存行无效,需要从主内存重新读取
volatile写时:
→ 将本地缓存行状态置为M
→ 通过总线发送消息,使其他核心的对应缓存行失效(置为I)
→ 其他核心读取时发现缓存失效,从主内存重新加载
可见性的完整链路:
线程A写volatile变量:
StoreStore屏障 → 刷新写缓冲区 → CPU发RFO消息 →
其他核心缓存行失效(I) → 写入主内存 → StoreLoad屏障
线程B读volatile变量:
本地缓存失效(I) → 从主内存加载 → LoadLoad屏障 → 读到最新值
四、指令重排序与volatile的有序性保证
疑问:加了volatile就能禁止指令重排吗?什么是重排序?
回答:volatile能禁止特定位置的重排序,但不是禁止全部。
4.1 编译器和CPU的重排序
java
// 你写的代码
a = 1; // 1
b = 2; // 2
flag = true; // 3 (volatile写)
c = 3; // 4
d = 4; // 5
JMM允许的重排序范围:
重排序自由区:
a=1 和 b=2 可以互换(1和2之间没有屏障)
c=3 和 d=4 可以互换(4和5之间没有屏障)
重排序禁止区:
所有在 volatile写之前的操作,不能重排到volatile写之后
所有在 volatile写之后的操作,不能重排到volatile写之前
本质:volatile就像一个"栅栏",只能管住栅栏两侧的操作不互换,但栅栏同侧的操作依然可以自由重排。
4.2 一个经典例子
java
// 线程A
data = 100; // 1
ready = true; // 2 (volatile写)
// 线程B
if (ready) { // 3 (volatile读)
System.out.println(data); // 4
}
volatile保证了什么?
- 1一定在2之前执行(不会被重排到2之后)
- 3一定在4之前执行(不会被重排到4之后)
- 所以:线程B看到
ready=true时,一定能看到data=100
如果没有volatile修饰ready :1和2可能重排,线程B可能看到ready=true但data=0(data还没赋值)。
五、happens-before规则------JMM的终极法则
疑问:除了volatile,还有哪些情况能保证可见性?
回答:JMM定义了一套happens-before规则,只要满足其中一条,前一个操作的结果就对后一个操作可见。
八大happens-before规则
java
1. 程序次序规则:同一个线程内,前面的代码 happens-before 后面的代码
int a = 1; // 1
int b = 2; // 2
// 1 happens-before 2
2. volatile变量规则:volatile写 happens-before volatile读
volatile int v;
v = 1; // 写
int x = v; // 读,能看到v=1
3. 锁规则:unlock happens-before lock
synchronized(obj) { a = 1; } // 释放锁
synchronized(obj) { int x = a; } // 获取锁,x一定等于1
4. 传递性:A hb B, B hb C → A hb C
// 结合规则1+2:volatile写前的所有操作,对volatile读后的所有操作可见
5. 线程启动规则:Thread.start() happens-before 该线程的run()
t.start();
// t.run()能看到start()之前的所有修改
6. 线程终止规则:线程的所有操作 happens-before join()返回
t.join();
// join返回后,能看到t线程的所有修改
7. 线程中断规则:interrupt() happens-before 被中断线程检测到中断
t.interrupt();
// t检测到中断时,能看到interrupt()之前的所有修改
8. 对象终结规则:构造函数执行完 happens-before finalize()
happens-before是最重要的并发概念,它是判断"线程B能不能看到线程A的修改"的唯一标准。不满足任何一条规则,就不能保证可见性。
六、DCL单例为什么必须用volatile?
疑问:双重检查锁定(DCL)单例模式,为什么volatile不能省?
回答:因为new操作不是原子的,没有volatile会导致指令重排序,线程可能拿到"半初始化"的对象。
6.1 没有volatile的DCL
java
public class Singleton {
private static Singleton instance; // 没有volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题出在这!
}
}
}
return instance;
}
}
6.2 new Singleton() 的实际执行过程
JVM将这一行代码分解为三条指令:
memory = allocate(); // 1. 分配内存空间
ctorInstance(memory); // 2. 调用构造函数初始化对象
instance = memory; // 3. 将instance指向分配的内存地址
问题:指令2和3可能被重排序!
memory = allocate(); // 1
instance = memory; // 3(重排后先执行) ← instance已经非null了!
ctorInstance(memory); // 2(重排后后执行) ← 但对象还没初始化!
多线程下的灾难:
时间线:
T1: 进入synchronized,执行new操作
T1: 分配内存 → instance指向内存(但还没初始化)
T2: 第一次检查 instance != null → 直接返回instance
T2: 拿到一个没初始化的对象!可能NPE或拿到错误的字段值
T1: 初始化对象(已经晚了)
6.3 加volatile解决
java
private static volatile Singleton instance;
volatile禁止了指令2和3的重排序。因为instance = memory是一个volatile写,它之前的ctorInstance(memory)(普通写)不能重排到volatile写之后。
完整的DCL:
java
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile保证安全
}
}
}
return instance;
}
}
七、volatile不能保证原子性
疑问:volatile保证了可见性,那i++用volatile安全吗?
回答:不安全。volatile只保证可见性和有序性,不保证原子性。
java
private static volatile int count = 0;
// 10个线程各执行10000次 count++
count = 0 → 最终结果 ≠ 100000(远小于预期)
原因 :count++不是原子操作,它分为三步:
1. 从主内存读取count的当前值(比如42)
2. 在CPU里执行 42 + 1 = 43
3. 把43写回主内存
并发问题:
时间线:
T1: 读取 count = 42
T2: 读取 count = 42 ← 两个线程都读到了42
T1: count = 43 ← T1写回
T2: count = 43 ← T2写回,也写43!两次++但值只加了1
volatile能做到什么 :T1写回后,T2能立即看到最新值(不会读到过期值),但不能阻止T2在T1写回之前就已经读取了旧值。
解决方案:
| 方案 | 用法 |
|---|---|
| synchronized | synchronized(this) { count++; } |
| AtomicInteger | atomicInteger.incrementAndGet() |
| LongAdder | 高并发计数最优 |
java
private static final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS保证原子性
八、JMM与硬件内存模型的对应关系
疑问:JMM为什么设计得这么抽象?直接映射硬件不更简单吗?
回答:因为不同CPU架构的内存模型差异巨大,JMM必须"屏蔽硬件差异",提供统一的行为保证。
| 硬件平台 | 内存模型特点 | 重排序程度 |
|---|---|---|
| x86/x64 | 强内存模型(TSO) | 只允许StoreLoad重排 |
| ARM/PowerPC | 弱内存模型 | 几乎所有重排都可能 |
| SPARC | TSO或RMO可选 | 取决于配置 |
JMM的设计折中:
强一致性模型(如x86 TSO):
优点:程序员容易理解,几乎不需要内存屏障
缺点:限制了硬件优化,性能差
弱一致性模型(如ARM):
优点:硬件可以大幅重排,性能好
缺点:程序员需要大量使用屏障,容易出错
JMM的折中:
为Java程序员提供统一的、易理解的happens-before规则
用volatile/synchronized声明需要保证可见性的地方
JVM负责在不同平台上插入对应的内存屏障
同一个volatile,不同平台的实现:
java
// Java代码:volatile写
x = 1;
// JVM在x86上的实现(只禁止StoreLoad重排,用mfence):
mov [x], 1
mfence
// JVM在ARM上的实现(禁止所有相关重排,用dmb):
dmb ish
str r1, [r0]
dmb ish
JMM的意义 :你只需要写一次volatile,剩下的屏障插入、平台适配全由JVM负责。这就是"一次编写,到处运行"在并发领域的体现。
总结
- JMM是并发访问的抽象规范,JVM内存结构是内存区域的物理划分,两者完全不同的概念
- 可见性问题的硬件根因是CPU私有缓存,JMM用"本地内存"抽象了它
- volatile通过内存屏障强制刷新写缓冲区、使其他核心缓存失效,保证可见性
- volatile通过禁止特定位置的重排序保证有序性,但不禁止所有重排
- happens-before是判断并发操作间可见性的唯一标准,掌握八条规则即可
- DCL单例必须加volatile,因为new操作可能被重排,导致拿到半初始化对象
- volatile不保证原子性,i++这种复合操作需要synchronized或AtomicInteger
- JMM的设计目的是屏蔽不同硬件平台的差异,提供统一的内存可见性保证
下一篇预告:第2篇------synchronized与ReentrantLock深度对比。