volatile 可见性原理

候选人小孙在面试滴滴 P6 时,面试官写下这段代码:

public class NoVisibility {
    private static volatile boolean ready = false;
    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            number = 42;
            ready = true;
        }).start();

        new Thread(() -> {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }).start();
    }
}

面试官问:"这段代码一定会输出 42 吗?为什么?"

小孙说:"因为 volatile 保证了可见性,ready 的修改对第二个线程可见。"面试官追问:"那 number 的修改呢?volatile 不是只能保证 ready 的可见性吗?"

小孙开始支支吾吾...

一、核心问题:volatile 如何保证可见性 🔴

1.1 问题拆解

第一层:表面理解(什么是可见性?)
  "volatile 修饰的变量和普通变量有什么区别?"
  考察点:是否能说清主内存与工作内存、缓存一致性

第二层:底层实现(怎么做到的?)
  "volatile 底层用了什么 CPU 指令?"
  考察点:MESI 协议、Store Buffer、Invalidate Queue、内存屏障

第三层:Happens-Before(保证什么?)
  "volatile 的写对后续的读可见,那之前的读呢?"
  考察点:是否理解 Happens-Before 规则的传递性

第四层:局限性(不是什么?)
  "volatile 能保证原子性吗?++操作呢?"
  考察点:是否理解可见性≠原子性,知道 volatile 的边界

1.2 ❌ 错误示范

候选人原话 A:"volatile 修饰的变量每次都从主内存读取。"

问题诊断:这个回答过于简单,忽略了 volatile 写的成本和底层机制。volatile 读确实强制从主内存读取(通过 Load Barrier 清空 Invalidate Queue),但 volatile 写也涉及 Store Buffer 刷新和缓存行失效广播,不能简单理解为"每次都从主存读"。

候选人原话 B:"volatile 和 synchronized 一样,都能保证可见性和原子性。"

问题诊断:volatile 不保证原子性!count++ 操作(读取-修改-写入)即使 volatile 修饰也是非原子的,因为中间可能被其他线程打断。这是最常见的面试陷阱。

候选人原话 C:"既然 volatile 能保证可见性,那 volatile int count = 0; count++; 就是线程安全的。"

问题诊断:大错特错。count++ 是三个操作(读、改、写),volatile 只保证单个读或写的可见性,不保证复合操作的原子性。

1.3 标准回答

P5 级别:准确说清可见性

可见性问题:在多核 CPU 环境下,每个核心有自己的缓存(L1/L2/L3)。当一个线程修改共享变量时,修改可能只停留在该核心的缓存中,还没有刷新到主内存。其他核心的缓存中仍然是旧值,导致另一个线程读取到过期数据。

volatile 保证可见性的原理

  1. 写操作:volatile 写会触发 lock 前缀指令:
    • 将修改写入 Store Buffer(高性能写入)
    • 发送 Invalidate 消息给所有其他 CPU 核心
    • 其他核心收到消息后,将对应缓存行标记为 Invalid
  2. 读操作:volatile 读会触发特定指令:
    • 清空 Invalidate Queue,强制等待所有 pending 的 Invalidate 消息处理完成
    • 从主内存(或最新缓存行)中读取数据

回到开头的代码

number = 42 之后 ready = true,由于 传递性规则(A HB B,B HB C → A HB C):

  • number = 42 Happens-Before ready = true(程序顺序规则)
  • ready = true Happens-Before while (!ready) 为 false(volatile 变量规则)
  • 因此 number = 42 Happens-Before System.out.println(number)(传递性)

所以第二个线程一定能看到 number 的值为 42。

这个回答展示了完整的推理链,比大多数候选人强。

P6 级别:深入 MESI 与内存屏障

MESI 协议详解

在没有 Store Buffer 和 Invalidate Queue 的简化模型中,MESI 协议的写操作流程:

  1. 线程 A 在 Core 1 上写入 volatile 变量 → 缓存行变为 Modified 状态
  2. Core 1 发送总线事务(BusRdX 或 BusUpgr)通知其他核心
  3. 其他核心将对应缓存行置为 Invalid
  4. 其他核心的下一次读取从主内存获取最新值

但引入 Store Buffer(CPU 为了减少写延迟)后:

graph LR
    A[CPU Core A] -->|写入| B[Store Buffer]
    B -->|异步刷新| C[缓存行]
    C -->|MESI广播| D[其他CPU缓存失效]
    E[CPU Core B] -->|读取| C

问题:如果 Core A 将值写入 Store Buffer 后还没刷新到缓存,就允许 Core B 的读取操作绕过(因为缓存中还是旧值),导致可见性问题。

解决方案:内存屏障(Memory Barrier)

  • Store Barrier(sfence):强制 Store Buffer 中的数据同步刷新到缓存,使所有后续的读取操作必须等到刷新完成
  • Load Barrier(lfence):清空 Invalidate Queue,强制所有 pending 的 Invalidate 消息处理完成,确保读到最新值

x86 架构下,volatile 写使用 lock; mov(隐含 store barrier),volatile 读使用 mov(隐含 load barrier)。ARM/RISC-V 架构需要显式使用 dmb ish / dmb ishst 等屏障指令。

P7 级别:x86 vs ARM 的内存模型差异

x86 的内存模型(TSO - Total Store Order):

x86 是强内存模型。x86 天然保证写后读不重排序(Store-Load 重排序被禁止),所以 x86 上 volatile 的代价相对较小。但 读后写、读写后写 仍然可能重排序。

ARM/RISC-V 的内存模型(弱内存模型):

ARM 是弱内存模型。几乎所有操作都可以重排序,只有显式的内存屏障才能限制重排序。所以在 ARM 架构下,volatile 的代价更高。

JMM 的跨平台抽象:JMM 为所有平台提供了一致的语义保证,由 JIT 编译器在运行时插入适当的内存屏障。程序员不需要关心具体平台的差异,只需要记住 volatile 的 Happens-Before 规则。

JDK 9+ 的 VarHandle

JDK 9 引入了 VarHandle,提供了比 volatile 更细粒度的内存操作:

VarHandle handle = MethodHandles.lookup()
    .findStaticVarHandle(VisibilityDemo.class, "ready", boolean.class);

handle.setRelease(true);  // 释放语义:写屏障
boolean r = (boolean) handle.getAcquire(); // 获取语义:读屏障

这使得可以在保证正确的前提下减少不必要的屏障开销。

【面试官心理】 我问他 volatile 可见性原理,其实不是在考 MESI 协议的背诵。我最想听到的是他能否说清:volatile 只能保证单个变量的读写原子性和可见性,但不能保证复合操作的原子性(count++)。能提到 Store Buffer 和 Invalidate Queue 的候选人说明他对 CPU 架构有了解。能说出 x86 和 ARM 内存模型差异的候选人,说明他有跨平台视野。

1.4 追问升级

追问 1:synchronized 和 volatile 的区别?

维度volatilesynchronized
原子性❌ 不保证✅ 保证(互斥)
可见性✅ 写后立即可见✅ unlock 时全部刷新
有序性✅ 禁止重排序(读写屏障)✅ 互斥保证有序
性能✅ 极低开销❌ 加锁/解锁开销
适用场景一写多读需要原子性的场景

追问 2:volatile int[] arr 修饰数组,数组元素的变化可见吗?

这是个陷阱题。volatile 修饰的是引用(数组的地址),不是数组内容。arr[0] = 1 这种操作,修改的是堆内存中的数组元素,不是 arr 引用本身,所以 volatile 的可见性保证不适用。需要用 AtomicIntegerArrayvolatile 配合其他同步手段。

追问 3:long 和 double 的 volatile 修饰

在 JVM 规范中,非 volatile 的 long(64位)和 double 的读写可能分两次执行(在高并发和32位系统中尤其),但 volatile 修饰的 long/double 保证了 64 位的读写原子性。现代 64 位 JVM 中,非 volatile 的 long/double 也保证原子性,但这不是语言规范层面的保证。

二、volatile 的正确使用场景 🟡

2.1 状态标志模式

// ✅ 正确:volatile 用于状态标志
private volatile boolean running = true;

public void stop() {
    running = false;  // 其他线程立即看到这个改变
}

public void run() {
    while (running) {
        // 处理任务
    }
}

// ❌ 错误:复合操作使用 volatile
private volatile int counter = 0;

public void increment() {
    counter++;  // 非原子操作:读-改-写
}

2.2 双重检查锁定(必须用 volatile)

// JDK 5+ 以后,volatile 才能保证安全
private static volatile Singleton instance;

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
                // volatile 防止:分配内存 -> 赋值引用 -> 执行构造函数 的重排序
            }
        }
    }
    return instance;
}

三、生产避坑

3.1 "伪安全"的 volatile

某服务用 volatile 控制线程是否应该停止:

volatile boolean stopRequested = false;

public void run() {
    while (!stopRequested) {
        doWork();
    }
}

问题:在某些 CPU 架构和 JIT 编译优化下,while (!stopRequested) 中的读取可能被 JIT 优化为寄存器读取而不重新从内存读取,导致线程永远不停止。

正确做法:如果需要原子性的停止控制,使用 volatile + interrupt() 组合,或使用 AtomicBoolean

3.2 缓存行伪共享

即使变量都声明为 volatile,如果两个 volatile 变量在同一个缓存行中,一个变量的修改导致另一个缓存行失效,影响性能。

解决方案:JDK 8+ 的 @Contended 注解,或手动 padding:

class FalseSharingPrevention {
    volatile long value1;
    long p1, p2, p3, p4, p5, p6;  // 填充,避免伪共享
    volatile long value2;
}
💡

面试加分点:能说出"JDK 9 的 VarHandle 提供了 release/acquire 语义,可以做到比 volatile 更细粒度的控制",同时提到"JDK 21 虚拟线程的堆栈是堆内存分配的,没有 OS 线程的缓存一致性问题"。这说明你关注了 JDK 的演进。

⚠️

面试陷阱:被问到"volatile int i = 0; i++; 线程安全吗",答"是"或"线程安全"都是错的。volatile 只保证单次读/写的可见性,i++ 是复合操作(read-modify-write),volatile 无法保证原子性。正确答案是:不是线程安全的。