Java 中 volatile 关键字指南

更新于 2025-12-28

baeldung 2017-08-20

1. 概述

编译器、运行时环境或处理器可能会应用各种优化。尽管这些优化通常是有益的,但在缺乏必要同步机制的情况下,它们可能导致微妙的问题,表现为意外的结果。

缓存(Caching)指令重排序(Reordering) 是两种常见的优化手段,在并发环境中,如果我们没有正确地进行同步,这些优化可能会带来令人惊讶的行为。

Java 及 JVM 提供了多种控制内存顺序(memory order)的方式,而 volatile 字段就是其中之一。

本教程将聚焦于 Java 中一个基础但常被误解的概念——volatile 字段。首先,我们将从底层计算机架构的工作原理入手,然后熟悉 Java 中的内存顺序。接着,我们将深入探讨多处理器共享架构中的并发挑战,并了解 volatile 字段如何帮助我们解决这些问题。


2. 共享多处理器架构

处理器负责执行程序指令,因此必须从 RAM 中获取程序指令和所需数据。

由于 CPU 每秒可以执行大量指令,直接从 RAM 获取数据效率不高。为改善这一状况,处理器采用了多种技巧,例如:

  • 乱序执行(Out of Order Execution)
  • 分支预测(Branch Prediction)
  • 推测执行(Speculative Execution)
  • 缓存(Caching)

这就引出了如下所示的内存层次结构

CPU Core → L1 Cache → L2 Cache → L3 Cache → Main Memory (RAM)

随着不同核心执行更多指令并操作更多数据,它们会在各自的缓存中填充更多相关数据和指令。这虽然提升了整体性能,但也引入了缓存一致性(Cache Coherence) 的挑战。

我们需要认真思考:当一个线程更新了一个缓存中的值时,究竟会发生什么?


3. 缓存一致性挑战

为了更深入理解缓存一致性问题,我们借用《Java 并发编程实战》(Java Concurrency in Practice)一书中的例子:

public class TaskRunner {

    private static int number;
    private static boolean ready;

    private static class Reader extends Thread {

        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }

            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new Reader().start();
        number = 42;
        ready = true;
    }
}

TaskRunner 类维护了两个简单变量。其 main 方法启动一个新线程,该线程会不断轮询 ready 变量,直到它变为 true,然后打印 number 变量。

很多人可能预期程序在短暂延迟后打印出 42;然而,实际情况可能是:

  • 延迟非常长
  • 程序永远挂起
  • 打印出 0

造成这些异常的根本原因是缺乏正确的内存可见性保证指令重排序。下面我们详细分析这两个问题。

3.1 内存可见性

这个简单示例包含两个应用线程:主线程和读取线程(Reader)。假设操作系统将这两个线程调度到两个不同的 CPU 核心上,那么:

  • 主线程在其核心缓存中保存了 readynumber 的副本
  • 读取线程也有自己的副本
  • 主线程更新的是其缓存中的值

现代处理器通常不会立即把写操作应用到主内存,而是将写请求暂存在一个特殊的写缓冲区(write buffer) 中,稍后再批量刷新到主内存。

因此,当主线程更新 numberready 时,读取线程无法保证何时(甚至是否)能看到这些更新。换句话说,读取线程可能立即看到新值、延迟看到,或者永远看不到。

这种内存可见性问题会导致依赖可见性的程序出现活性(liveness)问题。

3.2 指令重排序

更糟糕的是,读取线程看到的写入顺序可能与程序实际顺序不同。

例如,在以下代码中:

public static void main(String[] args) { 
    new Reader().start();
    number = 42; 
    ready = true; 
}

我们先更新 number,再设置 ready = true,因此期望读取线程打印 42。但实际上,它可能打印 0

重排序是一种用于提升性能的优化技术。值得注意的是,多个组件都可能执行重排序:

  • 处理器可能以不同于程序顺序的方式刷新写缓冲区
  • 处理器可能采用乱序执行
  • JIT 编译器也可能通过重排序进行优化

4. volatile 的内存顺序语义

我们可以使用 volatile 来解决缓存一致性问题。

为了确保变量的更新能可预测地传播到其他线程,我们应该对这些变量使用 volatile 修饰符。

这样,我们就向运行时和处理器发出信号:不要对涉及 volatile 变量的指令进行重排序,并且处理器应立即刷新对该变量的更新。

public class TaskRunner {

    private volatile static int number;
    private volatile static boolean ready;

    // 其余代码不变
}

通过这种方式,我们告诉运行时和处理器:避免对 volatile 变量相关的指令进行重排序,并确保更新立即对其他线程可见。


5. volatile 与线程同步

在多线程应用中,我们需要确保以下两个规则以实现一致行为:

  1. 互斥(Mutual Exclusion):同一时刻只有一个线程能执行临界区代码
  2. 可见性(Visibility):一个线程对共享数据的修改对其他线程可见,以维持数据一致性

synchronized 方法或代码块同时提供上述两种特性,但会带来一定的性能开销。

volatile 字段是一种非常有用的机制:它能确保数据变更的可见性,但不提供互斥。因此,当我们允许多个线程并行执行某段代码,但又需要保证可见性时,volatile 是理想选择。


6. volatile 的保证

Java 编译器允许对程序指令进行重排序,只要在单个线程内不影响其执行结果。但对于带有 volatile 修饰符的共享变量,JVM 保证:

  • 所有线程对 volatile 变量的访问都按照程序文本中的顺序执行
  • 不会对涉及 volatile 变量的指令进行重排序优化
  • 所有线程都能看到该变量的一致值
  • volatile 字段的任何更新都会立即更新到主内存,其他线程不会读取到过期或不一致的值

需要注意的是:volatile 保证了一致性,但不使用 volatile 并不意味着一定会出现不一致。在没有 volatile 的情况下,其他线程偶尔可能看到不一致的值。因此,不要期望移除 volatile 后程序一定会出错——但它有可能出错。


7. Happens-Before 顺序

volatile 变量的内存可见性效应不仅限于变量本身。

具体来说,假设线程 A 写入一个 volatile 变量,随后线程 B 读取同一个 volatile 变量。那么,线程 A 在写入该变量之前可见的所有变量值,在线程 B 读取该变量之后也对其可见

[线程 A] —— 写入 volatile 变量 ——> [线程 B] —— 读取同一 volatile 变量
          ↑                                     ↓
   A 写之前的所有操作                     B 读之后能看到 A 的所有操作

从技术上讲,volatile 字段的任何写操作 happens-before 后续对该字段的任何读操作。这是 Java 内存模型(JMM)中关于 volatile 变量的核心规则。


8. 借势(Piggybacking)

由于 happens-before 内存顺序的强大语义,我们有时可以“借势”于另一个 volatile 变量的可见性保证。

例如,在我们的示例中,其实只需将 ready 声明为 volatile 即可:

public class TaskRunner {

    private static int number; // 非 volatile
    private volatile static boolean ready;

    // 其余代码不变
}

根据 happens-before 规则:在写入 ready = true 之前对 number 的赋值,对读取 ready 之后的代码是可见的

因此,即使 number 不是 volatile 变量,它也能“搭便车”(piggyback)享受 ready 带来的内存可见性保证。

利用这种语义,我们可以在类中仅将少数关键变量声明为 volatile,从而优化可见性保障,同时减少性能开销。


9. 结论

在本文中,我们深入探讨了 Java 中的 volatile 关键字,包括其能力、工作原理,以及自 Java 5 起对其语义的重要增强。

volatile 是处理轻量级线程间通信和可见性问题的强大工具,尤其适用于“一个线程写、多个线程读”的场景。然而,它不能替代 synchronizedjava.util.concurrent 工具类来实现原子复合操作(如 i++)。

正确理解和使用 volatile,有助于编写高效、安全的并发程序。