原理

volatile 的底层原理确实涉及 Java 内存模型(JMM)、缓存一致性协议以及 CPU 指令等多个层面。

简单来说,volatile 保证了可见性有序性,但不保证原子性。其底层原理核心是内存屏障缓存一致性协议

下面我们自顶向下,从 JMM 规范到硬件实现来拆解。

1. 问题根源:CPU 缓存与 JMM

现代 CPU 为提升性能,每个核心都有自己的 L1/L2 高速缓存。变量会先被读取到缓存中修改,再择机写回主内存。这导致了可见性问题:一个线程修改了变量,另一个线程可能仍读着自己缓存中的旧值。

Java 内存模型(JMM)是一套抽象规范,它定义了 volatile 的行为规则,而其底层则依赖硬件提供的支持来实现。

2. 核心机制:内存屏障

JMM 的实现依赖在 volatile 读写指令前后插入特定的内存屏障。内存屏障是一条特殊的 CPU 指令,它能:

  • 禁止屏障两侧的指令重排序(保证有序性)
  • 强制将缓存写回主内存 / 使其他缓存行失效(保证可见性)

当写一个 volatile 变量时:

  1. StoreStore 屏障:保证屏障前的普通写操作都已刷回内存。
  2. volatile 变量的值从工作内存写回主内存。
  3. StoreLoad 屏障:这是最“重”的屏障,它确保屏障前的写操作完成,并强制让其他 CPU 的缓存行失效,且屏障后的读操作不会被重排到这里。

当读一个 volatile 变量时:

  1. LoadLoad 屏障:保证先读取 volatile 变量,再读取其后的普通变量。
  2. 从主内存读取最新的值。
  3. LoadStore 屏障:保证先读取 volatile 变量,再对其后的普通变量进行写操作。

形象理解:内存屏障就像一堵墙。对 volatile 变量的写操作,相当于把之前所有操作的结果都强制“推”回主内存,并通知其他 CPU“你们的缓存旧了,得重新从主存读”;对 volatile 变量的读操作,则强制从主内存获取最新值,并且后续操作不能跨过这道墙提前执行。

3. 硬件落地:缓存一致性协议 (MESI)

内存屏障具体如何让其他 CPU 核的缓存失效?这主要依赖于硬件层的缓存一致性协议,最常见的是 MESI 协议

  • MESI 状态:每个缓存行有四种状态:

    • M (Modified):已修改,数据只在本核缓存,且与主存不一致。
    • E (Exclusive):独享,数据只在本核缓存,且与主存一致。
    • S (Shared):共享,数据在多个核缓存中,且与主存一致。
    • I (Invalid):无效。
  • 嗅探机制:每个 CPU 核会不断“嗅探”总线上的内存事务。

当 CPU 写一个 volatile 变量时(配合 StoreLoad 屏障),过程大致如下:

  1. CPU 核 0 发起一个“写入”请求并锁住总线或缓存行。
  2. 它会通过总线发出一条 RFO (Read For Ownership) 信号,告知其他 CPU 核:“我要修改这个地址的数据”。
  3. 其他 CPU 核(如核 1)的嗅探机制收到 RFO 信号后,会将自身缓存中对应的缓存行状态从 S 或 E 标记为 I (Invalid)
  4. 核 0 将新值写入本地缓存(标记为 M),并最终写回主内存。

之后,当核 1 想读取这个变量时,发现缓存行已失效(状态 I),只能重新从主内存加载最新的值。这就实现了跨线程的立即可见。

4. 为什么 volatile 不保证原子性?

volatile 保证了读、写操作本身是原子的,但对于 count++ 这样的“读-改-写”复合操作,它无能为力。

  • 执行步骤:读取 count 值 -> 在 CPU 寄存器中加 1 -> 写回 count
  • 问题场景:线程 A 和 B 同时读取 count=10,各自加 1 后分别写回 11。结果两次自增,实际只增加了 1。volatile 无法阻止多个线程同时读到相同旧值的情况。

要解决这个问题,需要使用 synchronizedLockAtomicInteger 类(其底层使用 CAS,常与 volatile 配合)。

总结要点

特性 volatile 保证? 底层实现关键点
可见性 ✅ 是 内存屏障 + 缓存一致性协议(如 MESI + RFO 机制)
有序性 ✅ 是 禁止指令重排序(通过内存屏障限制编译器和 CPU 重排)
原子性 ❌ 否 仅保证单次读/写原子,不保证复合操作

补充:x86 架构下的底层指令

在 x86 平台上,volatile 写操作会被翻译成带有 lock 前缀的指令(如 lock addl $0x0, (%rsp)),或者类似 mov 配合内存屏障。lock 前缀的作用是:

  • 锁定总线或缓存行(实现缓存一致性)
  • 相当于一个 StoreLoad 屏障,强制其他 CPU 核刷新缓存
  • 禁止前后指令重排序

这与上述 MESI 协议无缝衔接。

经典使用场景剖析

1. 状态标志位(boolean 标记)

用来控制线程是否继续运行。由于只需保证可见性,不涉及复合操作。示例完整的双线程演示代码:

public class VolatileDemo {
    private volatile boolean running = true;

    public void shutdown() {
        System.out.println("Shutdown called");
        running = false;
    }

    public void doWork() {
        while (running) {
            // 模拟工作
        }
        System.out.println("Worker stopped");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo demo = new VolatileDemo();

        // 线程 A:工作线程,不断检查 running
        Thread worker = new Thread(demo::doWork);
        worker.start();

        // 让工作线程跑一会儿
        Thread.sleep(1000);

        // 线程 B:主线程,发出停止信号
        demo.shutdown();

        worker.join(); // 等待工作线程结束
        System.out.println("Program finished");
    }
}

运行结果:工作线程会在 shutdown() 调用后很快退出,因为 volatile 保证了 running 的新值被工作线程立刻看到。如果去掉 volatile,工作线程可能永远看不到 running = false,导致无法停止(死循环)。

2. 配置项更新

一个线程修改 timeout 配置值,另一个线程持续读取并打印,volatile 保证读取线程能立刻看到新值。双线程完整演示。

public class VolatileConfigDemo {

    // 配置项:没有 volatile 则无法保证可见性
    private volatile int timeout = 3000;

    public void setTimeout(int newTimeout) {
        System.out.println(Thread.currentThread().getName() + " 准备修改 timeout: " + newTimeout);
        timeout = newTimeout;
        System.out.println(Thread.currentThread().getName() + " 已修改 timeout 为: " + newTimeout);
    }

    public int getTimeout() {
        return timeout;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileConfigDemo config = new VolatileConfigDemo();

        // 线程 A:读取线程,每秒打印一次当前 timeout
        Thread reader = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                int current = config.getTimeout();
                System.out.println(Thread.currentThread().getName() + " 读取到 timeout = " + current);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "ReaderThread");

        // 线程 B:修改线程,3秒后将 timeout 改为 5000,再过3秒改为 10000
        Thread updater = new Thread(() -> {
            try {
                Thread.sleep(3000);
                config.setTimeout(5000);

                Thread.sleep(3000);
                config.setTimeout(10000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "UpdaterThread");

        reader.start();
        updater.start();

        // 主线程等待10秒后,结束演示(手动中断读取线程)
        Thread.sleep(10000);
        reader.interrupt();
        updater.interrupt();

        System.out.println("演示结束");
    }
}

运行效果(有 volatile 时)

ReaderThread 读取到 timeout = 3000
ReaderThread 读取到 timeout = 3000
ReaderThread 读取到 timeout = 3000
UpdaterThread 准备修改 timeout: 5000
UpdaterThread 已修改 timeout 为: 5000
ReaderThread 读取到 timeout = 5000   ← 立即看到新值
ReaderThread 读取到 timeout = 5000
ReaderThread 读取到 timeout = 5000
UpdaterThread 准备修改 timeout: 10000
UpdaterThread 已修改 timeout 为: 10000
ReaderThread 读取到 timeout = 10000  ← 再次立即看到新值
...

如果去掉 volatile(只声明 private int timeout = 3000;

读取线程可能一直输出 3000,永远看不到 500010000(取决于 JVM 是否偶然刷新缓存,但理论上无 volatile 不保证可见性,很可能死循环在旧值)。

延伸:如果修改依赖旧值会怎样?

假设我们想要 timeout += 100(读-改-写),则 volatile 仍可能不安全。若需要原子更新,应该使用 AtomicInteger

private AtomicInteger timeout = new AtomicInteger(3000);
// 然后执行 timeout.addAndGet(100);

3. 双重检查锁(DCL)单例模式

以下代码是标准的 DCL 单例模式

1  public class Singleton {
2      private static volatile Singleton instance;
3  
4      private Singleton() {}
5  
6      public static Singleton getInstance() {
7          if (instance == null) {                 // 第一步:第一次检查(第7行)
8              synchronized (Singleton.class) {    // 同步块开始(第8行)
9                  if (instance == null) {         // 第二步:第二次检查(第9行)
10                     instance = new Singleton(); // 第三步:创建对象(第10行)
11                 }
12             }
13         }
14         return instance;
15     }
16 }

逐行解释

  • 第 2 行private static volatile Singleton instance;
    volatile 是必须的,用于禁止 instance = new Singleton(); 的指令重排序。

  • 第 7 行if (instance == null)
    第一次检查。目的是避免每次调用 getInstance() 都进入同步块,提升性能。如果 instance 已经非空,直接返回现有实例。

  • 第 8 行synchronized (Singleton.class)
    加类锁,保证同步块内的代码在同一时刻只有一个线程执行。
    ⚠️ 注意:锁的粒度是整个类,因为静态方法。

  • 第 9 行if (instance == null)
    第二次检查。当多个线程同时通过第一次检查后,它们会排队进入同步块。第一个线程进入后创建了对象,后续线程进入时发现 instance 已经不是 null,则不会再创建。这一步避免了重复创建实例。

  • 第 10 行instance = new Singleton();
    创建对象。这一行在字节码中并非原子操作,它大致分解为:

    • ① 分配内存空间
    • ② 调用构造器初始化对象
    • ③ 将 instance 引用指向分配的内存

    正常顺序是 ① → ② → ③。但 JIT 编译器或 CPU 可能会指令重排序,变成 ① → ③ → ②(即先赋值引用,再初始化)。
    如果没有 volatile,一个线程执行到 ③ 但还未执行 ② 时,另一个线程来到第 7 行发现 instance != null,就会直接返回这个尚未初始化完成的对象(例如其中的字段还是默认值),导致程序出错。
    加了 volatile 之后,禁止了这种重排序,保证 ② 一定在 ③ 之前完成,即对象完全初始化后才将引用赋值给 instance

总结 DCL 中 volatile 的必要性

步骤 对应行号 作用
第一次检查(非同步) 第 7 行 性能优化,避免每次调用都加锁
同步块入口 第 8 行 线程安全地创建单例
第二次检查 第 9 行 防止多线程重复创建
对象创建(含重排序风险) 第 10 行 volatile 禁止 ① ③ ② 的重排序,保证先初始化再赋值

volatile与原子性详解

为什么 volatile 不保证原子性?

1. 原子性的含义

原子性是指一个或多个操作在 CPU 执行过程中不可被中断的特性。例如 int i = 1 这个写操作是原子的,因为一步就完成。但 i++ 实际上由三步组成:

  1. 从内存读取 i 的当前值到 CPU 寄存器
  2. 在寄存器中执行加 1 操作
  3. 将新值写回内存

这三步之间可以被线程调度打断

2. volatile 保证的是什么?

  • 单个读/写操作是原子的:例如 volatile int a = 10; 写入,或 int b = a; 读取,这些是原子的。
  • 可见性:写完后立即刷新到主内存,其他线程读取时强制从主内存取最新值。
  • 有序性:禁止指令重排序。

3. 为什么复合操作不安全?

考虑两个线程同时对同一个 volatile int count 执行 count++

时间 线程 A 线程 B count 实际值
T1 读取 count = 0 0
T2 读取 count = 0 0
T3 执行 +1 → 1 0
T4 执行 +1 → 1 0
T5 写回 count = 1 1
T6 写回 count = 1 1

最终结果是 1,而不是预期的 2。虽然 volatile 强制写回主内存并让其他线程失效缓存,但无法防止两个线程同时读到相同的旧值。这个“同时读”正是因为读操作和写操作之间没有锁保护,允许交错执行。

结论:volatile 只保证读或写本身是原子的,不保证“读-改-写”作为一个整体是原子的。需要原子性必须使用 synchronizedAtomicXXX 类(它们利用 CAS,内部往往也依赖 volatile 保存值,但通过 CAS 保证更新原子性)。

volatile vs synchronized 对比

维度 volatile synchronized
可见性 ✅ 保证 ✅ 保证(锁释放时强制刷新到内存)
原子性 ❌ 不保证复合操作 ✅ 保证代码块内操作原子性
有序性 ✅ 禁止重排序(有限制) ✅ 保证代码块内有序
阻塞 无阻塞,轻量 可能阻塞线程(重量级锁)
适用场景 单个写、多个读,且写不依赖当前值 需要原子性、多个操作组合
性能开销 低(无上下文切换) 相对高(锁竞争时)

选择原则

  • 如果只是一个线程写,多个线程读,且写操作不依赖读到的值 → 用 volatile
  • 如果需要读-改-写(如 count++)或多个操作组成一个不可分割的单元 → 用 synchronizedLock

总结与建议

  • 简单总结volatile 底层靠内存屏障 + MESI 实现可见性和有序性,但不适合需要原子性的复合操作。
  • 典型用途:状态标志、配置参数、DCL 单例(配合 synchronized)。
  • 与 synchronized 的关系:各有所长,volatile 是轻量级同步,synchronized 是重型保障。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐