volatile变量和线程安全

2023/06/07

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关键字是一种轻量级的同步机制。

与同步方法或块不同,它不会在一个线程处理临界区时让其他线程等待。因此,在对共享变量执行非原子操作或复合操作时,volatile关键字不提供线程安全

像递增和递减这样的操作是复合操作。这些操作在内部涉及三个步骤:读取变量的值,更新它,然后将更新的值写回内存。

读取值和将新值写回内存之间的短时间间隔可能会产生争用条件。在该时间间隔内,处理同一变量的其他线程可能会读取旧值并对其进行操作。

此外,如果多个线程对同一个共享变量执行非原子操作,它们可能会覆盖彼此的结果。

因此,在线程需要首先读取共享变量的值以计算出下一个值的情况下,将变量声明为volatile不起作用

6. 示例

现在,我们将尝试通过一个示例来理解上述情况,即在将变量声明为volatile时不是线程安全的。

为此,我们将声明一个名为count的共享volatile变量并将其初始化为0。我们还将定义一个方法来自增这个变量:

public class VolatileVarNotThreadSafe {
    private static volatile int count = 0;

    public static void increment() {
        count++;
    }

    public static int getCount() {
        return count;
    }
}

接下来,我们将创建两个线程t1和t2。这些线程调用上述increment操作一千次:

public class VolatileVarNotThreadSafe {
    private static final int MAX_LIMIT = 1000;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int index = 0; index < MAX_LIMIT; index++) {
                increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int index = 0; index < MAX_LIMIT; index++) {
                increment();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }
}

从上面的程序中,我们可能预期count变量的最终值为2000。但是,每次执行程序时,结果都会有所不同。有时,它会打印出“正确”的值(2000),有时则不会

让我们看一下运行示例程序时得到的两个不同输出:

...value of counter variable: 1978

...value of counter variable: 2000

上述不可预测的行为是因为两个线程都在对共享count变量执行递增操作。如前所述,自增操作不是原子操作。它执行三个操作-读取、更新,然后将变量的新值写入主内存。因此,当t1和t2同时运行时,很有可能会发生这些操作的交错执行。

假设t1和t2同时运行,t1对count变量执行递增操作。但是,在将更新后的值写回主内存之前,线程t2从主内存中读取count变量的值。在这种情况下,t2将读取一个旧值并对其执行递增操作。这可能会导致更新到主内存的count变量的值不正确。因此,结果将与预期2000的不同。

7. 总结

在本文中,我们看到将共享变量声明为volatile并不总是线程安全的。

我们了解到,要为非原子操作提供线程安全并避免争用条件,使用同步方法、同步块、或原子变量都是可行的解决方案。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章