1. 概述
虽然 Java 中的 volatile
关键字通常能保证线程安全,但并非所有情况都如此。
本教程将探讨共享 volatile
变量如何导致竞态条件的场景。
2. 什么是 volatile 变量?
与其他变量不同,volatile
变量的读写操作直接发生在主内存中。CPU 不会缓存 volatile
变量的值。
声明 volatile
变量的方式如下:
static volatile int count = 0;
3. volatile 变量的特性
本节将介绍 volatile
变量的一些重要特性。
3.1. 可见性保证
假设有两个运行在不同 CPU 上的线程,访问一个共享的非 volatile
变量。进一步假设第一个线程正在写入该变量,而第二个线程正在读取同一变量。
为提升性能,每个线程会将变量值从主内存复制到各自的 CPU 缓存中。
对于非 volatile
变量,JVM 不保证缓存中的值何时会写回主内存。
如果第一个线程的更新值没有立即刷新到主内存,第二个线程可能会读到旧值。
下图描述了上述场景:
这里,第一个线程已将变量 count
的值更新为 5。但更新值刷新回主内存的操作并非瞬时完成。因此,第二个线程读取到的是旧值。这在多线程环境中可能导致错误结果。
相反,如果我们将 count
声明为 volatile
,每个线程都能即时在主内存中看到其最新更新值。
这就是 volatile
关键字的可见性保证。它有助于避免上述数据不一致问题。
3.2. Happens-Before 保证
JVM 和 CPU 有时会重排独立指令并并行执行以提高性能。
例如,看两个可同时执行的独立指令:
a = b + c;
d = d + 1;
然而,某些指令无法并行执行,因为后续指令依赖前序指令的结果:
a = b + c;
d = a + e;
此外,独立指令的重排也可能发生。这在多线程应用中可能导致错误行为。
假设有两个线程访问两个不同变量:
int num = 10;
boolean flag = false;
进一步假设第一个线程递增 num
的值,然后将 flag
设为 true
,而第二个线程等待 flag
变为 true
。一旦 flag
为 true
,第二个线程就读取 num
的值。
因此,第一个线程应按以下顺序执行指令:
num = num + 10;
flag = true;
但假设 CPU 将指令重排为:
flag = true;
num = num + 10;
这种情况下,一旦 flag
被设为 true
,第二个线程就会开始执行。由于变量 num
尚未更新,第二个线程将读取 num
的旧值 10。这会导致错误结果。
然而,如果我们将 flag
声明为 volatile
,上述指令重排就不会发生。
对变量应用 volatile
关键字可通过提供 happens-before 保证来防止指令重排。
这确保了在写入 volatile
变量之前的所有指令,不会被重排到该写入操作之后。同样,读取 volatile
变量之后的指令也不会被重排到该读取操作之前。
4. volatile 关键字何时提供线程安全?
volatile
关键字在以下两种多线程场景中很有用:
- 当只有一个线程写入
volatile
变量,其他线程只读取其值时。这样读取线程总能看到变量的最新值。 - 当多个线程写入共享变量且操作是原子操作时。这意味着写入的新值不依赖于前一个值。
5. volatile 何时不能提供线程安全?
volatile
关键字是一种轻量级同步机制。
与 synchronized
方法或块不同,它不会让其他线程在某个线程操作临界区时等待。因此,当对共享变量执行非原子操作或复合操作时,volatile
关键字无法提供线程安全。
像递增和递减这样的操作是复合操作。这些操作内部包含三个步骤:读取变量值、更新值,然后将更新后的值写回内存。
读取值和将新值写回内存之间的短暂时间差可能产生竞态条件。在此时间差内,操作同一变量的其他线程可能读取并操作旧值。
此外,如果多个线程对同一共享变量执行非原子操作,它们可能会相互覆盖结果。
因此,在需要先读取共享变量值以确定下一个值的场景中,将变量声明为 volatile
是无效的。
6. 示例
现在,我们通过一个示例来理解上述场景:当变量被声明为 volatile
时为何不保证线程安全。
为此,我们声明一个名为 count
的共享 volatile
变量并初始化为零。同时定义一个递增该变量的方法:
static volatile int count = 0;
void increment() {
count++;
}
接下来,创建两个线程 t1
和 t2
。这些线程调用上述递增操作各一千次:
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
从上述程序中,我们可能期望 count
变量的最终值为 2000。然而,每次执行程序时结果都会不同。 有时会打印“正确”值(2000),有时则不会。
以下是运行示例程序时得到的两种不同输出:
value of counter variable: 2000
这种不可预测的行为是因为两个线程都在对共享的 count
变量执行递增操作。如前所述,递增操作不是原子操作。它执行三个操作——读取、更新,然后将变量新值写回主内存。因此,当 t1
和 t2
同时运行时,这些操作很可能发生交错。
假设 t1
和 t2
并发运行,t1
对 count
变量执行递增操作。但在它将更新值写回主内存之前,线程 t2
从主内存读取了 count
变量的值。这种情况下,t2
会读取旧值并在其基础上执行递增操作。 这可能导致 count
变量的错误值被更新到主内存。 因此,结果将与预期值 2000 不同。
7. 结论
本文我们了解到,将共享变量声明为 volatile
并不总能保证线程安全。
我们学到,要为非原子操作提供线程安全并避免竞态条件,使用 synchronized
方法/块或原子变量都是可行的解决方案。
如常,上述示例的完整源代码可在 GitHub 上获取。