01 并发编程的硬件基础——CPU 缓存、MESI 与内存屏障
摘要:
很多 Java 工程师学了 synchronized、volatile、happens-before,却始终觉得”知其然不知其所以然”——volatile 写为什么要插入 StoreLoad 屏障?synchronized 的解锁为什么能保证其他线程看到最新值?这些问题的答案不在 JVM 规范里,而在更底层的硬件架构里。本文从多核 CPU 的物理缓存层次出发,深入剖析缓存行(Cache Line)的结构、MESI 协议的四种状态转换、Store Buffer 与 Invalidate Queue 引入的”异步化”问题,以及内存屏障(Memory Barrier)如何在硬件层面重新建立秩序。理解这些硬件基础,是理解 JMM、volatile、synchronized 乃至整个 Java 并发体系的真正起点。
第 1 章 为什么硬件层面会有并发问题
1.1 从单核到多核:性能提升的代价
在单核处理器时代,并发编程几乎不存在”可见性”问题——所有线程共用同一个 CPU,共用同一套寄存器和缓存,写入的数据对所有后续读取立刻可见。那个时代的并发问题主要是”原子性”:操作系统可以在任意时刻打断线程切换到另一个,因此需要临界区保护。
多核处理器的普及彻底改变了这个图景。一台现代服务器通常有 2 个物理 CPU Socket,每个 Socket 有 16-64 个核心,每个核心有 2 个超线程。这些执行单元共享同一块内存(DRAM),但每个核心有自己私有的 L1/L2 缓存。
问题就在这里。内存的速度远远跟不上 CPU 的速度。一次 L1 缓存命中需要约 4 个 CPU 周期,L2 约 12 个,L3 约 40 个,而一次主内存访问需要约 200 个周期。如果 CPU 每次读写都直接操作内存,现代 CPU 将会有超过 95% 的时间在等待内存,性能优势荡然无存。
缓存(Cache)的引入是解决这个矛盾的必然选择——让 CPU 尽可能操作距离自己最近、速度最快的存储层次。但多个核心各自有私有缓存,就带来了一个新问题:同一块数据在多个核心的私有缓存中可能存在不一致的副本。这就是并发编程硬件层面的核心困境,也是一切上层并发机制的起点。
1.2 缓存层次结构
各层次的关键参数对比:
| 存储层次 | 容量 | 延迟 | 私有/共享 |
|---|---|---|---|
| L1 缓存 | 32-64 KB | 4 周期 | 每核私有 |
| L2 缓存 | 256 KB - 1 MB | 12 周期 | 每核私有 |
| L3 缓存 | 8-64 MB | 40 周期 | 所有核共享 |
| 主内存 | GB - TB | ~200 周期 | 共享 |
缓存行(Cache Line) 是缓存管理的最小单位,通常为 64 字节。CPU 不以单个字节为粒度读写缓存,而是以缓存行为单位——读一个 int(4 字节),实际上会把这个 int 所在的整个 64 字节缓存行都加载进 L1。
这个设计基于空间局部性原理(Spatial Locality):程序访问了一个内存位置,它很可能很快也会访问附近的位置(如遍历数组)。以缓存行为粒度批量加载,可以减少后续访问的缓存未命中次数。
第 2 章 缓存一致性问题的根源
2.1 问题的具体形态
假设一个最简单的场景:
int x = 0;
// 线程 A(运行在 Core 0)
x = 1;
// 线程 B(运行在 Core 1)
int r = x; // r 的值是 0 还是 1?在物理层面:变量 x 初始值 0 被加载到 Core 0 和 Core 1 的 L1 缓存中;线程 A 在 Core 0 上执行 x = 1,Core 0 的 L1 缓存中 x 变为 1;线程 B 在 Core 1 上执行读取,Core 1 的 L1 缓存中仍然是旧值 0。
如果没有任何协调机制,线程 B 读到的就是旧值 0——这就是可见性(Visibility) 问题。
2.2 为什么不直接写穿到内存
一个直觉性的解决方案是”写直达”(Write-Through):每次写缓存时同步写主内存。但这行不通:
- 写直达会把 L1 缓存的速度优势完全抹杀。L1 缓存的优势是在 4 个周期内完成操作,如果每次写都要等 200 个周期写完主内存再继续,L1 缓存形同虚设。
- 频繁写内存会造成内存总线拥塞。多核系统中所有核心共用一条内存总线,频繁写会使总线成为严重瓶颈。
因此,实际采用的是”写回”(Write-Back)策略:先写缓存,等缓存行被驱逐(Evict)时再写内存。这带来了极高的缓存命中性能,但需要额外的缓存一致性协议来保证多核之间的数据一致。
第 3 章 MESI 协议——缓存一致性的解决方案
3.1 四种状态的精确语义
MESI 协议是当前最广泛使用的缓存一致性协议(Intel x86、ARM 都在此基础上实现)。它用四个状态来描述一个缓存行的当前状态:
| 状态 | 全称 | 含义 | 其他核是否有副本 |
|---|---|---|---|
| M | Modified | 已被修改,与内存不一致 | 无(本核独占) |
| E | Exclusive | 与内存一致,只有本核有副本 | 无 |
| S | Shared | 与内存一致,多核都有副本 | 有 |
| I | Invalid | 缓存行已失效,不可使用 | 不确定 |
M 状态:只有持有 M 状态的核心有最新值,如果其他核心需要读,必须从这个核心获取。
E 状态:如果本核想写,可以直接从 E 升到 M,无需通知其他核心(因为没有其他副本需要失效)。
S 状态:多个核心都持有副本,任何一个核心要写时,必须先让其他所有核心的副本失效,然后才能写。
I 状态:如果要访问这行数据,必须从其他核心的缓存或主内存重新加载。
3.2 状态转换:一次完整的读-写-读场景
通过具体场景理解状态转换。假设 Core 0 和 Core 1 都要操作变量 x,初始两个核心缓存中都没有 x(I 状态):
步骤 1:Core 0 读取 x:Core 0 发出 BusRd 请求,主内存响应,将 x=0 传给 Core 0。Core 0 状态变为 E(只有我有)。
步骤 2:Core 1 读取 x:Core 1 发出 BusRd 请求,Core 0 通过总线嗅探(Bus Snooping)监听到,将自己的状态降为 S。Core 1 获取数据,状态也为 S。
步骤 3:Core 0 写入 x = 1:Core 0 发出 BusRdX(读取以获得独占权)。Core 1 监听到,将副本置为 I(失效)。Core 0 的状态升为 M,写入 x=1。
步骤 4:Core 1 再次读取 x:Core 1 发现缓存行是 I 状态,缓存未命中。Core 0 监听到请求,将修改后的数据写回内存(或直接传给 Core 1),自己降为 S。Core 1 获取到最新数据 x=1,状态也为 S。
这就是 MESI 的本质:通过总线嗅探和状态机,让每个缓存行的修改都能被其他核心感知,从而保证一致性。
3.3 伪共享问题
MESI 以缓存行(64 字节)为粒度管理一致性,这带来了一个工程陷阱——伪共享(False Sharing)。
class Counter {
volatile long count0 = 0; // 线程 A 使用
volatile long count1 = 0; // 线程 B 使用
// 两个 long 各 8 字节,合计 16 字节,很可能在同一个 64 字节缓存行中
}count0 和 count1 逻辑上互不关联,但由于它们在同一缓存行中:Core 0 更新 count0 → 这个缓存行变为 M,Core 1 的副本被 Invalidate → Core 1 下次更新 count1 时缓存未命中,必须重新加载 → 又触发 Core 0 的 Invalidate…如此往复,两个核心因共处同一缓存行频繁”踢皮球”,性能急剧下降。
解决方案:缓存行填充(Cache Line Padding)
class PaddedCounter {
volatile long count0 = 0;
long p1, p2, p3, p4, p5, p6, p7; // 填充 56 字节,保证 count0 独占一个缓存行
volatile long count1 = 0;
long q1, q2, q3, q4, q5, q6, q7;
}JDK 8 引入的 @sun.misc.Contended 注解会自动在被注解字段两侧插入填充字节。LongAdder 的 Cell 数组正是使用了这个注解来防止伪共享——这也是它比 AtomicLong 在高并发场景下性能更好的原因之一。
核心概念
伪共享是一个”幽灵问题”——代码逻辑上完全正确,但性能比预期低一个数量级。在多线程高频写的场景中(如计数器、统计器),一定要检查关键字段是否存在伪共享风险。排查工具:Linux
perf、Intel VTune 的 Cache Miss 指标。
第 4 章 Store Buffer 与 Invalidate Queue——性能优化引入的新问题
4.1 MESI 的性能瓶颈
MESI 保证了缓存一致性,但其同步阻塞特性带来性能问题。以”Core 0 写入 x”为例:
- Core 0 发出
BusRdX请求,要求所有其他核心将x对应缓存行置为 Invalid - Core 0 必须等待所有其他核心回复”已 Invalidate”确认
- 等确认收齐后,Core 0 才能真正写入数据
在一个 64 核服务器上,步骤 2 可能需要等待 63 个核心的确认。为解决这个问题,硬件引入了两个缓冲区来”异步化”原本同步的操作:Store Buffer 和 Invalidate Queue。
4.2 Store Buffer——写操作的异步化
Store Buffer 是每个核心私有的小缓冲区,位于 CPU 核心与 L1 缓存之间。
引入后,写操作变为:Core 0 要写 x=1,先把 (address_of_x, value=1) 写入 Store Buffer,然后立即继续执行后续指令;Store Buffer 在后台异步处理 Invalidate 确认;确认收到后,才将写操作提交到 L1 缓存。
对 Core 0 来说,写延迟从”等所有核心 Invalidate”变成了”写入 Store Buffer”(几个周期),性能大幅提升。
但 Store Buffer 引入了写-读可见性延迟。Core 0 写入 Store Buffer 中的值,对 Core 1 来说是不可见的——Core 1 只能从缓存读,看不到 Core 0 的 Store Buffer 内容。
说明
对本核自身,硬件通过 Store Forwarding(写前递转)解决:CPU 在读取时先检查 Store Buffer,如果有对相同地址的未提交写入,直接从 Store Buffer 取值。这保证了单线程的程序顺序语义。但 Store Forwarding 只对本核有效。
4.3 Invalidate Queue——失效操作的异步化
Invalidate Queue 在”失效”端做异步化。Core 1 收到 Invalidate 请求时,将请求放入 Invalidate Queue,然后立即回复确认,而不是真正去失效缓存行。之后在某个合适时机,才真正处理 Invalidate Queue 中的请求。
这让 Core 0 能快速收到确认,提升写操作吞吐量。但代价是:Core 1 的缓存中可能存在一个虽然”应该无效”但尚未被处理的缓存行。
4.4 两个异步机制导致的可见性问题
经典场景(flag-data 模式):
初始状态:flag = 0, data = 0,两个变量都在 Core 0 和 Core 1 的 S 状态缓存中
Core 0: Core 1:
data = 42; while(flag == 0) {} // 等待 flag
flag = 1; print(data); // 期望打印 42
实际可能发生的序列:
- Core 0 执行
data = 42:写入 Store Buffer,发出BusRdX(data),不等待。 - Core 0 执行
flag = 1:如果flag的 Invalidate 确认比data更快完成,flag=1先提交到缓存,data=42仍在 Store Buffer 中。 - Core 1 看到
flag = 1,退出循环。 - Core 1 读取
data:data的 Invalidate 请求可能还在 Core 1 的 Invalidate Queue 中未处理,Core 1 缓存中data仍是旧值0。 - Core 1 打印出
0,而不是预期的42。
生产避坑
这个场景正是
flag变量在 Java 中必须声明为volatile的根本原因。如果没有volatile,JVM 不会插入必要的内存屏障,上述乱序会在生产环境中静默发生——表现为偶发的读到旧值,极难复现和排查。
第 5 章 内存屏障——重新建立秩序
5.1 内存屏障的本质
内存屏障(Memory Barrier) 是 CPU 提供的、用来”排空”异步缓冲区的指令,从而在特定点重新建立确定的操作顺序。内存屏障不是”锁”,它只是保证屏障前后的操作不会被重排序跨越屏障,以及强制刷新 Store Buffer 或处理 Invalidate Queue。
5.2 四种内存屏障的语义
| 屏障类型 | 防止的重排序 | 主要作用 | 性能代价 |
|---|---|---|---|
| LoadLoad | Load-Load | 处理 Invalidate Queue | 低 |
| StoreStore | Store-Store | 刷新 Store Buffer | 中 |
| LoadStore | Load-Store | 两者都做 | 中 |
| StoreLoad | Store-Load | 刷新 Store Buffer + 处理 Invalidate Queue | 最高 |
StoreLoad 屏障是代价最高的屏障——它既要排空 Store Buffer(让写对所有核心可见),又要确保后续 Load 不读到缓存中的旧值(处理 Invalidate Queue)。在 x86 上通过 MFENCE 或 LOCK XCHG 实现。
5.3 x86 的内存模型:TSO
x86/x64 使用 TSO(Total Store Order) 模型——这是一种相对严格的内存模型,除了 Store-Load 重排序外,其他三种重排序在 x86 上都不会发生。
这意味着在 x86 上,很多在 ARM 上需要显式插入的内存屏障实际上是”空操作”。但 JVM 不能依赖特定平台,必须按照 JMM 的语义生成代码——在 ARM 等弱序架构上,所有必要的屏障都要实际插入。
设计哲学
这就是为什么 Java 在 x86 上运行时,
volatile的性能代价相对较小(只有 StoreLoad 屏障有实际成本),而在 ARM 上代价更大。这是 JVM 跨平台抽象的必要代价——写一份代码,在所有平台上都正确。
5.4 内存屏障在 Java 中的落地
Java 工程师不直接调用 CPU 内存屏障指令,而是通过 JMM 定义的语义间接使用。JVM 负责在不同 CPU 架构上插入正确的屏障指令:
volatile写:写操作前插入StoreStore,写操作后插入StoreLoadvolatile读:读操作后插入LoadLoad和LoadStoresynchronized进入:相当于LoadLoad+LoadStoresynchronized退出:相当于StoreStore+StoreLoad
这些对应关系将在 03 volatile 的实现原理——内存屏障与禁止重排序 和 04 synchronized 的锁升级——偏向锁、轻量级锁与重量级锁 中详细展开。
第 6 章 总结
本文建立了一条从硬件物理现实到 Java 并发抽象的完整推导链:
| 层次 | 问题 | 解决方案 |
|---|---|---|
| 物理层 | 多核私有缓存 → 数据不一致 | MESI 协议(总线嗅探 + 状态机) |
| 性能优化层 | MESI 同步阻塞 → 性能差 | Store Buffer + Invalidate Queue(异步化) |
| 可见性层 | 异步化 → 写-读顺序被打破 | 内存屏障(排空缓冲区) |
| Java 抽象层 | 屏障指令依赖 CPU 架构 | JMM:volatile、synchronized、happens-before |
理解了这条链路,02 Java 内存模型(JMM)——happens-before 与可见性保证 就不再是一堆抽象规则的死记硬背——JMM 的每一条 happens-before 规则,背后都对应着具体的内存屏障插入策略,而每一种屏障的存在,都是为了解决 Store Buffer 和 Invalidate Queue 引入的特定可见性问题。
参考文献
- Patterson & Hennessy, “Computer Organization and Design: The Hardware/Software Interface”, 5th Edition
- Intel, “Intel 64 and IA-32 Architectures Software Developer’s Manual”, Volume 3A
- Herlihy & Shavit, “The Art of Multiprocessor Programming”
- Preshing, Paul, “Memory Barriers Are Like Source Control Operations”, preshing.com, 2012
- JSR-133 Java Memory Model and Thread Specification, 2004
- Doug Lea, “The JSR-133 Cookbook for Compiler Writers”, gee.cs.oswego.edu
思考题
- MESI 协议保证了多核 CPU 缓存的一致性,但它本身并不能保证程序的可见性——因为 Store Buffer 和 Invalidate Queue 的存在延迟了缓存行状态的传播。在 x86 架构上,Store Buffer 导致的唯一可观察的重排序是’Store-Load 重排序’。这意味着在 x86 上,除了 Store-Load 之外的其他内存序是天然保证的——那为什么 Java 仍然需要
volatile关键字?- CPU 的 false sharing(伪共享)发生在两个变量恰好落在同一个缓存行(通常 64 字节)中——一个核写变量 A 会导致另一个核缓存中变量 B 的缓存行失效。Java 8 引入了
@Contended注解(如LongAdder中使用)来避免伪共享。@Contended的实现原理是什么?在什么场景下伪共享会导致明显的性能下降?- 内存屏障(Memory Barrier)分为 LoadLoad、StoreStore、LoadStore 和 StoreLoad 四种。StoreLoad 屏障的开销最大(通常需要刷新 Store Buffer)。在 Java 中,
volatile写操作后会插入 StoreLoad 屏障。如果一个热点循环中有volatile写操作,StoreLoad 屏障的开销是否会成为性能瓶颈?你如何量化这个开销?