你真的看懂 volatile 了吗?从 CPU 缓存一致性协议说起

写了十几年 Java,面试也面了上千个后端候选人,只要我问到 volatile,几乎 95% 的人张口就是那三句话:

  • 可见性
  • 不保证原子性
  • 禁止指令重排序

背得滚瓜烂熟,然后我再追问一句:“那可见性到底是怎么实现的?”
现场直接安静,顶多支支吾吾来一句“JVM 做了什么底层的东西吧”。

好,今天咱们就把这事彻底讲透,从真正让 volatile 生效的那层铁——CPU 和内存开始讲。

一、别再拿“主内存和工作内存”骗自己了

Java 内存模型(JMM)里那张经典的图把多少人带偏了:

线程A工作内存 ←→ 主内存 ←→ 线程B工作内存

这张图是故意抽象的,实际硬件根本不是这么玩的。

真实世界是这样的(随便拆一台 64 核机器看看):

L1 Cache (per core, 32KB~128KB) → L2 Cache → L3 Cache (shared) → DDR5 → [远端NUMA节点]

每个核心都有自己私有的 L1/L2,L3 是每 8~16 核共享的,主内存?那其实是所有 CPU 看得到的最终一致性边界,但访问延迟是 L1 的 100~200 倍。

所以问题来了:你改了一个变量,别的核心怎么知道?

二、MESI 协议:真正让 volatile 可见的幕后英雄

现代 x86/ARM64 用的都是 MESI 协议的变种(MESIF、MOESI),核心就 4 个状态:

状态含义是否可读是否可写
M (Modified)本地缓存已修改,主内存旧了
E (Exclusive)本地独占,主内存一致
S (Shared)多个核心缓存一致,主内存也一致
I (Invalid)本缓存行无效,必须从别处重新加载

画个最经典的场景你就懂了:

初始:两个核心都把变量 x = 0 加载进自己的 L1,状态都是 S (Shared)

Core 0 执行:x = 1
→ Core 0 先把自己的缓存行变成 M (Modified)
→ 同时发出 Invalidate 广播
→ Core 1 收到后把自己的缓存行标记为 I (Invalid)

Core 1 下次读 x
→ Cache Miss → 走总线去 Core 0 那里偷数据(或者写回主内存后再读)
→ 拿到最新值 1

这就是 volatile 可见性的底层实现。

三、volatile 到底在字节码里插了什么?

我们写段最简单的代码:

public class VolatileDemo {
    volatile boolean flag = false;

    public void flip() {
        flag = true;   // 写
    }

    public void check() {
        if (flag) {    // 读
            System.out.println("flipped!");
        }
    }
}

javap -c 看字节码:

putfield      #2  <VolatileDemo/flag>    ; 普通写
getfield      #2  <VolatileDemo/flag>    ; 普通读

好像啥都没有?别急,真正的东西在 JIT 编译器里。

HotSpot 在 C2 编译器里会对 volatile 写插入:

movb $0x1, 0x8(%rbx)      ; 真正写值
lock addl $0x0, (%rsp)    ; ← 这条才是关键!Lock 前缀

这条 lock addl $0, (%rsp) 干了三件事:

  1. 立刻把本核所有 Store Buffer 里的数据刷到 L1(Store Buffer Forwarding 失效)
  2. 发出跨核的 Invalidate 信号,让其他核的缓存行失效
  3. 强制本核后面的读操作不能重排序到这句之前(Acquire 语义)

而 volatile 读后面会跟一条普通的 mov + 编译器内存屏障,确保前面的读不会被重排到读之后(Release 语义)。

这就是所谓的“写带 lock,读带 barrier”。

四、为什么 long/double 也需要 volatile?

64 位 JVM 在 32 位机器上会把 long/double 拆成两次 32 位写,如果不加 volatile,可能出现高 32 位是新值、低 32 位是旧值的“撕裂读”。

但现在几乎所有服务器都是 64 位 CPU,单条指令就能写 8 字节,所以这点其实已经不是问题了。

但规范仍然要求你加 volatile,因为 JMM 是平台无关的,它得照顾最差的情况。

五、一个很多人没见过的真香案例:单例双检的正确姿势

public class Singleton {
    private static volatile Singleton instance;  // 必须 volatile!

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();  // 这里有三步!
                }
            }
        }
        return instance;
    }
}

new Singleton() 实际上是三步:

  1. malloc 分配内存
  2. 调用构造方法
  3. 把对象引用赋给 instance

没有 volatile,JIT 可能把 2 和 3 重排序,导致别的线程看到一个“半初始化”的对象。

volatile 的内存屏障正好禁止了这种重排序。

六、性能到底有多大损失?

很多人怕 volatile 慢,其实在现代 CPU 上:

  • 普通变量写:Store Buffer → 几十纳秒后刷到 L1
  • volatile 写:强制立即刷 + 跨核失效,大概 20~80 纳秒(取决于核间距离)

比你想象的要快得多。

真正慢的是频繁的 cache line 乒乓(false sharing),而不是 volatile 本身。

七、写在最后

下次再有人跟你背那三句话,你可以直接问他:

“你知道 lock addl $0,(%rsp) 干了啥吗?”

能答上来再说 volatile。

我面试的时候,只要候选人能把 MESI + Store Buffer + Lock 前缀讲清楚,offer 当场就批了。

底层这点东西,学透了,你写并发代码永远不会再掉坑里。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值