Skip to content

volatile:可见性、有序性与使用边界

本文是 Java 并发系统学习系列的 L2 核心篇。前置:并发三大问题与 JMM 入门synchronized 深入。 学完可以配合面试题食用:02 volatile 可见性与重排序12 DCL 单例为什么要 volatile

volatile 解决什么问题,不解决什么

上一篇讲 JMM 时提到:每个线程有自己的工作内存,读写共享变量默认先走工作内存,于是出现两个经典 bug——改了别人看不见(可见性),以及代码执行顺序和写的顺序不一致(有序性)。volatile 就是 JVM 提供的最轻量的纠正手段,它对被修饰的变量承诺两件事:

  • 可见性:写一个 volatile 变量时,工作内存里的值会立即刷回主内存,并让其他线程缓存该变量的副本失效;后续读必须重新从主内存加载。
  • 有序性:编译器和处理器不会把 volatile 读写前后的指令任意重排(下面细说靠什么挡住)。

但它不承诺原子性count++ 是"读-改-写"三步,volatile 只保证每一步读到的值是新的,挡不住两个线程同时读到同一个值各加一次。验证很简单:

java
public class VolatileNotAtomic {
    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10_000; i++) {
                count++;  // volatile 挡不住竞态:两个线程可能同时读到同一个旧值
            }
        };
        Thread t1 = new Thread(task), t2 = new Thread(task);
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println(count);  // 几乎必然 < 20000
    }
}

所以一句话定位:volatile 是"轻量级同步",只管可见与有序,不管复合操作的原子。需要原子性时用 synchronizedAtomicXxxLongAdder

内存屏障:volatile 的底层机制

JVM 层面对 volatile 的有序性保证,落到底层是内存屏障(memory barrier)指令。可以把它理解成插在指令流里的栅栏,规定栅栏前后的内存操作不能跨越。常见的四种:

  • LoadLoad:load1; LoadLoad; load2 —— load2 及之后的读不能提前到 load1 之前
  • StoreStore:store1; StoreStore; store2 —— store1 之前的写必须先落好,store2 才能执行
  • LoadStore:load1; LoadStore; store2 —— store2 不能提前越过 load1
  • StoreLoad:store1; StoreLoad; load2 —— 最重的一个,写必须全部可见后才能读

HotSpot 按 JMM 的保守策略,在 volatile 操作前后插入屏障:

mermaid
flowchart LR
    subgraph 普通读写区A[volatile 写之前的普通操作]
        A1[普通读/写]
    end
    A1 -- StoreStore 屏障 --> W[volatile 写]
    W -- StoreLoad 屏障 --> L[volatile 读]
    L -- LoadLoad + LoadStore 屏障 --> B1[后续普通读/写]
  • volatile 写之前插 StoreStore:保证前面的普通写不会被重排到 volatile 写之后
  • volatile 写之后插 StoreLoad:保证这个写对所有后续读可见(这也是写比读贵的原因,StoreLoad 在 x86 上对应 lock addl,代价接近完整内存栅栏)
  • volatile 读之后插 LoadLoad + LoadStore:后续读写都不能提前到这次读之前

另外还有一个重要的派生规则——happens-before:volatile 写 happens-before 后续对同一变量的读。结合传递性,写之前的所有普通写也能被读之后的线程看到。这就是"一次性发布"能成立的根基:把对象写进 volatile 字段前赋好的值,读到的线程都能看到完整状态。

DCL 单例:为什么要 volatile

volatile 最著名的应用场景是双重检查锁(DCL)单例。先看标准写法:

java
public class Singleton {
    private static volatile Singleton instance;  // 去掉 volatile 就是隐患版

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查:无锁快路径
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查:拿锁后再确认
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

问题出在 instance = new Singleton() 这一行。它不是原子的,大体分三步:

  1. 分配内存
  2. 调用构造函数初始化对象
  3. 把 instance 指向那块内存

其中 2 和 3 可能被重排成 1→3→2。如果线程 A 执行到 3 之后、2 之前被切走,线程 B 在第一次检查时看到 instance 非 null,直接返回——拿到的是一个字段还没初始化的半成品对象,用起来就是各种诡异 NPE。用状态图看这段时序:

mermaid
sequenceDiagram
    participant A as 线程A(持锁)
    participant B as 线程B(无锁)
    A->>A: 1. 分配内存
    A->>A: 3. instance 指向内存(重排后先执行)
    Note over B: 第一次检查 instance != null,直接返回
    B-->>B: 拿到未执行构造函数的对象 💥
    A->>A: 2. 执行构造函数(晚了)

加 volatile 后,对 instance 的写有 StoreStore 屏障兜底:构造函数的写完成之前,instance 的赋值不能对外可见,B 线程不可能读到半初始化状态。注意 volatile 在这里买的不是可见性而是有序性——很多人以为 DCL 加 volatile 是怕 null 值不可见,其实锁本身已经保证了可见性,真正缺的是禁止 2、3 重排。

什么时候该用 volatile:场景清单与边界

volatile 适合"一写多读、单变量状态"的场景,逐个过一遍:

  • 状态标志位:一个线程置位、其他线程只读。比如后台任务的 stop 标志,没有 volatile 的话工作线程可能永远读不到 true,循环退不出去。这是最典型、最安全的使用。
  • 一次性发布(one-time publication):配置对象在启动时构建一次、之后只读。用 volatile 引用发布,配合 happens-before,读线程能看到构造时的全部状态。注意前提是对象之后不可变或不会再被并发修改,否则只保证"看到完整初始版",后续修改的原子性 volatile 管不了。
  • 双读双写(独立观察):多个线程写同一个 volatile 变量的不同观测值、读方取"最新即可"。比如多个温度传感器各自 latestTemp = 读数;,展示端只读最后一个。能用的前提是写竞争不会造成逻辑错误——丢一次中间值无所谓。
  • 边界之外:任何复合操作(i++、check-then-act、先读再写依赖旧值)都不行;多个 volatile 变量之间的一致性约束也不行——A、B 两个 volatile 字段要"同时变",volatile 保证不了,得回到锁或 AtomicReference 打包成不可变对象。

判断口诀:"写的动作本身不依赖当前值、且新值一旦写定就有效"的变量,才配得上 volatile

动手实操

把上面两个关键点跑一遍。先是状态标志位(可见性的正确用法):

java
public class VolatileFlagDemo {
    // 换成普通 boolean 试试:循环大概率退不出来(JIT 把 run 提升为本地缓存)
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long count = 0;
            while (running) {   // volatile 读,每次都拿最新值
                count++;
            }
            System.out.println("worker stopped, looped " + count);
        });
        worker.start();
        Thread.sleep(1000);
        running = false;        // volatile 写,1 秒后通知 worker
        worker.join();
    }
}

再把 DCL 的半初始化问题用代码"看到"——用一个故意暴露中间状态的对象模拟:

java
public class HalfInitDemo {
    static class Config {
        int a = 0;
        Config() {
            a = 42;                       // 构造函数里的赋值
            try { Thread.sleep(50); } catch (InterruptedException ignored) {}
        }
    }

    // 去掉 volatile 后多跑几次,B 线程可能打出 a=0(读到构造未完成的引用)
    private static volatile Config config;

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> {
            synchronized (HalfInitDemo.class) {
                if (config == null) config = new Config();
            }
        });
        Thread b = new Thread(() -> {
            while (config == null) { }    // 自旋等引用非空
            System.out.println("B sees a = " + config.a);
        });
        a.start(); b.start();
        a.join(); b.join();
    }
}

java -XX:+PrintCompilation 观察 JIT 编译日志,配合 -Xint(纯解释执行,无 JIT 优化)对比 FlagDemo 的行为差异,能直观感受到可见性问题是由编译器/缓存优化引入的,不是 Java 语义的 bug。

常见误区与小结

  • 误区一:volatile 让 i++ 线程安全。 不,i++ 是读-改-写三步,只保证可见挡不住交错,该用 AtomicInteger。
  • 误区二:DCL 加 volatile 是为了 null 可见。 锁已保证可见性,volatile 买的是禁止"赋值先于构造"的重排。
  • 误区三:两个 volatile 变量能保持一致。 不能,各自独立可见,跨变量不变式需要锁或打包成单个不可变对象。
  • 误区四:volatile 比锁快所以处处用它替锁。 语义不同不可替换;且 StoreLoad 屏障并不免费,热点路径上 volatile 写的开销是普通写的数倍。

小结:volatile 是 JMM 面向程序员的轻量工具,理解它的正道是记住"可见 + 有序、不原子"六个字,再看内存屏障如何兑现承诺、happens-before 如何传导可见性。它在学习路径上位于 JMM 之后、AQS 之前,是读懂无锁结构(如 AQS 的 state)的 prerequisite。下一篇进入 AQS 源码走读,看 JUC 如何用 volatile + CAS 搭出整套同步器骨架。

参考

  • Java 语言规范 JLS §17.4:Java Memory Model(happens-before 与 volatile 语义的权威定义)
  • Doug Lea, The Java Language Specification 同步章节 / JSR-133 FAQ(中文译版流传较广,重排例子很直观)
  • OpenJDK HotSpot 源码 orderAccess 系列(各平台内存屏障的落地实现)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1