← 返回题目列表

volatile 关键字的作用和原理是什么?

高频 中等 第 15 / 31 题 更新于 2026/07/25
volatile内存可见性指令重排

简化版

volatile 为单个变量提供可见性与特定有序性:对它的写 happens-before 随后读取到该写的 volatile 读,可用于状态发布和 DCL 引用。它不会把 count++ 这类读—改—写变成原子操作,也不是一把互斥锁。JMM 规定语义,JIT 再用目标 CPU 的内存屏障或带顺序保证的指令实现,不能简单等同于“每次强制读写物理主内存”。

详细版

volatile 写与后续 volatile 读建立 happens-before,再结合程序顺序和传递性,可以安全发布写之前完成的普通字段更新。所有线程还会以一致的同步顺序观察同一个 volatile 变量的读写。

它约束编译器与处理器不能执行会破坏这套语义的重排序,但不会禁止所有优化。单次 volatile 读写具有相应原子与可见效果,复合表达式仍由多个动作组成:两个线程都读到 0,再各写 1,最终就可能只有 1。

适合场景包括停止标志、不可变配置快照发布和正确 DCL;需要复合不变量、累加扣减或 check-then-act 时,应使用锁或原子类。

完整版教学

一、可见性不是“立刻刷新”的墙上时钟承诺

JMM 允许线程和编译器采用寄存器、缓存与代码优化,只要最终行为符合内存模型。普通变量存在数据竞争时,另一个线程没有同步边就不能推导何时看到写入,甚至循环读取可能被优化成重复使用旧值。

boolean running = true;
// T1: while (running) { doWork(); }
// T2: running = false;

这里没有 happens-before 链,程序本身就有数据竞争。把 running 声明为 volatile 后,T2 的 volatile 写与 T1 后续 volatile 读建立同步关系;它保证内存语义,而不是承诺“在现实时间 1 纳秒内被调度并停止”。

二、volatile 写怎样发布前面的普通写

线程 A 先构造配置并写普通字段,再执行 ready=true 的 volatile 写;线程 B 读到 ready=true 后读取配置。程序顺序、volatile 规则和传递性把这些动作串成完整的 happens-before 链。

A: 写 config 字段 → volatile 写 ready
                         ↓ happens-before
B: volatile 读 ready → 读取 config 字段

因此 volatile 常用来发布已经构造完成、之后不再随意改变的对象快照。如果构造期间先把 this 泄露出去,或发布后多个线程继续无同步修改对象内部字段,仅给引用加 volatile 也不能修复所有竞态。

三、为什么 count++ 仍会丢更新

count++ 至少包含读旧值、计算加一、写回三个逻辑动作。假设 volatile count 初始为 0,两个线程可能发生如下交错:

时刻线程 A线程 Bcount
T1读到 00
T2读到 00
T3写回 11
T4写回 11

2 个线程执行了 2 次自增,期望值为 2,最终却可能只有 1。每次 volatile 读写都可见并不矛盾,因为缺失的是整个读—改—写序列的原子性;应改用 AtomicInteger.incrementAndGet() 或锁保护。

四、DCL 为什么需要 volatile

双重检查锁把无锁快速路径和初始化时的同步结合起来:

class Singleton {
    private static volatile Singleton instance;

    static Singleton getInstance() {
        Singleton result = instance;
        if (result == null) {
            synchronized (Singleton.class) {
                result = instance;
                if (result == null) {
                    result = new Singleton();
                    instance = result;
                }
            }
        }
        return result;
    }
}

没有 volatile 时,引用发布与构造写入之间缺少安全发布保证,另一个线程可能观察到非 null 引用却看不到完整初始化结果。volatile 写把构造期间的写发布出去,后续读到该引用的线程才能依据 happens-before 使用对象。

局部变量 result 还减少了 volatile 字段的重复读取。更简单的单例通常可使用枚举或静态初始化持有者,避免手写 DCL 的复杂度。

五、volatile 约束的是哪些重排序

“禁止指令重排”是过度简化。JMM 只禁止会让其他线程观察到不符合 volatile 语义的重排:volatile 写具有 release 类效果,之前的相关操作不能被挪到发布之后;volatile 读具有 acquire 类效果,后续相关操作不能被挪到读取之前。

普通写不能越过 volatile 写向后逃逸
普通读不能越过 volatile 读向前提取

JIT 会根据 x86、ARM 等目标架构选择屏障或具备相应顺序的指令。不同 CPU 的缓存协议和内存模型不同,所以“固定插入四种屏障”“每次刷回物理主内存”都不是跨平台源码层保证。

记忆钩子:JMM 先规定 happens-before,JIT 和 CPU 再负责实现;面试先讲语义,再讲屏障,别把 MESI 或主内存刷新当成 Java 规范。

六、volatile 与锁、原子类怎样选择

需求volatile原子类
单一状态标志合适可用但常没必要可用但较重
单变量原子更新不够合适合适
多字段不变量不够整体封装后才可能合适
互斥执行临界区不提供不直接提供提供
阻塞/条件等待不提供不提供Monitor/Condition

“一个线程写、多个线程读”是常见用法,但不是唯一判据。真正判据是写入是否能作为独立状态发布、是否依赖旧值,以及多个字段之间是否存在必须同时成立的约束。

七、性能成本与伪共享

volatile 不会像互斥锁那样让所有读者串行,但热点写仍会造成缓存行失效和核间通信。若两个互不相关的 volatile 计数恰好位于同一缓存行,两个核心频繁写各自字段也可能产生伪共享。

假设两个线程各更新 1,000 万次独立计数,放在同一缓存行时缓存行所有权会来回转移,吞吐可能显著下降。是否发生及下降多少依赖对象布局、CPU 和 JDK,应用应通过 JMH、硬件计数器或生产剖析验证,不能凭字段相邻就下结论。

只读 volatile 的成本通常低于高频写,但在紧密循环中仍可能限制编译器复用值。优化前先确认它是不是热点以及语义能否安全改变。

八、常见误区与追问

  • 误区:volatile 写能保证其他线程在固定时间内立刻运行并看到值。 它提供内存可见性顺序,不控制线程调度和墙上时钟延迟。
  • 误区:volatile 会禁止变量周围的所有编译器和 CPU 优化。 它只限制会破坏规定内存语义的重排序。
  • 误区:volatile int count 能让 count++ 线程安全。 自增是复合读改写,仍会发生丢更新。
  • 追问:volatile 怎样实现安全发布? 写前的操作通过程序顺序和 volatile 写—读规则传递到读线程后续操作。
  • 追问:volatile 能保护引用对象内部所有后续修改吗? 不能;发布后可变字段的并发修改仍需各自同步策略。
  • 追问:为什么 DCL 的引用必须是 volatile? 它阻止不安全发布,并让构造写入对读到非 null 引用的线程可见。
  • 追问:volatile 比 synchronized 一定快吗? 两者语义不同;热点 volatile 写也有一致性成本,不能用它替代必须互斥的临界区。

九、加强记忆

volatile 要按“发布、读取、边界”记:写是 release 类发布,读是 acquire 类获取,二者通过 happens-before 把前面的普通写传递给后续读,因此适合状态标志、配置快照和 DCL 引用。它只保护单次变量访问的规定语义,不把 count++、条件检查或多字段更新变成原子事务。底层屏障由 JIT 按 CPU 架构选择,不能机械解释成每次刷新物理主内存。遇到复合不变量用锁,单变量原子更新用 Atomic 类,热点统计再评估 LongAdder 与伪共享成本。