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 不保证缓存中的值何时会写回主内存。

如果第一个线程的更新值没有立即刷新到主内存,第二个线程可能会读到旧值。

下图描述了上述场景:

Volatile Variable

这里,第一个线程已将变量 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。一旦 flagtrue,第二个线程就读取 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++;
}

接下来,创建两个线程 t1t2。这些线程调用上述递增操作各一千次:

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 变量执行递增操作。如前所述,递增操作不是原子操作。它执行三个操作——读取、更新,然后将变量新值写回主内存。因此,当 t1t2 同时运行时,这些操作很可能发生交错。

假设 t1t2 并发运行,t1count 变量执行递增操作。但在它将更新值写回主内存之前,线程 t2 从主内存读取了 count 变量的值。这种情况下,t2 会读取旧值并在其基础上执行递增操作。 这可能导致 count 变量的错误值被更新到主内存。 因此,结果将与预期值 2000 不同。

7. 结论

本文我们了解到,将共享变量声明为 volatile 并不总能保证线程安全。

我们学到,要为非原子操作提供线程安全并避免竞态条件,使用 synchronized 方法/块或原子变量都是可行的解决方案。

如常,上述示例的完整源代码可在 GitHub 上获取。


原始标题:Volatile Variables and Thread Safety