拨开荷叶行,寻梦已然成。仙女莲花里,翩翩白鹭情。
IMG-LOGO
主页 文章列表 Java Volatile变量和线程安全

Java Volatile变量和线程安全

白鹭 - 2022-03-07 2168 0 2

1. 概述

尽管volatile关键字通常可确保线程安全,但情况并非总是如此。

在本教程中,我们将研究共享volatile变量可能导致竞争条件的情况。

2. 什么是volatile变量?

与其他变量不同,volatile变量在主存储器中写入和读取。CPU 不会缓存volatile变量的值。

让我们看看如何声明一个volatile变量:

static volatile int count = 0;

3.volatile变量的属性

在本节中,我们将了解volatile变量的一些重要特性。

3.1.可见性保证

假设我们有两个线程,运行在不同的CPU 上,访问一个共享的非易失volatile变量。让我们进一步假设第一个线程正在写入一个变量,而第二个线程正在读取同一个变量。

出于性能原因,每个线程都将变量的值从主内存复制到其各自的CPU 缓存中。

volatile变量的情况下,JVM 不保证值何时会从缓存写回主内存。

如果第一个线程的更新值没有立即刷新回主内存,则第二个线程可能最终读取旧值。

下图描述了上述场景:

volatile-variables-and-thread-safety (1).png

在这里,第一个线程已将变量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++;
 }

接下来,我们将创建两个线程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同时运行,并且t1 count变量执行增量操作。但是,在将更新的值写回主内存之前,线程t2 count变量的值。在这种情况下,t2将读取一个较旧的值并对其执行增量操作。**这可能会导致更新到主内存count**变量的值不正确。因此,结果将与预期的2000 年不同。

7. 结论

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

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


标签:

0 评论

发表评论

您的电子邮件地址不会被公开。 必填的字段已做标记 *