1. 概述
编译器、运行时或处理器会应用各种优化。虽然这些优化通常有益,但在缺乏必要同步时,可能导致意外结果等微妙问题。
在并发上下文中,缓存和重排序这两种优化可能会让我们踩坑,尤其是代码同步没做对的时候。Java和JVM提供了多种控制内存顺序的方法,volatile
字段就是其中之一。
本教程聚焦于Java中基础但常被误解的volatile
字段概念。我们先从底层计算机架构讲起,然后熟悉Java的内存顺序,接着分析多处理器共享架构中的并发挑战,以及volatile
如何解决这些问题。
2. 共享多处理器架构
处理器负责执行程序指令,因此必须从RAM获取指令和数据。
由于CPU每秒能执行大量指令,直接从RAM获取数据效率太低。为此,处理器使用了乱序执行、分支预测、推测执行和缓存等技巧。
这就形成了以下内存层次结构:
随着不同核心执行更多指令并操作更多数据,它们会用更多相关数据和指令填充自己的缓存。这虽然提升了整体性能,但也带来了缓存一致性的挑战。
当一个线程更新缓存值时,我们必须三思其后果。
3. 缓存一致性挑战
为深入探讨缓存一致性,我们借用《Java并发编程实战》一书中的例子:
public class TaskRunner {
private static int number;
private static boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
// 自旋等待
}
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new Reader().start();
number = 42;
ready = true;
}
}
TaskRunner
类维护两个简单变量。其main
方法创建一个线程,该线程在ready
为false
时一直自旋。当变量变为true
时,线程打印number
变量。
很多人期望程序短暂延迟后打印42,但延迟可能长得多,甚至可能永久挂起或打印0。
这些异常的根本原因是缺乏适当的内存可见性和重排序。下面详细分析:
3.1. 内存可见性
这个例子有两个应用线程:main
线程和reader
线程。假设操作系统将它们调度到两个不同CPU核心:
-
main
线程在其核心缓存中有ready
和number
的副本 -
reader
线程也有自己的副本 -
main
线程更新了缓存中的值
大多数现代处理器不会立即应用写请求。处理器倾向于将写入操作排队到特殊写缓冲区,稍后一次性应用到主内存。
综上所述,当main
线程更新number
和ready
时,无法保证reader
线程会看到什么。换句话说:
-
reader
可能立即看到更新值 - 可能延迟看到
- 可能永远看不到
这种内存可见性问题可能导致依赖可见性的程序出现活性问题。
3.2. 重排序
更糟糕的是,**reader
线程看到的写入顺序可能与实际程序顺序不同**。例如我们先更新number
:
number = 42;
ready = true;
我们期望reader
线程打印42,但实际上它完全可能打印0。
重排序是性能优化技术,不同组件都可能应用:
- 处理器可能以非程序顺序刷新写缓冲区
- 处理器可能应用乱序执行
- JIT编译器可能通过重排序优化
4. volatile内存顺序
我们可以用volatile
解决缓存一致性问题。
为确保变量更新可预测地传播到其他线程,应该对这些变量应用volatile
修饰符。
这样就能通知运行时和处理器:
- 避免重排序任何涉及
volatile
变量的指令 - 立即刷新对这些变量的更新
public class TaskRunner {
private static volatile int number;
private static volatile boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
// 自旋等待
}
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new Reader().start();
number = 42;
ready = true;
}
}
这样就能通知运行时和处理器避免重排序涉及volatile
变量的指令,并立即刷新更新。
5. volatile与线程同步
多线程应用需要确保两个规则以保持行为一致:
- 互斥——一次只有一个线程执行临界区
- 可见性——一个线程对共享数据的更改对其他线程可见
synchronized
方法和块同时提供这两种特性,但代价是性能损失。
volatile
字段很有用,因为它能确保数据更改的可见性,但不提供互斥。因此,当我们允许多线程并行执行代码块,但需要保证可见性时,它就派上用场了。
6. volatile的保证
Java编译器允许重排序指令,只要不影响线程的独立执行。包含volatile
修饰符的共享变量保证:
- 运行时和处理器会按程序文本顺序执行相关指令
- 不应用可能重排序指令的优化
- 所有线程看到该共享变量的值是一致的
- 对
volatile
字段的任何更新都会立即更新共享值 - 更新后其他线程不可能获取到不一致的值
重要提示:volatile
保证一致性,但缺少volatile
不意味着其他线程总是获取不一致值——只是可能偶尔发生。不要期望移除TaskRunner
中的volatile
后程序就必然打印错误值,但这种情况确实可能发生。
7. Happens-Before顺序
volatile
变量的内存可见性效果超越了变量本身。
假设线程A写入volatile
变量,然后线程B读取同一变量。在这种情况下,写入volatile
前对A可见的值,在读取volatile
后将对B可见:
技术上,对volatile
字段的任何写入都发生在后续每次读取之前。这是Java内存模型(JMM)的volatile
变量规则。
8. 搭便车(Piggybacking)
**由于happens-before的强大特性,有时我们可以利用另一个volatile
变量的可见性来"搭便车"**。例如,在我们的例子中,只需将ready
标记为volatile
:
public class TaskRunner {
private static int number;
private static volatile boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
// 自旋等待
}
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new Reader().start();
number = 42;
ready = true;
}
}
在写入ready=true
之前的任何操作,在读取ready
后都是可见的。因此number
变量搭上了ready
强制内存可见性的便车。即使它不是volatile
变量,也表现出volatile
行为。
利用这种语义,我们可以在类中只定义少量volatile
变量来优化可见性保证。
9. 结论
本文探讨了volatile
关键字的能力,以及从Java 5开始对其的改进。简单粗暴地总结:volatile
是解决并发可见性问题的利器,但要注意它不保证原子性。理解其背后的内存模型和happens-before原则,才能在并发编程中少踩坑。
完整代码可在GitHub上找到。