MESI 协议与 Java 并发可见性——从硬件到 JMM
为什么 volatile 能保证可见性?为什么 synchronized 既保证原子性又保证可见性?答案藏在 CPU 缓存一致性协议和内存屏障中。本文将从硬件层面的 MESI 协议出发,逐步上升到 Java 内存模型(JMM),揭示并发可见性问题的完整因果链。
Part 1: CPU 缓存架构
为什么需要 CPU 缓存
现代 CPU 的运算速度远超内存访问速度,两者之间存在巨大的速度鸿沟:
| 操作 | 延迟 | 相对速度 |
|---|---|---|
| CPU 寄存器访问 | ~0.3 ns | 1x |
| L1 Cache 访问 | ~1 ns | 3x |
| L2 Cache 访问 | ~4 ns | 13x |
| L3 Cache 访问 | ~12 ns | 40x |
| 主内存访问 | ~100 ns | 333x |
如果 CPU 每次都直接访问主内存,大部分时间都在等待数据。缓存利用了时间局部性和空间局部性,将最近和附近的数据保存在更快的存储中。
多级缓存架构
1 | |
缓存行(Cache Line)
CPU 缓存的最小操作单位是缓存行(Cache Line),通常为 64 字节。
当 CPU 读取一个变量时,会将该变量所在的整个缓存行(64 字节)加载到缓存中。这意味着:
- 相邻的变量会被一起加载(空间局部性)
- 修改一个变量可能影响同一缓存行中的其他变量(伪共享问题)
MESI 协议的必要性
当多个 CPU 核心同时访问同一块内存时,如果没有缓存一致性协议,会出现数据不一致的问题:
1 | |
详细的状态转换场景
场景一:Core 0 独占读取
1 | |
场景二:Core 1 也读取 x
1 | |
场景三:Core 0 写入 x
1 | |
场景四:Core 1 读取被修改的 x
1 | |
总线嗅探(Bus Snooping)
MESI 协议通过总线嗅探实现:每个核心的缓存控制器都在监听总线上的所有事务。当检测到与自己缓存行相关的操作时,自动更新状态。
总线嗅探的局限性:
- 总线带宽有限,核心数增多时成为瓶颈
- 现代多核处理器(如 AMD EPYC)使用**目录协议(Directory Protocol)**替代总线嗅探
Part 3: Store Buffer 与 Invalidate Queue
为什么 MESI 还不够
MESI 协议保证了缓存一致性,但它有一个性能问题:写入操作需要等待所有其他核心的 Invalidate Acknowledge。
1 | |
Core 0 写入 x = 100:
- 将 x = 100 写入 Store Buffer(立即返回,不等待)
- 异步发送 Invalidate 到其他核心
- 收到所有 Acknowledge 后,将 Store Buffer 中的值写入缓存
1 | |
原因:Core 0 写入 a = 1 时,值还在 Store Buffer 中,尚未对 Core 1 可见。Core 1 同理。
Invalidate Queue(失效队列)
类似地,接收 Invalidate 请求的核心也不想立即处理(处理需要时间),于是引入了 Invalidate Queue:
1 | |
Invalidate Queue 带来的问题:Core 1 可能在处理 Invalidate 之前读取了缓存中的旧值。
重排序的根源
Store Buffer 和 Invalidate Queue 是 CPU 指令重排序的硬件根源:
| 重排序类型 | 原因 | 示例 |
|---|---|---|
| Store-Store 重排序 | Store Buffer 中的写入可能乱序刷出 | 先写 a 后写 b,但 b 先对其他核心可见 |
| Load-Load 重排序 | Invalidate Queue 中的失效可能延迟处理 | 读到了已经被其他核心修改的旧值 |
| Store-Load 重排序 | Store Buffer 中的写入对本核心的后续读取不可见 | 写入 a 后读取 b,但 a 的写入还在 Store Buffer 中 |
| Load-Store 重排序 | 较少见,某些架构可能发生 | — |
Part 4: 内存屏障(Memory Barrier)
什么是内存屏障
内存屏障(Memory Barrier / Memory Fence) 是 CPU 提供的指令,用于限制重排序:
| 屏障类型 | 作用 | 实现方式 |
|---|---|---|
| Store Barrier(写屏障) | 屏障前的写入必须在屏障后的写入之前完成 | 刷出 Store Buffer |
| Load Barrier(读屏障) | 屏障前的读取必须在屏障后的读取之前完成 | 处理 Invalidate Queue |
| Full Barrier(全屏障) | 同时具有读屏障和写屏障的效果 | 刷出 Store Buffer + 处理 Invalidate Queue |
x86 的内存模型
x86 架构使用 TSO(Total Store Order) 内存模型,这是一个相对强的内存模型:
| 重排序类型 | x86 是否允许 |
|---|---|
| Load-Load | ❌ 不允许 |
| Load-Store | ❌ 不允许 |
| Store-Store | ❌ 不允许 |
| Store-Load | ✅ 允许 |
x86 只允许 Store-Load 重排序(写入后的读取可能读到旧值)。这意味着在 x86 上,大多数情况下不需要显式的内存屏障,只有 Store-Load 场景需要 mfence 指令。
TSO 的实现机制:
- 所有写操作进入 Store Buffer
- 写操作按顺序从 Store Buffer 刷出到缓存
- 读操作会检查 Store Buffer,如果命中则返回 Store Buffer 中的值
- 这保证了 Store-Store 顺序,但无法保证 Store-Load 顺序
x86 内存屏障指令:
mfence:全屏障(禁止所有重排序)sfence:Store 屏障(禁止 Store-Store 和 Store-Load 重排序)lfence:Load 屏障(禁止 Load-Load 和 Load-Store 重排序)lock前缀:隐式全屏障(如lock addl $0, (%rsp))
ARM/RISC-V 的弱内存模型
ARM 和 RISC-V 使用弱内存模型,允许所有四种重排序:
| 重排序类型 | ARM 是否允许 | RISC-V 是否允许 |
|---|---|---|
| Load-Load | ✅ 允许 | ✅ 允许 |
| Load-Store | ✅ 允许 | ✅ 允许 |
| Store-Store | ✅ 允许 | ✅ 允许 |
| Store-Load | ✅ 允许 | ✅ 允许 |
因此在这些架构上,需要更多的内存屏障指令。
ARM 内存屏障指令(ARMv8):
DMB(Data Memory Barrier):数据内存屏障DSB(Data Synchronization Barrier):数据同步屏障ISB(Instruction Synchronization Barrier):指令同步屏障
RISC-V 内存屏障指令:
fence rw, rw:全屏障fence r, r:读屏障fence w, w:写屏障
跨平台差异的影响:
- 某些并发 Bug 在 x86 上不会出现,但在 ARM 上会出现
- JVM 需要为不同架构生成不同的屏障指令
- ARM/PowerPC 等弱内存模型架构需要更多的屏障指令,可能影响性能
Part 5: Java 内存模型(JMM)
JMM 的设计目标
Java 内存模型(Java Memory Model, JMM)定义在 JSR-133(Java 5)中,其目标是:
- 为程序员提供足够强的保证:正确同步的程序在所有平台上行为一致
- 为编译器和 CPU 提供足够的优化空间:不过度限制重排序
JMM 的抽象模型
1 | |
注意:JMM 是一个抽象模型,不是物理实现。"工作内存"对应的是 CPU 缓存、Store Buffer、寄存器等硬件结构的抽象。
Happens-Before 关系
Happens-Before 是 JMM 的核心概念:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。
JMM 定义了以下 Happens-Before 规则:
| 规则 | 说明 | 示例 |
|---|---|---|
| 程序顺序规则 | 同一线程中,前面的操作 happens-before 后面的操作 | a = 1; b = 2; 中 a=1 hb b=2 |
| Monitor 锁规则 | unlock 操作 happens-before 后续对同一锁的 lock 操作 | synchronized 块解锁后的操作对后续加锁可见 |
| volatile 规则 | volatile 写 happens-before 后续对同一 volatile 变量的读 | volatile flag = true; 后的读取保证看到 true |
| 线程启动规则 | Thread.start() happens-before 该线程中的任何操作 | 主线程调用 start() 前的操作对新线程可见 |
| 线程终止规则 | 线程中的任何操作 happens-before Thread.join() 返回 | join() 返回后,线程的所有修改对调用者可见 |
| 中断规则 | Thread.interrupt() happens-before 被中断线程检测到中断 | 调用 interrupt() 后,线程能检测到中断状态 |
| 终结器规则 | 构造函数完成 happens-before finalize() 开始 | 对象构造完成 before 垃圾回收调用 finalize() |
| 传递性 | 如果 A hb B,B hb C,则 A hb C | A=1 hb B=2 hb C=3 ⇒ A=1 hb C=3 |
Happens-Before 不等于时间上的先后
Happens-Before 是一种可见性保证,不是时间顺序。即使操作 A 在时间上先于操作 B 执行,如果没有 Happens-Before 关系,B 也不一定能看到 A 的结果。
Part 6: volatile 的实现原理
volatile 的语义
- 可见性:对 volatile 变量的写入,对后续读取该变量的线程立即可见
- 有序性:禁止 volatile 读写与前后操作的重排序
- 不保证原子性:
volatile int count; count++不是原子操作
volatile 的内存屏障
JVM 在 volatile 读写前后插入内存屏障:
volatile 写:
1 | |
volatile 读:
1 | |
volatile 在 x86 上的实现
由于 x86 的 TSO 内存模型已经禁止了大部分重排序,volatile 在 x86 上的实现非常轻量:
- volatile 读:普通的
mov指令(不需要额外屏障) - volatile 写:
mov+lock addl $0, (%rsp)(lock前缀指令充当 StoreLoad Barrier)
lock 前缀指令的效果:
- 锁定缓存行(或总线)
- 将 Store Buffer 中的所有写入刷出到缓存
- 使其他核心的对应缓存行失效
JIT 编译产物分析:
对于以下代码:
1 | |
在 x86 上,JIT 编译器(如 HotSpot C2)会生成类似以下的汇编代码:
1 | |
注意:volatile int counter; counter++ 不是原子操作,实际包含:
- volatile 读 counter
- 递增
- volatile 写 counter
这三个步骤之间可能被其他线程插入,导致竞态条件。如果需要原子递增,应使用 AtomicInteger.incrementAndGet()。
AtomicInteger 的实现:
1 | |
JIT 会生成:
1 | |
lock cmpxchg 指令是原子的,保证了递增操作的原子性。
volatile 的典型应用
双重检查锁定(DCL):
1 | |
为什么 instance 必须是 volatile?
instance = new Singleton() 实际上包含三步:
- 分配内存
- 调用构造函数初始化
- 将引用赋值给
instance
如果没有 volatile,步骤 2 和 3 可能被重排序:先赋值引用(此时对象尚未初始化),另一个线程看到 instance != null,直接使用了未初始化的对象。
volatile 禁止了这种重排序,保证构造函数完成后才赋值引用。
状态标志:
1 | |
Part 7: synchronized 的内存语义
synchronized 的三重保证
| 保证 | 说明 |
|---|---|
| 原子性 | 同一时刻只有一个线程执行临界区代码 |
| 可见性 | 解锁时将工作内存刷新到主内存,加锁时从主内存重新加载 |
| 有序性 | 临界区内的操作不会与临界区外的操作重排序 |
synchronized 的内存屏障
1 | |
synchronized 的锁升级
Java 6 引入了锁升级机制,根据竞争程度自动选择最优的锁实现:
1 | |
| 锁状态 | 适用场景 | 实现方式 | 性能 |
|---|---|---|---|
| 偏向锁 | 只有一个线程访问 | 在对象头记录线程 ID | 最快(无 CAS) |
| 轻量级锁 | 少量线程交替访问(无竞争) | CAS + 自旋 | 快(用户态) |
| 重量级锁 | 多线程竞争 | OS Mutex | 慢(内核态切换) |
对象头中的锁标志位:
1 | |
Part 8: 伪共享(False Sharing)
问题描述
当两个线程分别修改同一缓存行中的不同变量时,虽然它们操作的是不同的数据,但由于 MESI 协议以缓存行为单位工作,会导致缓存行在两个核心之间不断失效和重新加载——这就是伪共享(False Sharing)。
1 | |
解决方案:缓存行填充
Java 8 之前:手动填充
1 | |
Java 8+:@Contended 注解
1 | |
需要 JVM 参数 -XX:-RestrictContended 才能在非 JDK 内部类中使用。
实际案例
Disruptor(高性能队列)使用缓存行填充来避免伪共享:
1 | |
Java 的 Thread 类中的 @Contended 使用:
1 | |
Part 9: final 的内存语义
final 字段的特殊保证
JMM 对 final 字段提供了特殊的可见性保证:
在构造函数中对 final 字段的写入,与随后将该对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
这意味着:只要对象的引用不在构造函数中逸出,其他线程看到该对象时,final 字段一定已经被正确初始化。
1 | |
构造函数中的 this 逸出
如果在构造函数中将 this 引用泄露给其他线程,final 的保证将失效:
1 | |
其他线程可能通过 registerListener 获取到未完全构造的对象,此时 value 可能还是 0。
Part 10: 从硬件到 JMM 的完整映射
完整的因果链
1 | |
volatile vs synchronized vs final
| 特性 | volatile | synchronized | final |
|---|---|---|---|
| 可见性 | ✅ | ✅ | ✅(构造函数完成后) |
| 原子性 | ❌(仅单次读/写) | ✅ | N/A |
| 有序性 | ✅(禁止重排序) | ✅(临界区内外不重排) | ✅(构造函数内) |
| 性能 | 低开销 | 中-高开销 | 零开销 |
| 适用场景 | 状态标志、DCL | 临界区保护 | 不可变对象 |
实际编程建议
- 优先使用不可变对象:
final字段 + 不可变类,天然线程安全 - 其次使用 volatile:适合简单的状态标志和发布不可变对象
- 需要复合操作时使用 synchronized 或 Lock:保证原子性
- 考虑使用 java.util.concurrent:
AtomicInteger、ConcurrentHashMap等已经处理好了并发问题 - 注意伪共享:高性能场景下使用
@Contended或手动填充
总结
| 层次 | 概念 | 作用 |
|---|---|---|
| 硬件 | CPU 缓存 + MESI 协议 | 保证缓存一致性 |
| 硬件 | Store Buffer + Invalidate Queue | 提升性能,但引入可见性问题 |
| 硬件 | 内存屏障指令 | 限制重排序,恢复可见性 |
| JVM | volatile / synchronized / final | 将内存屏障封装为语言级语义 |
| JMM | Happens-Before 规则 | 为程序员提供可见性保证 |
核心认知:并发可见性问题的根源不是"线程切换",而是 CPU 缓存和写缓冲区导致的数据不一致。volatile 和 synchronized 通过插入内存屏障,强制将数据从 Store Buffer 刷出到缓存/主内存,并处理 Invalidate Queue 中的失效请求,从而保证可见性。





