《Java 并发编程·线程与内存模型》4.1 volatile 的语义与边界

用 javap 与实测把 volatile 的三重语义拆开:它保证可见性与有序性,却不保证复合原子性;同步给出 DCL 的正确写法、8 线程下 volatile 计数器仍然丢更新的真实数据,以及 volatile 数组、volatile 引用与字段内容之间的边界。

一个关键字,三种承诺

volatile 大概是 Java 里被误用最多的关键字。它只有三个词的说明——「可见、有序、不原子」——但每个词都要配上具体的字节码、平台实现和反例才能说得清。

这一节按三个问题展开:

  1. 它在字节码里长什么样?
  2. 它禁止了哪些重排,靠什么手段?
  3. 它到底不保证什么?

第 1 问的答案会让人意外:字节码里完全看不到 volatile 的痕迹。第 2 问的答案取决于 CPU 架构。第 3 问的答案决定了绝大多数 volatile 误用的根源。

先把结论列出来,后面逐条给证据:

承诺volatile 是否提供典型用途
写后对其它线程可见是状态标志、停止信号
禁止与前后指令重排是DCL 单例、发布不可变对象
复合操作的原子性否计数、累加、check-then-act
引用所指对象内部字段的一致性否缓存、快照对象

字节码里没有 volatile

写一个最小类,把 volatile 字段和普通字段放在一起:

public class VolatileFlags {
    private volatile boolean running = true;
    private int plain;

    public void setPlain(int v) { this.plain = v; }
    public void setRunning(boolean r) { this.running = r; }
    public int getPlain() { return plain; }
}

用 javap -c -p 看这两个 setter:

public void setPlain(int);
  Code:
     0: aload_0
     1: iload_1
     2: putfield      #13                 // Field plain:I
     5: return

public void setRunning(boolean);
  Code:
     0: aload_0
     1: iload_1
     2: putfield      #7                  // Field running:Z
     5: return

两条字节码完全一样,都是 aload_0 / iload_1 / putfield,没有屏障指令,没有特殊 opcode。volatile 的信息不在指令里,而在字段的访问标志上:

private volatile boolean running;
  descriptor: Z
  flags: (0x0042) ACC_PRIVATE, ACC_VOLATILE

private int plain;
  descriptor: I
  flags: (0x0002) ACC_PRIVATE

ACC_VOLATILE 的值是 0x0040,与 ACC_PRIVATE 的 0x0002 相或就是 0x0042。JVM 在解释执行时读这个标志决定要不要走内存屏障路径;在 JIT 编译时读这个标志决定要不要在生成的机器码里插屏障。

这解释了一个常见困惑:为什么用反编译工具看 volatile 字段的赋值,看不出它和普通赋值有什么区别。因为差异在语义层和执行层,不在指令层。

屏障插在哪里

HotSpot 定义了四种内存屏障,volatile 的读写会按规则在前后插入:

屏障作用
LoadLoad保证前面的读先于后面的读完成
StoreStore保证前面的写先于后面的写完成
LoadStore保证前面的读先于后面的写完成
StoreLoad保证前面的写先于后面的读完成,最重的屏障

一个 volatile 写之前插 StoreStore,之后插 StoreLoad;一个 volatile 读之后插 LoadLoad 和 LoadStore。

对应到「volatile 写不能和前面的普通写重排」,就是 StoreStore 在起作用;「volatile 读不能和后面的普通读重排」,就是 LoadLoad。

屏障在 CPU 层面未必是真实指令。x86 是强内存模型,只有 StoreLoad 需要 mfence 或带 lock 前缀的指令,其余三种在 x86 上都是空操作。ARM 是弱内存模型,需要 dmb 系列指令。所以同一段 Java 代码在 M1 和 x86 服务器上生成的指令数不同,语义却一致——这是 JMM 承诺的。

本机没有安装 hsdis,-XX:+PrintAssembly 拿不到反汇编,所以这里不贴机器码。想自己验证,需要先把 hsdis 库放进 $JAVA_HOME/lib/server/,再加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 运行。没有 hsdis 时更可靠的验证方式是用 JCStress 做行为级测试——语义不依赖具体指令,行为测试才是判据。

可见性:为什么循环停不下来

第 3.1 节演示过没有 volatile 的循环停不下来的现象。这里补上它为什么发生。

public class SpinWait {
    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (running) {
                // 空转
            }
            System.out.println("stopped");
        });
        worker.start();
        Thread.sleep(100);
        running = false;
    }
}

主线程已经写了 running = false,worker 线程却可能永远看不到。原因有两层:

  • JIT 提升:编译器发现循环体内没有对 running 的写,可以把它读一次存进寄存器,循环条件退化为「检查寄存器」。
  • 缓存:即使每次重新读,读取也可能命中 CPU 缓存,而写方的更新还在写缓冲里没刷出去。

JMM 不允许编译器做第一种优化吗?允许。因为程序里没有任何同步点,编译器可以假设没有其它线程会改这个字段。volatile 的作用就是告诉编译器「这个字段随时可能被外部改变,不许缓存它的值」。

加上 volatile 后现象消失,不是因为「每次都读主存」(现代 CPU 没有主存这个概念),而是因为屏障禁止了寄存器提升,并且缓存一致性协议保证写方的更新最终对所有核可见。

有序性:DCL 为什么需要 volatile

双重检查锁(DCL)是最经典的 volatile 应用场景:

public final class Config {
    private static Config instance;
    private final Map<String, String> values;

    private Config() {
        this.values = load();
    }

    public static Config getInstance() {
        if (instance == null) {          // 第一次检查
            synchronized (Config.class) {
                if (instance == null) {  // 第二次检查
                    instance = new Config();   // 危险点
                }
            }
        }
        return instance;
    }
}

instance = new Config() 不是一步操作,它至少拆成三步:

  1. 分配内存;
  2. 调用构造器初始化字段(values 被赋值);
  3. 把引用写到 instance。

第 2 步和第 3 步之间没有数据依赖,编译器和 CPU 都可以把它们重排成 1-3-2。此时另一个线程在第一次检查处看到 instance != null,直接返回这个对象,然后访问 instance.values——拿到的是还没有初始化的 null。

注意这个错误在什么样的机器上都可能发生,而且极难复现。x86 的 StoreStore 屏障语义会让 2 和 3 的重排在多数情况下不出现,所以很多人在 x86 上写了十年 DCL 也没出过问题。换到 ARM 服务器,或者换个 JIT 版本,问题就冒出来了。

修复是把 instance 声明成 volatile:

private static volatile Config instance;

volatile 写之前的 StoreStore 屏障保证构造器的写先于引用写完成;volatile 读之后的 LoadLoad 屏障保证读到引用后,构造器写入的字段也已经可见。

更简单的写法是静态持有者:

private static final class Holder {
    static final Config INSTANCE = new Config();
}

public static Config getInstance() {
    return Holder.INSTANCE;
}

类初始化由 JVM 保证线程安全,且延迟到首次访问。前面 VolatileFlags.dclStyle 的字节码就是这种写法:

public void dclStyle();
  Code:
     0: getstatic     #17                 // Field Holder.INSTANCE:LHolder;
     3: astore_1
     4: return

只有一条 getstatic,没有任何同步指令。类初始化的锁由 JVM 在 getstatic 内部处理,字节码里看不见。

原子性:volatile 计数器为什么还丢更新

这是最重要的一条边界。volatile int count; count++; 看起来「每次都读写最新值」,实际上 count++ 是三步:

  1. 读 count 的当前值;
  2. 加一;
  3. 写回。

volatile 只保证第 1 步读到的是最新的、第 3 步写完立刻可见。它不能让第 1 步到第 3 步变成一个不可分割的整体。两个线程可以同时读到 100,各自加一,各自写回 101。

实测。8 个线程各做 10 万次自增,期望 80 万:

static volatile int vCount;
static int plainCount;
static int syncCount;

// 三种实现分别在 8 个线程里跑 100_000 次
run("volatile", () -> vCount++);
run("plain", () -> plainCount++);
run("synchronized", VolatileCounter::syncInc);

本机 JDK 25 上连跑三次的结果:

volatile -> 132754 (expect 800000), 38 ms
plain -> 138596 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 106 ms
---
volatile -> 127606 (expect 800000), 37 ms
plain -> 188270 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 131 ms
---
volatile -> 126739 (expect 800000), 36 ms
plain -> 214327 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 29 ms

三个结论:

  • volatile 计数器只跑到 12.7 万~13.3 万,丢了八成以上的更新。它比普通 int 好看,纯粹是因为每次写都要过屏障,线程之间的时间窗口错开了,碰撞变少;这属于「错得少一点」,不是「对」。
  • 普通 int 每次跑出来的数都不一样,进一步说明结果完全不可预测。
  • synchronized 三次都是准确的 800000。

耗时数据只能看数量级,不能当基准:本机当时系统负载很高(load averages 在 70 以上),微秒级差异不可信。但正确性结论与负载无关。

这就是为什么计数器要用 AtomicInteger、AtomicLong 或 LongAdder,而不是 volatile。AtomicInteger.incrementAndGet 用一次 CAS 把读-改-写合成一个原子操作;LongAdder 在高竞争下进一步分片。第 8 章会展开 LongAdder 的 Cell 结构。

三种「看起来是原子」的错觉

错觉一:volatile 的 check-then-act

if (!initialized) { doInit(); initialized = true; },initialized 声明为 volatile。两个线程可以同时通过 !initialized 检查,doInit() 执行两次。volatile 保证读写可见,不保证检查和赋值之间没有别人插进来。正确写法是 CAS:if (compareAndSet(false, true)) { doInit(); },或者用 synchronized。

错觉二:volatile 引用不代表内容安全

private volatile List<String> cache = new ArrayList<>();

public void add(String s) {
    cache.add(s);   // 写的是 ArrayList 内部数组,不是 volatile 字段
}

volatile 修饰的是「cache 这个引用」,不是「cache 指向的 ArrayList」。往列表里加元素修改的是对象内部字段,没有任何 volatile 语义。多线程 add 依然会坏。

要让「引用 + 内容」一起安全,得让内容不可变,用整体替换代替原地修改:先复制旧列表、追加新元素,再用一次 volatile 写把新引用发布出去。写时复制(CopyOnWrite)就是这个思路,第 6 章会讲它的开销。

错觉三:volatile 数组元素不是 volatile

private volatile String[] slots = new String[16];

public void put(int i, String v) {
    slots[i] = v;   // 数组元素没有 volatile 语义
}

volatile 修饰数组引用,slots[i] 的读写是普通读写。要元素级语义得用 AtomicReferenceArray 或 VarHandle 的数组视图。

这三条错觉的共同点:volatile 的作用范围是「字段本身」,不会传播到字段指向的对象内部。

该用什么:四选一的判断表

需求首选备选不选
状态标志、停止信号volatile booleanAtomicBoolean普通 boolean
单例发布volatile + DCL 或静态持有者枚举无 volatile 的 DCL
计数器LongAdderAtomicLongvolatile int
引用整体替换volatile + 不可变对象AtomicReferencevolatile + 可变对象
需要多个字段一起变synchronized 或不可变快照AtomicReference<Snapshot>多个 volatile 字段
有竞争的长临界区ReentrantLocksynchronized自旋 CAS

最后一行值得单独说明。两个 volatile 字段 a、b 在同一个方法里依次赋值,读方可能看到新的 a 和旧的 b——两次 volatile 写之间不是原子的。要「要么都看到新的,要么都看到旧的」,得把两个值打包进一个不可变对象,用单个 volatile 引用发布。这是「快照式更新」的基本手法。

在 libraryd-conc 里落地

借阅服务的生命周期状态用 volatile 就够,因为它只需要「可见的开关」:

public final class ServiceState {
    private volatile boolean running = true;
    private volatile boolean draining;

    public boolean isRunning() {
        return running;
    }

    public void startDrain() {
        draining = true;   // 先置排空标志
        running = false;   // 再停止接收
    }

    public boolean isDraining() {
        return draining;
    }
}

这里有两个 volatile 字段,顺序有讲究:先 draining = true 再 running = false。因为 StoreStore 屏障保证两个写的顺序,读方看到 running == false 时一定能看到 draining == true。反过来写就会出现「已停止但没标记为排空」的中间态。

这正是「volatile 写之间有序」的一个正当用法。但要注意,它依赖的是写方的顺序保证;写方如果是多条线程并发改这两个字段,顺序就没了。

书目缓存则用「不可变快照 + volatile 引用」:

public final class CatalogCache {
    private volatile Map<String, Book> snapshot = Map.of();

    public Book find(String isbn) {
        return snapshot.get(isbn);   // 无锁读,读到的是一份完整快照
    }

    public void reload(Map<String, Book> fresh) {
        snapshot = Map.copyOf(fresh);   // 整体替换
    }
}

读方永远拿到一份完整的、不可变的 Map,不需要加锁。写方用 Map.copyOf 保证发布出去的对象不会再被修改。这个模式在配置热更新、路由表刷新里很常见。

常见误用清单

写法问题修复
volatile int i; i++;复合操作非原子AtomicInteger / LongAdder
if (!flag) { work(); flag = true; }check-then-actCAS 或加锁
volatile List 后直接 add修饰引用不修饰内容不可变对象整体替换
volatile String[] a; a[i] = v元素无 volatile 语义AtomicReferenceArray
多个 volatile 字段表示一个状态组合不可原子打包成不可变快照
DCL 里漏写 volatile引用可能先于构造完成可见加 volatile 或改静态持有者
用 volatile 替代锁保护长临界区只保证可见,不保证互斥用锁
以为 volatile 每次都读「主存」现代 CPU 无此机制理解屏障语义

小结

  • volatile 在字节码里体现为 ACC_VOLATILE 标志,指令与普通字段赋值完全相同;语义由 JVM 和 JIT 在屏障层面实现。
  • 它提供可见性和禁止重排,不提供复合操作的原子性。8 线程实测里 volatile 计数器只跑到期望值的 16% 左右。
  • DCL 必须用 volatile,因为引用写可能先于构造器完成;静态持有者类更简单。
  • volatile 的作用范围只到字段本身,不会传播到指向对象的内部,也不会传播到数组元素。
  • 需要多个字段一起变,用不可变快照或锁;需要原子累加,用 AtomicLong 或 LongAdder。

下一节拆 synchronized 的字节码,看监视器是怎么在指令层面进入和退出的。

阅读导航:上一节 3.3 final 字段与安全发布 | 下一节 4.2 synchronized 的字节码与对象头

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收