**引言:**本篇文章涉及领域较广,涵盖并发编程,计算机体系结构,JVM。文章较长但干货满满,相信各位读者读完能有所收获。
1. 存储层级访问速度差异
先对CPU的存储层级访问速度差异有个基础概念:
| 存储层级 | 访问耗时 (ns) |
|---|---|
| 寄存器 | < 0.5 |
| L1 缓存 | ~1 |
| L2 缓存 | ~4 |
| L3 缓存 | ~20 |
| 主存 (RAM) | ~100 |
导出到 Google 表格
在古早时代, CPU缓存和主存的数据一致性采用“直写”策略——“CPU在缓存中的修改需要立即同步到主存中”。这种策略每次写操作都要访问主存, CPU的运行速度会大幅降低。
所以现代的CPU一般采用“回写”策略, CPU在修改数据时只需要保证各个核心之间的数据一致性, 不需要保证主存和缓存之间的一致性, 只有缓存被踢出或被强制动作触发时才会同步到主存,这种策略通过减少主存的访问来提升运行速度, 同时也需要更复杂的一致性协议来保障。
即MESI协议。
2. MESI协议详解
让我们来说一说MESI协议,已经了解过的读者可以跳过。缓存行有四种状态:
- M - Modified (已修改) :该缓存行数据只存在于该CPU核心中,且与主存中不一致
- E - Exclusive (独占) :该缓存行数据只存在于该CPU核心中, 且与主存一致
- S - Share (共享) :该缓存行数据存在于多个CPU核心中, 且与主存一致
- I - Invalid (无效) :该缓存行数据已经作废, 只能去别的核心或者主存读
举个例子了解一下 MESI 运作流程,假设内存中 count = 0, 核心 A,B分别读取该数据:
-
读入 (I → E) : 核心 A 读取 count。由于只有 A 有,状态标记为 Exclusive 。
-
共享 (E → S) : 核心 B 也读取 count。通过总线嗅探机制,核心 A 监听到请求,两者状态均变为 Shared 。
-
写前置 (S → M) : 核心 A 要修改 count,做加1操作。它必须先发出 RFO (请求所有权) 信号:
- 核心 A:状态变为 Modified ,核心A地缓存值变为 1 (注意此时主存还是 0 !)。
- 核心 B:监听到信号,强制将本地缓存设为 Invalid (无效)。
-
同步 (M → S & I → S) : 核心 B 尝试再次读取。通过总线嗅探机制,核心 A 将 1 写回内存并同步给 B。两者回归 Shared。(此时缓存与主存数据同步了)
纯正的MESI语义不会出现可见性问题!
真正值得我们注意的是, 在纯正的MESI语义下,当核心 A 发送 RFO 信号时,它必须同步等待核心 B 的 Acknowledge (确认反馈) 才能继续执行写操作。与此同时,核心 B 触发总线嗅探,监听到信号后,必须强行中断当前的执行流水线来处理失效逻辑。这种同步等待机制肯定能保证可见性,但是会导致双端 CPU 的指令流水线出现短暂挂起,带来性能损耗。
3. Store Buffer 与 Invalidate Queue
所以现代的CPU使用的不是纯正的MESI协议, 而是引入了两个关键组件:
- Store Buffer (存储缓冲区) :核心 A 执行写操作时,不再同步等待其他核心的失效确认(ACK),而是将新值直接丢入 Store Buffer 并立即执行后续指令。待收到所有 ACK 后,硬件才异步将 Store Buffer 中的数据刷入 Cache。 (注!当前核心可以, 但是别的核心读不到当前核心Store Buffer的值,也就是说当前核心的修改对外界不可见,会出现不可见问题)
- Invalidate Queue(失效队列) : 核心 B 收到失效广播后,为了不立即打断当前流水线,先回传确认信号(ACK),并将失效请求存入 Invalidate Queue。核心 B 会在周期空隙,再异步地将对应缓存行置为 I (Invalid)。 (注!也就是说在这个异步的时间窗口内, 其他核心修改的最新值对当前核心不可见,会出现脏读现象)
**这种同步确认转异步确认的方式, 就是“可见性”的根源。**大家也可以把 “可见性” 理解为硬件工程师为了彻底压榨CPU性能给我们软件工程师挖的一种坑, 不过同时也给了我们填坑的工具——那就是大名鼎鼎的 “内存屏障”。
4. CPU主要有三种内存屏障
-
写屏障(Store Barrier)
- 物理语义:强制排空 Store Buffer。
- 运行逻辑:要求 CPU 必须将缓冲区内所有待写入的数据刷入 Cache,并同步等待总线上所有核心返回的失效确认(ACK)。在这一过程完成前,屏障后的任何写指令均不得执行。
- 解决问题:保障了写操作的即时全局可见性。
-
读屏障 (Load Barrier)
- 物理语义:强制清空 Invalidate Queue。
- 运行逻辑:要求 CPU 在执行后续读操作前,必须先处理完失效队列中累积的所有失效请求。
- 解决问题:确保当前核心能读到外界修改的最新数据
-
全屏障 (Full Barrier)
- 物理语义:Store Barrier + Load Barrier。
- 运行逻辑:同时具备读写屏障能力,是一种最高规格的“同步栅栏”。它不仅强制刷新本地写缓冲区,还强制同步远程失效状态。
我们软件工程师在进行读写操作时可以加上对应的内存屏障来抑制CPU的优化操作, 相当于回到原始的MESI语义, 通过这样来保障线程安全。
5. Java内存模型 (JMM)
由于不同硬件架构对缓存一致性与内存屏障的实现并不相同(如x86),适配成本极高。为了屏蔽不同硬件之间的差异,Java 在硬件之上构建了一层逻辑抽象—— Java Memory Model(java内存模型JMM)
JMM 的本质就是一套内存一致性规范。它将复杂的硬件屏障指令抽象为统一的语义规范(如 Happens-before),使得开发者只需遵循 JMM 的规则编写并发代码,无需关心底层是 x86 的强模型还是 ARM 的弱模型。最终,由 JVM 负责将这些逻辑语义“翻译”为对应硬件平台的物理指令,实现"Write Once, Run Anywhere"。
JMM把运行时存储区抽象划分为工作内存与主存。工作内存是线程私有的, 在线程首次访问主存时为其划分工作内存。 线程在工作内存中的修改不一定要立刻同步到主存(会有数据不一致问题), 线程对数据的读取可以只在工作内存中完成, 不一定非得到主存(会有脏读问题)。
同时JMM还抽象出了四种内存屏障:
- LoadLoad 屏障:确保屏障后的 load 操作不会直接复用工作内存中可能已经过期的旧值,而是强制 从主内存中获取最新的变量副本。
- StoreStore 屏障:语义:(该屏障主要作用于有序性, 这里仅做了解即可)它确保在执行后续的 write 之前,前面所有已经在工作内存中修改过的数据,必须已经成功执行了 store 和 write 操作,进入了主内存。
- LoadStore 屏障:语义:(该屏障主要作用于有序性, 这里仅做了解即可)保证“读”这个动作的上下文是完整的,不会被后续写操作刷回主内存的过程所干扰。
- StoreLoad 屏障:可见性语义:把当前工作内存中所有修改过的变量全部立刻刷回主内存。
6. 实例分析与汇编验证
举个例子来融会贯通, 打通软件与硬件之间的模糊边界:
Java
public class Main{
static volatile int num = 0;
public static void main(String[] args) {
for(int i = 0; i < 1e5; i++){
num++;
}
System.out.println(num);
}
}
对于volatile修饰的变量,JMM规范要求在该变量的读取后加入 LoadLoad 与 LoadStore 屏障, 在该变量的写操作前加入 StoreStore , 在写操作后加入 StoreLoad。
由于笔者本机为x86架构(强内存模型),无显式的Invalidate Queue,核心在收到RFO后会立即处理(立即感知外界修改,不会出现脏读), 且该模型天然保障读读, 读写, 写写操作有序(有序性层面)。 因为硬件的天然保障, 所以LoadLoad, LoadStore, StoreStore 屏障在x86架构下会被JVM编译为空指令。但x86仍保留了StoreBuffer,“不可见问题” 依旧存在, 且允许写读操作重排序, 所以StoreLoad屏障在编译后不是空指令。
编译上述代码,然后让我们用hsdis来看看StoreLoad屏障最后长什么样子, 其核心汇编代码为:
- 第一步含义:将寄存器 %r8d 中的数值 自增 1。
- 第二步含义:将 %r8d(新值)移动到 0x70(%r10)(内存地址)。
- 第三步含义: “addl $0x0,-0x40(%rsp)” 本身含义为对当前栈顶的指针加0, 这是一个无意义操作, 但关键点在于其 “lock”前缀。 这就是JMM的StoreLoad屏障在x86架构下汇编成的最终样子 该lock前缀会触发 “全屏障” 效果, 强制刷新StoreBuffer并清空Invalidate Queue(当然,在x86架构下没有后者操作)。自此,这个被volatile修饰的num, 它的修改对别的核心可见了。
##注:“lock”前缀的功能不止如此,它还会锁定该数据缓存行并发出对应数据的RFO(请求所有权)信号, 让别的核心数据失效并独占改缓存行。这涉及到原子性的实现, 这里不过多展开。对原子性感兴趣的读者可以去读一读笔者的另一篇文章—— 《不中断就能保证原子性?大错特错!》
笔者寄语
写这篇博文不是为了钻牛角尖,而是为了看清真相。很多情况下我们确实可以只专注于业务代码而忽略其底层实现。但需要强调的是。并发领域绝非孤岛, 它与操作系统, JVM, 计算机体系结构本就同根同源。如果不这样深入挖掘, 我们就很难真正触摸到并发领域的本质。
文章的最后,感谢每一位看到这里的读者。
如果这篇文章对您有所启发,笔者深感荣幸。烦请各位读者给个三连, 笔者将不胜感激。