前言
很多 Java 程序员都会写 volatile,也知道 DCL 单例一定要加 volatile。
但是很少有人真正思考:
CPU 为什么会乱序执行?
为什么会出现 DCL 单例失效?
volatile 到底是 JVM 做的,还是 CPU 做的?
内存屏障到底是什么?
如果不了解 CPU 的执行机制,你看到的 Java 内存模型(JMM)其实只是"表象"。
这篇文章,我们从 CPU 的角度,一步步理解 Java 并发为什么要这样设计。
一、为什么 CPU 不按照代码顺序执行?
很多人的第一反应是:
我代码写的是
a = 1;
b = 2;
CPU 就一定是
执行a
↓
执行b
实际上不是。
现代 CPU 为了提高吞吐量,会尽可能让更多执行单元同时工作。
例如:
A 指令:读取内存
B 指令:计算加法
C 指令:写入寄存器
如果它们彼此没有依赖关系,
CPU 完全可能变成:
执行B
↓
执行C
↓
执行A
虽然顺序变了,
但是最终结果一样。
这种技术,就叫:
Out-of-Order Execution(乱序执行)
它几乎是现代所有高性能 CPU 的标配。
二、CPU 为什么要乱序?
因为 CPU 太快了。
真正慢的是:
- 内存
- IO
- Cache Miss
例如:
读取内存
↓
等待100ns
CPU 不可能傻傻等待。
于是它会:
等待内存的时候
↓
先去执行后面的指令
这样整个流水线不会停。
所以:
CPU 的目标不是按顺序执行,而是最快完成任务。
三、乱序执行依赖哪些技术?

现代 CPU 能做到乱序,并不是简单交换指令。
它背后依赖很多硬件技术。
例如:
流水线(Pipeline)
把一条指令拆成多个阶段:
取指
↓
译码
↓
执行
↓
写回
不同指令可以同时处于不同阶段。
分支预测(Branch Prediction)
CPU 会提前猜:
if (x > 0)
到底走哪条路径。
猜对:
几乎没有损耗。
猜错:
流水线全部清空。
重新执行。
寄存器重命名(Register Renaming)
避免两个变量实际上使用同一个物理寄存器。
减少数据冲突。
重排序缓冲区(ROB)
虽然执行顺序乱了,
但是提交结果时,
必须恢复程序员看到的执行顺序。
所以 CPU 内部还有一个:
Reorder Buffer
负责"善后"。
四、乱序执行为什么不会影响单线程?
这里有一个经典原则:
As-if-Serial
意思就是:
CPU 可以随便优化。
但是:
最终结果必须和顺序执行一致。
例如:
a = 1;
b = 2;
CPU 可以:
先准备b
再准备a
最后一起提交
程序员完全感觉不到。
所以:
单线程一般不会出问题。
五、多线程为什么会出问题?

真正的问题,从多线程开始出现。
来看一段经典代码。假设 a、b、x、y 的初始值都是 0:
// Thread 1
a = 1;
x = b;
// Thread 2
b = 1;
y = a;
线程 1 先把 a 改成 1,然后读取 b;
线程 2 先把 b 改成 1,然后读取 a。
最终可能出现哪些结果?
x = 0, y = 1
x = 1, y = 0
x = 1, y = 1
这些结果都比较容易理解。
例如,线程 1 先执行完,那么线程 2 读取 a 时,就可能读到 1。
但还有一个看起来不可能的结果:
x = 0, y = 0
为什么它看起来不可能?
如果所有指令都严格按照代码顺序执行,并且一个线程写入的数据能够立刻被另一个线程看到,那么:
- 线程 1 要读到
b = 0,就必须在线程 2 执行b = 1之前读取b; - 线程 2 要读到
a = 0,就必须在线程 1 执行a = 1之前读取a。
但是,每个线程内部又都是“先写后读”:
线程 1:写 a → 读 b
线程 2:写 b → 读 a
按照严格的全局执行顺序,这两个条件无法同时成立。
因此,在理想的顺序一致性模型中,x = 0、y = 0 不应该出现。
但真实的 CPU 并不是这样工作的。
现代 CPU 为了提高执行效率,通常不会等待一次写操作完全同步到其他 CPU 核心之后,再继续执行后面的读操作。写入的数据可能先进入当前核心的 Store Buffer,而后面的读取操作已经开始执行。
于是,实际效果可能变成:
线程 1:a = 1 暂时保存在本地,然后读取 b,得到 0
线程 2:b = 1 暂时保存在本地,然后读取 a,得到 0
此时,两个线程都执行了自己的写操作,但这些写操作还没有及时对另一个线程可见。
所以最终就可能得到:
x = 0
y = 0
需要注意的是,这种现象不能只用“CPU 把两条指令交换了顺序”来理解。
更准确地说,它可能由多种因素共同造成:
- 编译器或 JIT 对指令进行重排序;
- CPU 采用乱序执行;
- 写操作暂存在 Store Buffer 中;
- 不同 CPU 核心观察到内存操作的顺序不同;
- 程序中没有使用同步机制建立可见性和有序性保证。
因此,多线程程序中的“执行顺序”,不能只看源代码的书写顺序。
对当前线程来说,代码可能仍然保持正确的执行结果;但对另一个线程来说,它观察到的内存操作顺序却可能完全不同。
这也是为什么 Java 需要通过 volatile、synchronized 和内存屏障,来约束重排序,并保证线程之间的内存可见性。
六、DCL 单例为什么必须加 volatile?

来看经典的双重检查锁定,也就是 DCL:
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;
}
}
这里为什么一定要给 instance 加上 volatile?
关键就在这一句:
instance = new Singleton();
从 Java 源代码来看,它只有一行;但从底层执行过程来看,可以大致拆成三个步骤:
① 为 Singleton 对象分配内存
② 执行构造过程,初始化对象
③ 将对象引用赋值给 instance
按照我们直觉中的顺序,应该是:
分配内存
↓
初始化对象
↓
发布对象引用
也就是只有当对象完全初始化之后,其他线程才能通过 instance 访问它。
但是,在没有任何内存语义约束的情况下,编译器、JIT 和 CPU 可能对内存操作进行优化。
从其他线程的观察结果来看,步骤②和步骤③可能表现为:
① 分配内存
③ instance 指向这块内存
② 对象初始化完成
注意,这里不一定意味着 CPU 真的把两条机器指令简单交换了位置。
更准确地说,是对象引用的写入可能先被另一个线程观察到,而构造过程中对对象字段的写入还没有全部对该线程可见。
此时,执行过程可能变成:
线程 A:
为对象分配内存
↓
instance 已经指向对象
↓
继续初始化对象
线程 B:
发现 instance != null
↓
直接返回 instance
↓
读取到尚未完全初始化的状态
线程 B 虽然拿到了一个非空引用,但这个对象的构造结果还没有安全地发布给它。
这就是 DCL 中真正危险的地方。
因此,volatile 在这里有两个重要作用。
第一,保证可见性。
线程 A 完成对 instance 的写入后,线程 B 能够看到最新的引用。
第二,保证有序性和安全发布。
对 instance 的 volatile 写入,不能被重排序到对象初始化操作之前;而线程 B 读取到这个 volatile 引用后,也能够看到线程 A 在发布引用之前完成的初始化操作。
在 Java 内存模型中:
对 volatile 变量的写
happens-before
后续对同一个 volatile 变量的读
因此,只要线程 B 读取到了线程 A 写入的 instance,它就能同时看到线程 A 在写入 instance 之前完成的对象初始化。
所以,DCL 中的 volatile 并不只是为了让其他线程“及时看到 instance”。
它更重要的作用是:
防止对象引用在初始化完成之前被发布,并保证对象能够被安全地交给其他线程使用。
七、volatile 到底解决了什么?
很多人只记住一句话:
volatile保证可见性。
这句话没有错,但并不完整。
volatile 主要提供两方面的内存语义:
可见性
+
有序性
1. 可见性
当一个线程修改 volatile 变量时,其他线程再次读取这个变量,能够看到最新的值。
例如:
volatile boolean running = true;
一个线程执行:
running = false;
另一个线程不断读取 running 时,能够观察到这次修改。
但需要注意:
volatile 只保证单次读取和写入的可见性,并不能让复合操作自动具备原子性。
例如:
volatile int count = 0;
count++;
count++ 实际上包含:
读取 count
加 1
写回 count
多个线程同时执行时仍然可能发生数据竞争。
因此:
volatile不能代替锁,也不能保证所有操作都是线程安全的。
2. 有序性
volatile 还会限制特定的指令重排序。
对于 volatile 写:
data = 100;
ready = true; // volatile 写
Java 内存模型要求:
data = 100
不能被重排序到
ready = true
之后
对于 volatile 读:
if (ready) { // volatile 读
System.out.println(data);
}
读取 ready 之后的普通读操作,也不能被随意移动到 volatile 读之前。
于是,两个线程之间形成了这样的关系:
线程 A:
写入普通变量
↓
写入 volatile 变量
线程 B:
读取 volatile 变量
↓
读取普通变量
如果线程 B 读取到了线程 A 写入的 volatile 值,那么线程 A 在 volatile 写之前完成的普通写入,对线程 B 也必须可见。
这就是 volatile 的核心语义:
volatile 不只是让某个变量本身可见,它还可以建立线程之间的 happens-before 关系。

3. volatile 和内存屏障
为了实现这些语义,JVM 会在生成机器代码时加入必要的内存顺序约束。
在经典的内存屏障模型中,常见屏障包括:
LoadLoad
LoadStore
StoreStore
StoreLoad
它们分别用于限制不同类型内存操作之间的重排序。
可以简单理解为:
LoadLoad:限制“读—读”重排序
LoadStore:限制“读—写”重排序
StoreStore:限制“写—写”重排序
StoreLoad:限制“写—读”重排序
但需要注意:
并不是每次 volatile 读写前后,都会机械地插入全部四种屏障。
JMM 规定的是 volatile 必须表现出的内存语义;JVM 会根据具体操作和 CPU 架构,生成满足这些语义的最小指令组合。
通常可以把 volatile 的语义理解为:
volatile 写具有 release 语义
volatile 读具有 acquire 语义
release 保证发布之前的写操作不会跑到发布之后;
acquire 保证读取之后的操作不会跑到读取之前。
两者配合,完成线程之间的数据发布与读取。

八、CPU 如何真正限制乱序执行?
Java 内存模型规定了程序必须表现出的结果,但 JMM 本身并不直接执行任何机器指令。
真正将这些规则落实到底层的是:
Java 编译器
↓
JIT 编译器
↓
JVM
↓
具体 CPU 架构
首先,编译器和 JIT 必须限制自身的重排序。
它们不能把本应受到 volatile 或锁保护的操作,优化到不正确的位置。
这种约束通常可以称为:
Compiler Barrier
也就是编译器屏障。
它首先解决的是:
不允许编译器在这里进行破坏内存语义的代码移动。
但只限制编译器还不够。
现代 CPU 内部还存在:
乱序执行
Store Buffer
失效队列
多级缓存
推测执行
即使生成的机器指令顺序没有变化,不同核心观察到内存操作的顺序也可能不同。
因此,在必要时,JVM还需要生成特定的硬件屏障指令。
例如,在 x86 架构中可能涉及:
mfence
带 lock 前缀的指令
在 ARM 架构中,则可能使用:
dmb
不同处理器的内存模型不同,因此 JVM 生成的指令也不一样。
x86 的内存模型相对较强,很多“读—读”“读—写”和“写—写”顺序天然就能得到保证。
它最需要额外处理的通常是:
StoreLoad
也就是前面的写操作与后面的读操作之间的顺序。
因此,在 x86 上,JVM 不一定需要为每一种内存顺序都生成一条独立的硬件屏障指令。
而在 ARM 等内存模型较弱的处理器上,JVM通常需要使用更多明确的屏障指令,才能实现相同的 Java 内存语义。
内存屏障的作用也不能简单理解为:
屏障前面的所有指令必须全部执行结束,后面的指令才能开始。
更准确地说,它限制的是:
特定类型的内存操作
不能以不被允许的顺序
被其他处理器观察到
它关注的重点不是单个 CPU 核心内部的时间顺序,而是多个线程之间能够观察到的内存顺序。
因此,内存屏障并不是告诉 CPU:
这里完全不能优化。
而是告诉编译器和 CPU:
这里的内存操作必须满足规定的顺序和可见性,不能进行破坏语义的重排序。
最终,Java 中的一行:
volatile boolean ready;
背后连接的是一整套机制:
Java 内存模型
happens-before 规则
JIT 编译器屏障
CPU 内存屏障
缓存一致性机制
它们共同保证了:
一个线程正确发布的数据,能够被另一个线程按照规定的顺序观察到。

九、CPU 还有哪些底层优化?
除了乱序执行,
现代 CPU 还有很多性能优化技术。
例如:
| 技术 | 作用 |
|---|---|
| Cache Line | 提高缓存利用率 |
| Cache Line Alignment | 避免伪共享 |
| Write Combining | 合并多个写操作 |
| Cache Coherence | 保证多核缓存一致 |
| NUMA | 就近访问内存,提高多 CPU 性能 |
这些技术共同决定了一台服务器真正的性能上限。

十、总结
很多人学习 volatile 时,只停留在 Java 语法层。
真正理解之后,你会发现:
Java
↓
JMM
↓
HotSpot
↓
CPU
↓
晶体管
这是一条完整的技术链路。
volatile 不是魔法。
它只是 JVM 对 CPU 能力的一层抽象。
理解 CPU 为什么会乱序执行,理解内存屏障为什么存在,理解缓存一致性协议如何工作,你才真正理解了 Java 并发的底层逻辑。
评论区