1. 概述
尽管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.发生前保证
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;
在这种情况下,只要将标志设置为true
,第二个线程就会开始执行。并且因为变量num
尚未更新,第二个线程将读取num
的旧值,即10。这会导致不正确的结果。
但是,如果我们将flag
声明为volatile
,则不会发生上述指令重新排序。
在变量上应用volatile
关键字通过提供发生在先保证来防止指令重新排序。
这确保了在写入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
方法或块或原子变量都是可行的解决方案。
0 评论