【JUC】内存屏障

内存屏障(Memory Barrier)详解

内存屏障是理解 volatile、synchronized、JMM 的底层关键。下面从为什么需要 、四种屏障 、volatile 如何插入屏障 、底层 CPU 如何实现四个层面展开。


一、为什么需要内存屏障

内存屏障要同时解决两类"乱序"问题:

1. 编译器/CPU 的指令重排序

编译器和 CPU 为了提升性能,在不改变单线程语义的前提下会重排指令。这在单线程没问题,但多线程下会破坏程序的预期。

2. 硬件层面的存储缓冲(Store Buffer)

现代 CPU 写数据不是直接写主存,而是先写入 Store Buffer,再异步刷回缓存/主存。这导致:

  • 写操作对其他 CPU 不是立即可见
  • 后续读操作可能越过尚未提交的写操作

内存屏障就是告诉 CPU/编译器:"屏障前后的内存操作,不许跨越这条线重排,且必须保证刷写/失效完成。"


二、四种基本内存屏障

JMM 定义了四种屏障,按"读写方向"组合:

屏障类型 指令示例 作用
LoadLoad Load1; LoadLoad; Load2 保证 Load1 的读先于 Load2 的读完成
StoreStore Store1; StoreStore; Store2 保证 Store1 的写先于 Store2 的写刷新到主存
LoadStore Load1; LoadStore; Store2 保证 Load1 的读先于 Store2 的写完成
StoreLoad Store1; StoreLoad; Load2 保证 Store1 的写先于 Load2 的读,且写对后续读可见

StoreLoad 是全能屏障,开销最大。它同时具备其他三种屏障的效果,因为要"清空 Store Buffer 并等待写可见",代价最高。

记忆方法:屏障名字 = "前面操作类型 + 后面操作类型",表示禁止这两类操作交换顺序。


三、JMM 的保守策略:volatile 如何插入屏障

JMM 对 volatile 采用一套保守但正确的屏障插入策略(JSR-133 规范):

1. volatile 写操作

复制代码
普通写...
   ↓
【StoreStore 屏障】   ← 保证之前的普通写都刷新到主存
   ↓
volatile 写
   ↓
【StoreLoad 屏障】    ← 保证 volatile 写先于后续任何读写(关键!)
   ↓
普通读/写...

2. volatile 读操作

复制代码
普通读...
   ↓
volatile 读
   ↓
【LoadLoad 屏障】     ← 保证 volatile 读先于后续普通读
   ↓
【LoadStore 屏障】    ← 保证 volatile 读先于后续普通写
   ↓
普通读/写...

用表格总结

操作 前插屏障 后插屏障
volatile 读 --- LoadLoad、LoadStore
volatile 写 StoreStore StoreLoad

注意 :这些屏障是保守策略,实际执行时编译器会根据上下文优化掉不必要的屏障(例如连续的 volatile 写之间可以合并屏障)。


四、用屏障解释两个经典场景

场景 1:DCL 单例为何需要 volatile

java 复制代码
instance = new Singleton();
// 实际三步:
// 1. memory = allocate()      分配内存
// 2. ctorInstance(memory)     初始化对象
// 3. instance = memory        赋值引用

若步骤 2、3 重排(1→3→2),另一个线程在第一次 if (instance == null) 检查时可能读到非 null 但未初始化的引用。

volatile 的作用 :写 volatile 前插入 StoreStore,保证"初始化对象"(普通写)先于"赋值引用"(volatile 写)完成,从而禁止 2、3 重排。

场景 2:状态标志的可见性

java 复制代码
// 线程 A
data = 42;          // 普通写
flag = true;        // volatile 写

// 线程 B
if (flag) {         // volatile 读
    print(data);    // 普通读
}

线程 A 侧 :volatile 写前有 StoreStore 屏障 → 保证 data = 42 先刷到主存,再写 flag。

线程 B 侧:volatile 读后有 LoadLoad 屏障 → 保证读到 flag=true 后,再去读 data 能读到 42。

这就是 volatile 实现 "安全发布" 的底层机制。


五、底层 CPU 如何实现(x86 为例)

JMM 的屏障是抽象规范,最终要落到具体 CPU 指令。不同架构实现不同:

x86 架构特点

  • 强内存模型:只有 StoreLoad 可能重排(因为 Store Buffer),LoadLoad/StoreStore/LoadStore 天然不乱序
  • 因此 x86 上:
    • LoadLoad、StoreStore、LoadStore 屏障 = 空操作(no-op),仅编译期约束
    • StoreLoad 屏障 = lock 前缀指令 或 mfence

lock 前缀指令做了什么

复制代码
lock addl $0, (%rsp)   // 常见的空操作加 lock 前缀

lock 前缀触发:

  1. 将当前 CPU 的 Store Buffer / 缓存行写回主存
  2. 基于 MESI 协议,使其他 CPU 中该地址的缓存行失效(Invalidate)
  3. 后续读操作必须重新从主存/缓存加载,从而看到最新值
  4. 同时充当全能内存屏障,禁止指令重排

不同架构对比

架构 内存模型 StoreLoad 实现 说明
x86 强(TSO) lock / mfence 只有 StoreLoad 需真实指令
ARM 弱 dmb ish 四种屏障都需显式指令
PowerPC 弱 sync / lwsync 屏障更重

这也是为什么在 ARM 上运行 Java(如手机、Apple M 系列)时,volatile 的开销比 x86 更大------弱内存模型需要更多显式屏障指令。


六、屏障与缓存一致性协议的关系

内存屏障不能孤立理解,它和 MESI 缓存一致性协议 配合工作:

复制代码
CPU0                     CPU1
─────                    ─────
Store x=1                Load x
  ↓ Store Buffer           ↓
【StoreLoad 屏障】        缓存行失效
  ↓ lock 指令             ↓
广播失效 x 的缓存行 ────→ 重新从主存加载 x=1
  • MESI 负责缓存行状态同步(Modified/Exclusive/Shared/Invalid)
  • 内存屏障 负责时序约束:保证失效广播完成、Store Buffer 清空后,才允许后续操作
  • 没有屏障,Store Buffer 中的写可能延迟可见,导致其他 CPU 读到旧值

七、屏障在 JVM 中的其他应用

内存屏障不只服务 volatile,JMM 中多处使用:

场景 屏障策略
volatile 读后 LoadLoad+LoadStore,写前 StoreStore,写后 StoreLoad
synchronized monitorenter 相当于读屏障,monitorexit 相当于写屏障(保证临界区内存可见)
final 字段 写 final 字段后插入 StoreStore,保证构造完成前不被发布
CAS/原子类 底层 lock cmpxchg,自带全能屏障效果
Thread.start() 启动线程前插入屏障,保证 start 前的操作对子线程可见

八、一张图总览 volatile 写的内存语义

复制代码
        线程A                        主内存                    线程B
          │                            │                        │
    普通写 data=42                    │                        │
          │                            │                        │
    ┌─────┴─────┐                      │                        │
    │ StoreStore│  保证 data 先刷出     │                        │
    └─────┬─────┘                      │                        │
          │──────── data=42 刷到主存 ──→│                        │
   volatile 写 flag=true               │                        │
          │──────── flag=true 刷到主存 ─→│                        │
    ┌─────┴─────┐                      │                        │
    │ StoreLoad │  保证 flag 写先完成   │                        │
    └─────┬─────┘                      │                        │
          │                            │      volatile 读 flag  │
          │                            │←──── 从主存读取 ────────┤
          │                            │                        │
          │                            │    ┌──────────────┐    │
          │                            │    │LoadLoad+Store│    │
          │                            │    │Load 屏障     │    │
          │                            │    └──────┬───────┘    │
          │                            │           │            │
          │                            │←── 普通读 data ─────────┤
          │                            │   读到 42(可见)        │

总结口诀

写前 StoreStore,写后 StoreLoad;读后 LoadLoad + LoadStore。

StoreLoad 最贵,是全屏障;x86 靠 lock,ARM 靠 dmb。

屏障管时序,MESI 管一致,二者配合才有可见性。

相关推荐
Linux运维技术栈1 小时前
近期 PHP 高危安全风险排查与多环境升级实操
开发语言·安全·php
Fnetlink11 小时前
Fnet 云网安 261023
运维·开发语言·网络·人工智能·安全·web安全·php
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 13 篇):放量突破策略
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口
文丑颜不良啊1 小时前
LeetCode626~650题目解析
java·leetcode
ESDWAN2 小时前
SD-WAN 主备切换为什么仍会断网?从检测、切换到会话恢复分析
运维·开发语言·网络·php
jimy12 小时前
lvalue、xvalue、prvalue(二):引用是哪一类?
开发语言·c++·1024程序员节
cxyxixiy2 小时前
数智码力:数据核对不用人工比对,Python 快速完成多表格差异校验
开发语言·python·学习
一个风轻云淡2 小时前
从 int $0x80 到 iret:用 gdb 把 getpid 走一遍
linux·c语言·开发语言·汇编
杜 硕2 小时前
从零开始的C++生活——第三讲
开发语言·c++·生活