Java volatile 关键字

更新于 2025-12-28

Jakob Jenkov 2023-06-27

Java volatile 关键字用于将一个 Java 变量标记为“存储在主内存中”。更准确地说,这意味着对 volatile 变量的每次读取都会直接从计算机的主内存中读取,而不是从 CPU 寄存器中读取;而对 volatile 变量的每次写入也会直接写入主内存,而不仅仅是写入 CPU 寄存器。

实际上,从 Java 5 开始,volatile 关键字所保证的内容远不止“读写都发生在主内存”这一点。我将在以下章节中详细解释。

变量可见性问题

Java volatile 关键字确保了变量在多线程之间的可见性。这听起来可能有点抽象,让我详细说明一下。

在一个多线程应用程序中,如果线程操作的是非 volatile 变量,出于性能原因,每个线程可能会将变量从主内存复制到 CPU 寄存器中进行处理。如果你的计算机有多个 CPU,那么每个线程可能运行在不同的 CPU 上。这就意味着,每个线程可能会把变量复制到不同 CPU 的寄存器中,如下图所示:

非 volatile 变量的可见性问题

对于非 volatile 变量,JVM 不保证何时将数据从主内存读入 CPU 寄存器,也不保证何时将数据从 CPU 寄存器写回主内存。这会引发一些问题,我们将在接下来的章节中解释。

假设两个或更多线程可以访问一个共享对象,该对象包含如下声明的计数器变量:

public class SharedObject {
    public int counter = 0;
}

再假设只有 Thread 1 对 counter 变量进行递增操作,但 Thread 1 和 Thread 2 都会时不时地读取这个变量。

如果 counter 变量没有被声明为 volatile,就无法保证 Thread 1 何时会将 counter 的值从 CPU 寄存器写回到主内存。这意味着 CPU 寄存器中的 counter 值可能与主内存中的值不一致,如下图所示:

可见性问题示意图

这种由于一个线程尚未将变量值写回主内存,导致其他线程看不到最新值的问题,被称为“可见性问题”(visibility problem)——即一个线程的更新对其他线程不可见。


Java volatile 的可见性保证

Java volatile 关键字正是为了解决变量可见性问题而设计的。通过将 counter 变量声明为 volatile,所有对该变量的写入操作都会立即写回主内存,所有读取操作也都会直接从主内存读取

以下是将 counter 声明为 volatile 的方式:

public class SharedObject {
    public volatile int counter = 0;
}

这样声明后,就能保证其他线程能看到对该变量的写入操作。

在上述场景中,如果只有 Thread 1 修改 counter,而 Thread 2 只读取(不修改),那么将 counter 声明为 volatile 就足以确保 Thread 2 总是能看到最新的值。

然而,如果 Thread 1 和 Thread 2 都要对 counter 进行递增操作,仅使用 volatile 就不够了。我们稍后再详细讨论这一点。


完整的 volatile 可见性保证

实际上,Java volatile 的可见性保证不仅限于 volatile 变量本身,还包括与之相关的其他变量

具体规则如下:

  • 如果线程 A 写入了一个 volatile 变量,而线程 B 随后读取了同一个 volatile 变量,那么在线程 A 写入该 volatile 变量之前可见的所有变量,在线程 B 读取该 volatile 变量之后也对线程 B 可见。
  • 如果线程 A 读取了一个 volatile 变量,那么在线程 A 读取该变量时对其可见的所有变量,也会从主内存中重新读取。

我们用代码示例来说明:

public class MyClass {
    private int years;
    private int months;
    private volatile int days;

    public void update(int years, int months, int days) {
        this.years = years;
        this.months = months;
        this.days = days;
    }
}

update() 方法中,三个变量被赋值,但只有 days 是 volatile 的。

根据完整的 volatile 可见性保证:当 days 被写入时,当前线程可见的所有变量(包括 yearsmonths)也会被刷新到主内存

同样,在读取时:

public class MyClass {
    private int years;
    private int months;
    private volatile int days;

    public int totalDays() {
        int total = this.days;      // 读取 volatile 变量
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days) {
        this.years = years;
        this.months = months;
        this.days = days;
    }
}

注意:totalDays() 方法首先读取 days。根据 volatile 的语义,此时 monthsyears 也会从主内存中重新加载。因此,你可以确保看到这三个变量的最新值。


指令重排序带来的挑战

出于性能优化,Java 虚拟机(JVM)和 CPU 允许对程序中的指令进行重排序(reordering),只要重排序后的语义与原始顺序一致。

例如,以下代码:

int a = 1;
int b = 2;
a++;
b++;

可以被重排序为:

int a = 1;
a++;
int b = 2;
b++;

语义不变,但执行顺序变了。

然而,当涉及 volatile 变量时,指令重排序就可能带来问题。

回顾之前的 MyClass 示例:

public void update(int years, int months, int days) {
    this.years = years;
    this.months = months;
    this.days = days; // volatile
}

正常情况下,写入 days 会触发 yearsmonths 也被刷新到主内存。

但如果 JVM 将指令重排序为:

public void update(int years, int months, int days) {
    this.days = days; // volatile
    this.months = months;
    this.years = years;
}

那么在写入 days 时,monthsyears 还未被赋新值!此时刷新到主内存的仍是旧值,导致其他线程看到不一致的状态。

语义已经发生了改变!


Java volatile 的 happens-before 保证

为了解决指令重排序的问题,Java volatile 关键字除了提供可见性保证外,还提供了 happens-before(先行发生)保证

  1. 写入 volatile 变量前的操作不会被重排到写入之后
    如果某些读/写操作在程序顺序上发生在 volatile 写入之前,那么这些操作一定会在 volatile 写入之前完成,并对后续读取该 volatile 变量的线程可见。

  2. 读取 volatile 变量后的操作不会被重排到读取之前
    如果某些读/写操作在程序顺序上发生在 volatile 读取之后,那么这些操作一定会在 volatile 读取之后执行。

注意:允许将“写 volatile 之后”的操作重排到“写 volatile 之前”(只要不影响语义),但不允许将“写 volatile 之前”的操作重排到“写 volatile 之后”。

这一保证确保了 volatile 的可见性语义不会被指令重排序破坏。


volatile 并不总是足够的

即使 volatile 保证了读写都直接访问主内存,但在某些场景下,仅使用 volatile 仍然不足以保证线程安全

场景一:单写多读 ✅

如果只有一个线程写入 volatile 变量,其他线程只读取,那么 volatile 是足够的。

场景二:多线程写入且新值依赖旧值 ❌

但如果多个线程都要基于当前值计算新值(例如 counter++),那么 volatile 就不够了。

原因:counter++ 实际上包含三步:

  1. 从内存读取 counter
  2. 加 1
  3. 写回内存

即使 counter 是 volatile 的,这三个步骤不是原子的。两个线程可能同时读到相同的旧值(比如 0),各自加 1 后都写回 1,导致最终结果应为 2 却变成了 1。

如下图所示:

竞态条件示意图

这就是典型的竞态条件(race condition)。


什么时候 volatile 足够?

总结如下:

  • 仅一个线程写,多个线程读volatile 足够。
  • 多个线程写,且写操作依赖变量当前值(如 i++i = i + 1)→ volatile 不够,需使用 synchronizedjava.util.concurrent 中的原子类(如 AtomicIntegerAtomicLong 等)。
  • 多个线程写,但新值不依赖旧值(如直接赋常量 flag = true)→ volatile 足够。

注意:volatile 保证对 32 位和 64 位变量都有效(包括 longdouble)。


volatile 的性能考量

  • 读写 volatile 变量需要访问主内存(或至少是缓存一致性协议),比访问 CPU 寄存器慢。
  • volatile 会禁止某些编译器和 CPU 的优化(如指令重排序),可能影响性能。

因此,只在真正需要保证可见性时才使用 volatile

实际上,现代 CPU 通常先将寄存器值写入 L1 缓存(速度较快),再由其他硬件同步到主内存。但即便如此,仍应谨慎使用 volatile。