前言
前面的文章已经把 CPU Cache、Cache Line、MESI 和 False Sharing 串了起来。
但理解到这里,很容易产生另一个错觉:
既然多个 CPU Core 之间已经有缓存一致性协议,那么只要内存里的数据最终能够保持一致,多线程程序是不是就不会再出问题了?
答案显然不是。
下面这段 Java 代码看起来非常简单:
int a = 1;
int b = 2;
int c = a + b;
我们平时会自然地认为:
第一行
↓
第二行
↓
第三行
CPU 就会严格按照源代码的先后顺序,一条一条执行。
但现代处理器为了追求性能,实际做的事情远比这个模型复杂:
- 前一条指令正在等待内存,后面的指令一定要陪着等吗?
- 两条没有依赖关系的指令,为什么不能同时执行?
- CPU 内部可以乱序,为什么单线程程序通常又不会算错?
- 两个线程都没有写错代码,为什么却可能观察到“违反直觉”的结果?
- DCL 单例里的
volatile到底在防什么? volatile是不是简单粗暴地“禁止 CPU 乱序”?- LoadLoad、StoreStore、LoadStore、StoreLoad 到底是谁定义的?
- x86 的
lfence、sfence、mfence,和 Java 的 happens-before 又是什么关系? - NUMA 为什么又会影响 JVM 的内存分配?
如果把这些概念分开背,会得到一堆名词:
Out-of-Order
Memory Barrier
volatile
happens-before
Store Buffer
NUMA
但它们真正有价值的地方,是能够组成一条完整的因果链:
这篇文章就沿着这条链往下走。
一、CPU 为什么会乱序执行?
1. CPU 为什么不能一直等待内存?
假设 CPU 正准备执行下面几步:
指令 1:读取内存中的 x
指令 2:计算 2 + 3
指令 3:计算 8 * 4
指令 4:使用 x 做计算
其中指令 4 依赖指令 1:
指令1 → 指令4
但指令 2、指令 3 和 x 没关系。
如果 CPU 采用最机械的执行方式:
读取 x
↓
等内存
↓
等内存
↓
x 回来了
↓
算 2 + 3
↓
算 8 * 4
那么 CPU 在等待内存期间,大量执行资源都处于空闲状态。
更合理的做法当然是:
读取 x
↓
等待 x 的同时
├── 算 2 + 3
└── 算 8 * 4
↓
x 返回
↓
执行依赖 x 的指令
这就是理解乱序执行最重要的出发点:
乱序执行不是为了“打乱程序”,而是为了在某条指令暂时无法继续时,让其他已经具备条件的工作先跑起来。
2. 用后厨出餐理解乱序执行
换一个场景。
假设一家后厨正在准备一份套餐,需要完成:
- 烤箱里烤鸡;
- 切配沙拉;
- 装饮料;
- 给烤好的鸡肉淋酱;
- 最后一起装盘。
其中“淋酱”必须等鸡肉烤好,但切沙拉和装饮料并不依赖烤箱。
如果厨师采用严格串行方式:
把鸡放进烤箱
↓
站在烤箱前等待
↓
鸡肉烤好
↓
切沙拉
↓
装饮料
↓
淋酱、装盘
等待烤箱的这段时间就完全浪费了。
更合理的做法是:
这里最关键的不是“把步骤打乱”,而是区分两类关系:
没有依赖:
烤鸡进行中 ←→ 切沙拉 / 准备饮料
存在依赖:
鸡肉烤好 → 淋酱
CPU 的思路非常类似。
某条指令正在等待内存数据时,如果后面的指令已经具备操作数,而且不存在必须等待的依赖关系,那么处理器就有机会先利用空闲的执行单元去处理这些工作。
但真正依赖前序结果的操作仍然不能凭空越过去。
所以乱序执行真正追求的是:
让“已经准备好的工作”先使用空闲硬件资源,而不是为了改变程序逻辑而故意打乱顺序。
3. 乱序执行不是随便打乱顺序
真实处理器并不是看到两条 Java 语句没有依赖,就直接交换它们。
现代 CPU 内部通常还有:
- 指令 Fetch;
- Decode;
- 微操作;
- 寄存器重命名;
- 调度器;
- Reservation Station / Scheduler;
- Load / Store Queue;
- Reorder Buffer;
- 多个执行端口;
- 分支预测;
- 推测执行。
一个更接近现代处理器的抽象流程是:
内部执行顺序可以和程序顺序不同,但最终提交体系结构状态时,又要满足处理器定义的规则。
所以:
乱序执行描述的是 CPU 内部为了提高吞吐量而进行的动态调度,不等于“程序语义也可以随便乱”。
二、重排序到底发生在哪些层级?
讨论 Java 并发时,经常把所有问题都统一叫:
CPU 指令重排序。
这其实会把几个不同层级混在一起。
1. 从 Java 代码到 CPU 的三个层级
一段 Java 代码从源代码到真正执行,会经过:
这里至少可能出现三种不同意义上的“顺序变化”。
编译器 / JIT 优化
只要不破坏语言允许的可观察行为,编译器可以:
- 消除无用代码;
- 合并操作;
- 移动计算;
- 做公共子表达式消除;
- 做循环优化;
- 调整内存操作顺序。
ISA 的内存顺序模型
不同处理器架构,对 Load / Store 向其他处理器可观察的顺序有不同约束。
例如 x86 的内存顺序通常比 ARM 更强,但也不是“所有 Load / Store 都绝对按照程序顺序立即对其他 Core 可见”。
CPU 微架构的乱序执行
处理器内部可以让已经准备好的微操作提前进入执行单元,只要最终满足 ISA 和内存顺序模型。
因此后面看到某个多线程结果时,不能一上来就断言:
一定是 CPU 把这两条指令交换了。
因为你观察到的是整条链最终产生的行为。
三、(0, 0) 实验到底说明了什么?
下面这个小程序经常用来讲“指令重排序”。
初始状态:
int x = 0;
int y = 0;
int a = 0;
int b = 0;
两个线程分别执行:
Thread 1 Thread 2
a = 1; b = 1;
x = b; y = a;
如果严格按照顺序一致性来理解,并且每个线程内部程序顺序不能改变,那么:
x == 0 && y == 0
无法出现。
为什么?
要让:
x == 0
就意味着 Thread 1 读取 b 时:
b = 1
还没有发生。
而要让:
y == 0
又意味着 Thread 2 读取 a 时:
a = 1
还没有发生。
把两个条件连起来,会形成一个无法满足的循环顺序。
1. 示例代码
可以写一个简单实验:
public class Disorder {
static int x, y, a, b;
public static void main(String[] args) throws Exception {
for (long i = 1; ; i++) {
x = y = a = b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println(
"第 " + i + " 次出现 (0, 0)"
);
break;
}
}
}
}
编译:
javac Disorder.java
运行:
java Disorder
这个实验有两个重要注意事项。
第一,它不是一个保证几秒钟就能复现的程序。
不同的:
- CPU;
- JDK;
- JIT 状态;
- 操作系统调度;
- 运行环境;
都可能导致复现概率不同,甚至很长时间看不到 (0,0)。
第二,也是更重要的一点:
即使真的观察到了
(0,0),也不能据此唯一证明“物理 CPU 把某两条机器指令交换了”。
2. 为什么不能只归因于 CPU 乱序?
这段代码存在 Data Race,没有使用任何同步手段。
Java Memory Model 本身就允许一些不符合顺序一致性直觉的执行结果。
产生最终行为的原因可能涉及:
Java 源代码
↓
JIT 优化
↓
机器指令
↓
Store Buffer / Memory Ordering
↓
CPU 执行
因此更严谨的结论是:
这个实验说明:没有同步关系的数据竞争程序,不能用“每个线程都严格按源码顺序、所有线程立刻看到彼此写入”的模型去推理。
这和“直接抓到 CPU 内部某两条微操作交换位置”是两件不同的事情。
四、DCL 为什么需要 volatile?
乱序和内存可见性真正影响业务代码的经典案例,就是 Double-Checked Locking。
1. DCL 的问题出在哪里?
先看错误版本:
public class Singleton {
private static Singleton instance;
private int value;
private Singleton() {
value = 8;
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
它有两层检查:
第一次检查
↓
instance 已经存在?
├── 是 → 直接返回
└── 否 → 进入 synchronized
↓
第二次检查
↓
创建对象
看起来锁已经足够了。
问题出在:
instance = new Singleton();
并不是我们想象中的一个不可分割的“神奇动作”。
2. 查看真实字节码
把实例变量改成正确的 volatile 版本:
private static volatile Singleton instance;
在本文使用的 OpenJDK 21.0.11 环境中编译:
javac Singleton.java
然后:
javap -c -p Singleton.class
关键位置可以看到类似:
17: new #8
20: dup
21: invokespecial #17 // Method "<init>":()V
24: putstatic #13 // Field instance:LSingleton;
也就是 Class 文件中的字节码顺序清楚地写着:
new
↓
dup
↓
invokespecial <init>
↓
putstatic
这就带来一个很重要的纠正。
3. 字节码真的被直接重排了吗?
很多 DCL 教程会说:
1. 分配内存
2. 执行构造方法
3. 把引用赋给 instance
然后:
2 和 3 可能重排序
于是变成:
1. 分配内存
3. 发布引用
2. 执行构造
这个模型很适合建立直觉,但如果把它理解成:
Class 文件里的
invokespecial和putstatic两条字节码会被 JVM 直接交换位置。
就不准确了。
javap 已经能看到,字节码本身仍然保持:
new → invokespecial → putstatic
真正的问题在于:
如果
instance不是 volatile,另一个线程对这个对象引用的读取与构造线程中的普通写之间,没有建立可靠的 happens-before 关系。
因此从另一个线程可观察的角度,不能保证构造过程中对对象状态的写入,在它读到这个引用时已经按照需要全部可见。
所谓“看到半初始化对象”,更适合理解成一种不安全发布的可见性模型,而不是简单理解成“Class 文件把两条字节码调换了”。
4. 正确写法
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
关键就是:
volatile
它在这里不仅是“让别的线程马上看到 instance 不为 null”这么简单。
更重要的是建立:
构造线程对对象状态的写
↓
volatile 写 instance
↓
happens-before
↓
另一个线程 volatile 读 instance
↓
安全观察前面的初始化结果
这才是理解 DCL 的核心。
五、volatile 到底保证什么?
volatile 是 Java 并发里最容易被一句话说坏的关键字之一。
常见说法是:
volatile 保证可见性,并禁止指令重排序。
方向没错,但还需要继续拆开。
1. volatile 为什么不保证复合原子性?
例如:
volatile int count = 0;
执行:
count++;
仍然不是一个不可分割操作。
它至少可以抽象成:
读取 count
↓
+1
↓
写回 count
两个线程同时执行时仍然可能丢失更新。
所以:
volatile ≠ 锁
volatile ≠ 所有操作原子化
2. volatile 如何建立同步关系?
Java Language Specification 对 volatile 给出的关键关系之一是:
对某个 volatile 字段的写,happens-before 后续对同一个字段的读。
因此:
data = 100;
ready = true; // volatile
另一个线程:
if (ready) {
System.out.println(data);
}
一旦这个线程通过 volatile 读观察到对应的:
ready = true
Java Memory Model 就为前面的普通写建立了需要的可见性关系。
可以画成:
所以 volatile 最值得记住的不是某一条固定汇编指令,而是:
它在 Java 内存模型中建立了一条跨线程的同步边。
六、Java 的内存屏障怎么落到 CPU?
这里需要特别小心,因为“内存屏障”这个词经常同时出现在:
JMM
HotSpot
CPU
三个层级。
如果不分层,很容易越学越乱。
1. JLS 到底规定了什么?
Java Language Specification 负责规定的是:
- volatile 的同步语义;
- monitor 的同步语义;
- Thread.start / join 等跨线程关系;
- happens-before;
- 哪些执行属于 Java 语言允许的行为。
它不会规定:
x86 必须生成 mfence
ARM 必须生成 dmb
因为 JVM 的目标之一就是跨平台。
真正到了 HotSpot,需要由 JVM 实现把这些语言层语义映射到底层硬件。
2. LoadLoad 等四种屏障是什么?
经常会看到四种屏障:
LoadLoad
LoadStore
StoreStore
StoreLoad
它们不是四条 JVM 字节码,也不是 JLS 要求 CPU 必须存在的四条真实机器指令。
OpenJDK HotSpot 的 OrderAccess 接口明确说明:
这套接口基于 JSR-133 Cookbook for Compiler Writers。
它定义这些抽象屏障,是为了让 JVM 编译器能够用一个跨平台模型描述:
哪些 Load / Store 不能跨过彼此移动
例如:
Load1
LoadLoad
Load2
表达:
Load1 不能越过屏障跑到 Load2 后面
同理:
Store1
StoreStore
Store2
限制两个 Store 的相对移动。
3. volatile 的四屏障模型
一种常见的编译器教学模型是:
volatile 写
普通 Store
普通 Store
↓
StoreStore
↓
volatile Store
↓
StoreLoad
↓
后续 Load
volatile 读
volatile Load
↓
LoadLoad
LoadStore
↓
后续 Load / Store
这个模型非常适合理解:
volatile 为什么能够限制它周围的内存操作重排序。
但一定要加一句:
这是内存屏障抽象模型,不等于每次 volatile 访问都会在所有 CPU 上原样生成四条硬件 Fence。
4. 为什么 x86 和 ARM 实现不同?
因为不同 ISA 的内存顺序模型不同。
以 x86 为例,很多 LoadLoad、LoadStore、StoreStore 顺序本身已经由其内存模型提供较强保证。
HotSpot 当前 OrderAccess 源码中的架构映射表也明确体现了这一点:
x86 上很多 acquire / release 场景
不需要额外硬件 fence
StoreLoad / full fence
才需要更强的处理
而在更弱内存顺序的架构上,JVM 可能需要插入不同的 Barrier 指令。
所以我们写:
volatile boolean ready;
Java 代码完全一样。
真正落到底层时却可能是:
x86-64
↓
一种指令序列
AArch64
↓
另一种指令序列
这正是 JVM 实现需要完成的工作。
七、x86 Fence 与 LOCK 如何保证顺序?
从 Java 再向下一层,就进入真实 CPU 指令。
1. LFENCE / SFENCE / MFENCE
在 x86 中可以看到:
LFENCE
SFENCE
MFENCE
可以先建立如下直觉:
| 指令 | 主要关注 |
|---|---|
LFENCE | Load 相关顺序与执行约束 |
SFENCE | Store 相关顺序 |
MFENCE | 前后 Load / Store 的完整内存顺序约束 |
但不要把它们机械翻译成:
LoadLoad = lfence
StoreStore = sfence
StoreLoad = mfence
因为:
JVM 抽象屏障
≠
某一条固定 CPU 指令
JVM 会结合目标架构自身的内存模型选择实现方式。
2. LOCK 真的会锁住整个总线吗?
旧资料经常会说:
x86 的 LOCK 指令执行时会锁住内存总线。
这是历史上非常常见的解释,但对于现代 x86 来说过于绝对。
对于正常的、可缓存内存,现代处理器通常可以依靠:
Cache Coherence
+
Cache Line 所有权
完成带 LOCK 的原子操作,而不需要让整个外部内存总线停止工作。
只有某些特殊场景,例如:
- Split Lock;
- 不可缓存内存;
- 特殊内存类型;
才可能走更昂贵的总线级路径。
这一点和前面讲 MESI 时是一致的:
现代多核处理器会尽量把同步控制限制在必要的 Cache Line 和一致性域中,而不是动不动就封锁整个内存系统。
3. volatile 固定使用 lock addl 吗?
也不能这么绝对地记。
OpenJDK HotSpot 的 OrderAccess 当前源码中,在 x86 的某些 Fence 路径上确实使用了 locked add 一类操作来实现强屏障。
但:
volatile
↓
一定固定生成
lock addl $0, (%rsp)
不是 Java 语言规范。
具体代码生成取决于:
- JDK 版本;
- HotSpot 实现;
- JIT 编译器;
- CPU 架构;
- 当前操作类型;
- 上下文中已有的顺序保证。
所以面试里真正应该回答的是:
volatile 的语义由 JMM 规定,HotSpot 再依据目标 CPU 的内存模型,用编译器屏障、Fence 或原子指令等手段实现。
而不是背死某一条汇编模板。
八、happens-before 到底解决什么?
如果说 Memory Barrier 更接近“实现手段”,那么 happens-before 更接近:
Java 程序员应该用什么规则判断线程之间的可见性和顺序。
1. happens-before 不是物理时间顺序
假设:
A happens-before B
它并不是在说:
CPU 内部必须真的先把 A 的所有微操作执行完,再开始碰 B。
JLS 甚至明确允许实现重排序,只要最终产生的结果仍然符合合法执行。
所以 happens-before 更重要的含义是:
A 对 B 可见
+
A 在 Java 内存模型规定的顺序上先于 B
2. happens-before 从哪里建立?
很多教程会整理成:
happens-before 八大规则。
这种记忆方式没有错,但容易让人误以为 JLS 就有一个标题叫“八大规则”。
更接近规范的理解是:
Program Order
同一个线程内,动作按照该线程的程序顺序建立 happens-before。
Synchronizes-With
一些同步动作会建立跨线程边,例如:
unlock
↓
后续对同一 monitor 的 lock
volatile write
↓
后续对同一字段的 volatile read
Thread.start()
↓
新线程中的动作
以及线程终止与其他线程检测到其终止之间的关系。
Transitivity
如果:
A hb B
B hb C
那么:
A hb C
这就是为什么 volatile 可以把前面的普通写“传递”给另一个线程。
3. 为什么不能只说“刷新主内存”?
很多 Java 并发教程喜欢说:
volatile 写会立即刷新主内存,volatile 读会立即从主内存读取。
这种说法适合作为极粗糙比喻,但现代 CPU 并不是:
Core 0
↓
每次 volatile 都写 DRAM
↓
Core 1 再去 DRAM 取
中间还有:
- Store Buffer;
- Cache;
- Coherence;
- 共享 LLC;
- 内存控制器。
因此真正跨平台、不会误导的描述应该是:
volatile 建立 Java Memory Model 要求的可见性和顺序关系;JVM 再利用目标硬件的内存顺序机制实现这些语义。
九、as-if-serial:乱序后为什么单线程不乱?
下面两句:
int a = 1;
int b = 2;
如果后面的程序没有依赖它们的先后顺序,编译器和 CPU 有很大优化空间。
但:
int a = 1;
int b = a + 1;
第二句依赖第一句的结果。
无论内部怎样优化,当前线程自己观察到的行为都必须符合 Java 的单线程语义。
这就是理解 as-if-serial 的核心:
实现可以改变内部执行方式,但不能让单线程程序观察到一个违反自身语言语义的结果。
1. 它不是把指令重新排回源码顺序
不要理解成:
先乱序执行
↓
最后又把所有指令重新排回原来的物理顺序
CPU 不需要这么做。
只需要保证:
对当前线程而言,结果和一个合法的顺序执行一致。
多线程为什么更难?
因为另一个线程可能成为新的“观察者”。
原来单线程不可观察的内部顺序变化,在缺少同步时,可能通过共享内存表现出来。
于是我们才需要:
volatile
synchronized
Lock
Atomic
VarHandle
这些同步工具。
十、CPU 如何优化写入?
前面多次提到 CPU 不愿意等待。
读数据如此,写数据也是如此。
1. Store Buffer:先把写操作缓冲起来
如果 CPU 每执行一个 Store,都必须等待整个 Cache Coherence 流程完成以后才能继续,执行流水线会经常停住。
于是现代处理器通常存在 Store Buffer 一类结构,让 Store 可以先进入缓冲,再在满足内存顺序规则的前提下完成后续传播。
可以先建立:
执行单元
↓
Store Buffer
↓
Cache / Coherence
↓
更下层内存体系
这也是为什么:
“当前 Core 已经执行了 Store”与“其他 Core 已经观察到了这个 Store”不是完全相同的概念。
2. Write Combining:合并多次写入
Write Combining 是另一种写入优化思想:
多次相邻写
↓
在合适的缓冲结构中合并
↓
更高效地向下层传输
它在图形缓冲、设备内存、非时间性写入等场景尤其有价值。
但这里不要记一个错误模型:
CPU 固定有一个 4 字节 WC Buffer,ALU 写满 4 字节后就刷入 L2。
真实的 Write Combining Buffer:
- 数量是微架构相关的;
- 大小和组织方式不是 Java 可以依赖的固定常量;
- 和普通 Write-Back Cache 下的 Store Buffer 也不是同一个概念;
- 数据流也不能简单画成“ALU → 4B WC → L1 → L2”。
所以这一部分了解目的即可:
现代 CPU 不仅会优化“什么时候读”,还会通过缓冲、合并等方式优化“什么时候把写入继续向下传播”。
至于具体某款处理器有几个 Buffer、怎么合并,不值得普通 Java 代码依赖。
十一、NUMA:为什么内存也有远近?
在普通桌面电脑上,我们很容易把内存想成:
所有 CPU Core
↓
同一块内存
访问任何内存地址的成本都差不多。
在大型多 Socket 服务器上,这个模型就不够用了。
1. UMA:统一内存访问
最简单的抽象:
所有处理器看到一个相对统一的主内存访问模型。
2. NUMA:本地内存与远程内存
NUMA 是:
Non-Uniform Memory Access
非统一内存访问
在典型多 Socket 机器上,可以想象成:
CPU 0 访问:
Local Memory 0
通常比访问:
Remote Memory 1
延迟更低、带宽条件也更好。
于是出现一个很现实的问题:
如果线程一直在 CPU 0 上执行,但它最频繁访问的数据全被放到了 Node 1,会发生什么?
程序当然还能运行,但会不断付出跨 NUMA Node 的访问成本。
3. Linux 如何查看 NUMA?
在 Linux 上可以执行:
lscpu | grep -E 'NUMA node'
本文测试环境得到:
NUMA node(s): 1
NUMA node0 CPU(s): 0-4
说明这个环境只暴露一个 NUMA Node,因此没法直接观察 Local / Remote 内存差异。
在真正的多 Socket NUMA 服务器上,则可能看到:
NUMA node(s): 2
NUMA node0 CPU(s): ...
NUMA node1 CPU(s): ...
如果安装了 numactl,还可以:
numactl --hardware
查看各 NUMA Node 的 CPU、内存容量以及 Node Distance。
4. NUMA 为什么会影响 Java?
如果 JVM 的 Heap 很大,又横跨多个 NUMA Node,那么对象分配位置就可能影响后续访问成本。
这也是垃圾收集器会考虑 NUMA 的原因。
例如 G1 从 JDK 14 的 JEP 345 开始提供 NUMA-Aware Allocation:
-XX:+UseNUMA
在支持的 Linux NUMA 环境里,G1 会尝试让年轻代对象分配更贴近当前线程所在的 NUMA Node。
ZGC 也具备 NUMA Awareness,OpenJDK 在 ZGC 成为生产特性的 JEP 377 中还专门列出了对 NUMA Awareness 的改进。
但不要进一步推导成:
Java 对象永远会被放到当前 CPU 最近的内存。
实际行为还受到:
- GC 类型;
- JDK 版本;
UseNUMA配置;- 操作系统;
- CPU Affinity;
- 线程迁移;
- Heap 布局;
等因素影响。
所以 NUMA 更适合记成:
在多 Socket 服务器上,“内存在哪里”也可能成为 JVM 性能问题。
十二、常见误区
1. 关于乱序与实验
误区 1:出现 (0,0) 就证明 CPU 把两条机器指令交换了。
不准确。
它说明这段存在 Data Race 的程序不能按照顺序一致性模型推理。最终行为可能同时涉及 JIT 优化、硬件 Memory Ordering、Store Buffer 和 CPU 微架构。
误区 2:CPU 可以把任何两条指令随便交换。
错误。
数据依赖、控制依赖、ISA 内存顺序、同步指令以及最终可观察语义都会限制优化空间。
2. 关于 DCL 与 volatile
误区 3:DCL 的问题就是 JVM 把 invokespecial 和 putstatic 两条字节码直接互换。
不准确。
Class 文件中的字节码顺序仍然可以是:
new → dup → invokespecial → putstatic
真正要解决的是没有同步时的安全发布、可见性和内存顺序问题。
误区 4:volatile 能让 count++ 变成原子操作。
错误。
volatile 不会把“读 → 改 → 写”这种复合操作自动合成原子事务。
3. 关于内存屏障
误区 5:JLS 定义了 LoadLoad、StoreStore、LoadStore、StoreLoad 四条 JVM 指令。
错误。
JLS 定义的是 Java 内存模型语义。这四种 Barrier 是 JSR-133 Cookbook / JVM 实现中常用的跨平台内存顺序抽象。
误区 6:每个 volatile 写都固定生成 lock addl $0, (%rsp)。
错误。
这是特定 HotSpot、特定架构、特定实现路径中可能出现的实现方式,不是 Java 语言规范。
误区 7:x86 LOCK 一定会锁住整个内存总线。
错误。
现代处理器对正常可缓存内存通常优先依靠 Cache Coherence 实现原子访问;总线级锁定属于更特殊的情况。
4. 关于 happens-before 和 NUMA
误区 8:happens-before 就是物理时间上 A 必须彻底执行完才能执行 B。
错误。
它定义的是 Java 内存模型中的顺序与可见性关系。实现仍然可以优化,只要最终行为满足规范。
误区 9:JLS 明文规定了“happens-before 八大规则”。
不准确。
“八大规则”是常见教学整理。JLS 更正式地通过 Program Order、Synchronizes-With 和传递闭包定义 happens-before。
误区 10:NUMA 是缓存一致性的一部分。
错误。
NUMA 解决的是多处理器系统中的内存拓扑与访问局部性问题;Cache Coherence 解决的是多个缓存副本的一致性。两者会共同影响性能,但不是同一个概念。
十三、把整条执行链串起来
现在重新回答最开始的问题:
CPU 为什么可以乱序,而 Java 多线程程序又不能随便乱?
完整链路是:
也就是说:
乱序执行
解决的是:
CPU 怎么更快。
而:
JMM / volatile / happens-before
解决的是:
在允许底层继续优化的同时,Java 程序员怎样得到可靠的跨线程语义。
这两个目标不是敌人。
Java Memory Model 并不是要求 CPU:
从此全部老老实实顺序执行
而是在性能优化和并发正确性之间画出边界:
边界以内
可以优化
跨过同步语义的边界
必须保证程序看到正确结果
十四、总结
理解 CPU 乱序执行之后,最容易犯的错误,是把所有“顺序变化”都归咎于 CPU。
实际上,从 Java 源代码到硬件之间至少存在:
Java 编译器
↓
JVM / JIT
↓
ISA Memory Model
↓
CPU 微架构
↓
Cache / Buffer / Coherence
多个层级。
因此讨论并发时,最可靠的方法不是猜:
CPU 到底把哪两条指令交换了?
而是先问:
这两个线程之间有没有建立 Java Memory Model 认可的同步关系?
有了:
volatile
monitor
Lock
Atomic
Thread.start()
Thread.join()
等同步机制,JMM 才能为跨线程行为建立 happens-before。
至于这些语义最后怎样落到 x86、ARM 或其他 CPU 上,是 HotSpot 等 JVM 实现需要完成的工作。
现代 CPU 可以为了性能乱序执行,Java 编译器也可以进行合法优化;真正约束并发程序正确性的,不是“所有指令永远按源码顺序跑”,而是 Java Memory Model 定义的可见性、同步关系与 happens-before。
评论区