Java 关键字volatile

时间:2020-01-09 10:35:53  来源:igfitidea点击:

Javavolatile关键字用于将Java变量标记为"正在存储在主存储器中"。更准确地说,这意味着每次对易失性变量的读取都将从计算机的主内存中读取,而不是从CPU缓存中读取,并且对易失性变量的每次写入都将被写入主内存中,而不仅是CPU缓存。

实际上,从Java 5开始,volatile关键字不仅仅保证将volatile变量写入主存储器或者从主存储器读取。我将在以下各节中对此进行解释。

变量的可见范围问题

Java关键字" volatile"保证了线程间变量变化的可见性。这听起来可能有点抽象,所以让我详细说明一下。

在多线程应用程序中,线程对非易失性变量进行操作,出于性能方面的考虑,每个线程在对其进行处理时都可以将主存储器中的变量复制到CPU缓存中。如果计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU缓存中。这在这里说明:

对于非易失性变量,不能保证Java虚拟机(JVM)何时将数据从主存储器读取到CPU缓存中,或者何时将数据从CPU缓存写入主存储器。这可能会导致一些问题,我将在以下部分中进行解释。

设想一种情况,其中两个或者多个线程可以访问一个共享对象,该共享对象包含一个声明为如下所示的计数器变量:

public class SharedObject {

    public int counter = 0;

}

还要想象一下,只有线程1会增加counter变量,但是线程1和线程2都可能会不时读取'counter'变量。

如果没有将counter变量声明为" volatile",则无法保证counter变量的值何时从CPU高速缓存写回到主存储器。这意味着,CPU缓存中的"计数器"变量值可能与主存储器中的不同。此处说明了这种情况:

由于线程尚未看到变量的最新值而导致该变量尚未被另一个线程写回到主内存的问题,被称为"可见性"问题。一个线程的更新对其他线程不可见。

Java易失性可见性保证

Javavolatile关键字旨在解决变量可见性问题。通过声明counter变量volatile,所有对counter变量的写操作将立即写回到主存储器。同样,所有对counter变量的读取都将直接从主存储器中读取。

这是counter变量的volatile声明的外观:

public class SharedObject {

    public volatile int counter = 0;

}

因此,声明变量'volatile'可以保证其他线程对该变量的可见性。

在上面给出的场景中,一个线程(T1)修改了计数器,而另一个线程(T2)读取了计数器(但从未修改过),声明" counter"变量" volatile"足以保证写操作T2的可见性到counter变量。

但是,如果T1和T2都在增加counter变量,那么声明'counter'变量'volatile'是不够的。以后再说。

完全可变的可见性保证

实际上,Java volatile的可见性保证超出了volatile变量本身。可见性保证如下:

  • 如果线程A写入一个volatile变量,然后线程B随后读取相同的volatile变量,那么在写入volatile变量之前,线程A可见的所有变量在读取volatile变量之后也将对线程B可见。
  • 如果线程A读取了volatile变量,那么读取volatile变量时线程A可见的所有所有变量也将从主内存中重新读取。

让我用一个代码示例来说明这一点:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate()方法写入三个变量,其中只有几天是可变的。

完整的"易失性"可见性保证意味着,当将值写入"天"时,线程可见的所有变量也将写入主内存。这意味着,当将值写入" days"时," years"和" months"的值也将写入主存储器。

当读取"年","月"和"天"的值时,我们可以这样做:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

注意," totalDays()"方法是通过将" days"的值读入" total"变量开始的。当读取"天"的值时,"月"和"年"的值也被读入主存储器。因此,保证我们可以按照上述读取顺序查看"天","月"和"年"的最新值。

指令重新排序的挑战

出于性能原因,允许Java VM和CPU对程序中的指令进行重新排序,只要指令的语义含义保持相同即可。例如,请查看以下说明:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以重新排序为以下顺序,而不会丢失程序的语义:

'
int a = 1;
a++;

int b = 2;
b++;

然而,当变量之一是" volatile"变量时,指令重排序提出了挑战。让我们来看一下这个Java易失性教程中前面示例中的MyClass类:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦update()方法将值写入" days",新写入的值" years"和" months"也将被写入主存储器。但是,如果Java VM重新对指令进行排序,如下所示:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

当修改" days"变量时," months"和" years"的值仍被写入主存储器,但这一次它发生在将新值写入" months"和" years"之前。因此,新值无法正确显示给其他线程。重新排序的指令的语义已更改。

Java有解决此问题的方法,我们将在下一节中看到。

Java易失性发生之前保证

为了解决指令重新排序的挑战,除了具有可见性保证之外,Javavolatile关键字还提供了"在发生之前"的保证。事前保证保证:

  • 如果对volatile变量的写操作最初是在对volatile变量的写之前进行的,则对其他变量的读和写不能重新排序。确保在对volatile变量进行写操作之前进行读/写操作,以确保在对volatile变量进行写操作之前"发生"。请注意,例如读/写位于对volatile的写入之后的其他变量,该变量在发生对volatile的写入之前被重新排序。只是没有相反的方式。从后到前是允许的,但从前到后是不允许的。
  • 如果读取/写入最初发生在读取volatile变量之后,则对其他变量的读取和写入不能重新排序以在读取volatile变量之前发生。注意,在读取" volatile"变量之前可能发生的其他变量的读取可以在读取" volatile"之后重新排序。只是没有相反的方式。允许从前到后,但不允许从后到前。

上面发生的事前保证保证了volatile关键字的可见性保证正在被强制执行。

volatile并不总是满足要求

即使关键字" volatile"保证直接从主存储器读取所有" volatile"变量,并且将所有对" volatile"变量的写入都直接写入主存储器,在某些情况下,仍然不够声明一个变量volatile

在前面解释的情况下,只有线程1写入共享的counter变量,声明counter变量volatile足以确保线程2总是看到最新的写入值。

实际上,如果写入变量的新值不依赖于先前的值,则多个线程甚至可能正在写入共享的" volatile"变量,并且仍将正确的值存储在主存储器中。换句话说,如果一个线程将一个值写入共享的volatile变量,则首先不需要读取其值即可找出下一个值。

一旦线程需要首先读取volatile变量的值,并基于该值为共享的volatile变量生成一个新值,volatile变量就不足以保证正确的可见性。在读取'volatile'变量和写入其新值之间的短暂时间间隔会造成竞争状况,其中多个线程可能会读取'volatile'变量的相同值,为该变量生成一个新值,并且将值写回主存储器时,将覆盖彼此的值。

多个线程递增同一计数器的情况恰好是" volatile"变量不足的情况。以下各节将更详细地说明这种情况。

想象一下,如果线程1将一个共享的counter变量读取为0,则将其添加到其CPU缓存中,将其递增为1,而不将更改后的值写回到主内存中。然后,线程2可以从主存储器(该变量的值仍为0)读取相同的"计数器"变量到其自己的CPU高速缓存中。然后线程2还可将计数器增加到1,也不会将其写回主存储器。下图说明了这种情况:

线程1和线程2现在实际上不同步。共享的"计数器"变量的实际值应该为2,但是每个线程在其CPU高速缓存中的变量值为1,而在主内存中该值仍然为0。真是一团糟!即使线程最终将共享的counter变量的值写回到主存中,该值也将是错误的。

什么时候用volatile就能满足要求?

如前所述,如果两个线程都在读取和写入共享变量,那么仅使用volatile关键字是不够的。在这种情况下,我们需要使用同步来保证变量的读写是原子的。读取或者写入volatile变量不会阻止线程的读取或者写入。为此,我们必须在关键部分周围使用synchronized关键字。

作为"同步"块的替代,我们还可以使用在java.util.concurrent包中找到的许多原子数据类型之一。例如," AtomicLong"或者" AtomicReference"或者其他之一。

如果只有一个线程读取和写入易失变量的值,而其他线程仅读取该变量,则保证读取线程可以看到写入易失变量的最新值。如果不使变量可变,则将无法保证。

保证关键字" volatile"可以在32位和64个变量上工作。