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 核心上,那么:
- 主线程在其核心缓存中保存了
ready和number的副本 - 读取线程也有自己的副本
- 主线程更新的是其缓存中的值
现代处理器通常不会立即把写操作应用到主内存,而是将写请求暂存在一个特殊的写缓冲区(write buffer) 中,稍后再批量刷新到主内存。
因此,当主线程更新 number 和 ready 时,读取线程无法保证何时(甚至是否)能看到这些更新。换句话说,读取线程可能立即看到新值、延迟看到,或者永远看不到。
这种内存可见性问题会导致依赖可见性的程序出现活性(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 与线程同步
在多线程应用中,我们需要确保以下两个规则以实现一致行为:
- 互斥(Mutual Exclusion):同一时刻只有一个线程能执行临界区代码
- 可见性(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 是处理轻量级线程间通信和可见性问题的强大工具,尤其适用于“一个线程写、多个线程读”的场景。然而,它不能替代 synchronized 或 java.util.concurrent 工具类来实现原子复合操作(如 i++)。
正确理解和使用 volatile,有助于编写高效、安全的并发程序。