Java中volatile变量与线程安全
Java中volatile变量与线程安全
尽管Java中的volatile关键字通常确保线程安全,但情况并非总是如此。在本教程中,我们将探讨共享volatile变量可能导致竞态条件的场景。
什么是volatile变量?
与其他变量不同,volatile变量是直接写入和从主内存中读取的。CPU不将volatile变量的值缓存。
让我们看看如何声明一个volatile变量:
static volatile int count = 0;
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变量读取后的指令不能被重新排序到它之前发生。
volatile关键字何时提供线程安全?
volatile关键字在两种多线程场景中很有用:
- 当只有一个线程写入volatile变量,而其他线程读取其值时。因此,读取线程可以看到变量的最新值。
- 当多个线程正在写入共享变量,操作是原子的。这意味着新写的值不依赖于先前的值。
volatile不提供线程安全的情况
volatile关键字是一种轻量级的同步机制。
与_synchronized_方法或块不同,它不会让其他线程在执行关键部分时等待。因此,volatile关键字在对共享变量执行非原子操作或复合操作时不提供线程安全。
像递增和递减这样的操作是复合操作。这些操作内部涉及三个步骤:读取变量的值,更新它,然后,将更新后的值写回内存。
在读取值和将新值写回内存之间的短暂时间间隔可能会创建竞态条件。在那个时间间隔内,其他线程可能读取并操作旧值。
此外,如果多个线程对同一共享变量执行非原子操作,它们可能会覆盖彼此的结果。
因此,在这种情况下,线程需要首先读取共享变量的值以确定下一个值,声明变量为volatile将不起作用。
示例
现在,我们将通过一个示例来理解上述场景,即声明变量为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 value of counter variable: 1652
上述不可预测的行为是因为两个线程都在对共享_count_变量执行递增操作。如前所述,递增操作不是原子的。它执行三个操作——读取变量的值,更新它,然后将新值写入主内存。因此,当_t1_和_t2_同时运行时,这些操作的交错发生的可能性很高。
假设_t1_和_t2_正在并发运行,_t1_对_count_变量执行递增操作。但在它将更新后的值写回主内存之前,线程_t2_从主内存读取_count_变量的值。在这种情况下,_t2_将读取一个旧值并对其进行递增操作。 这可能导致_count_变量被更新到主内存中的错误值。因此,结果将与预期的2000不同。
结论
在本文中,我们看到了声明共享变量为volatile并不总是线程安全的。
我们了解到,为了提供线程安全并避免非原子操作的竞态条件,使用_synchronized_方法或块或原子变量都是可行的解决方案。
像往常一样,上述示例的完整源代码可在GitHub上找到。