Java Volatile Keyword 【译】

Java的volatile关键字被用来标记一个Java变量为”被存储在主内存中“,更准确的来说,这意味着每次读取volatile变量时都会从主内存中读取,而不是从CPU缓存中,写入数据也是一样,对一个volatile变量的写入,每次都会写入到主内存中,而不仅仅是写入到CPU缓存中。

事实上,从Java 5以后,volatile关键字就不仅仅用来保证变量的读写是在主内存中操作了,下文中我会详细解释。

变量可见性的问题

Java的volatile关键字可以保证变量在多个线程间的修改可见性,这可能听起来有点抽象,所以我下面会详细说明。

在一个多线程应用中,线程在操作一个非volatile变量时,由于性能的原因,每个线程可以在操作这些变量时从主内存中复制一个变量到CPU缓存中。如果你的电脑包含不止一个CPU,各个线程可以分别运行在不同的CPU中。这意味着每个线程都可以复制一个变量到不同的CPU缓存中,说明如下:

image-20200128143337614

对于非volatile变量,无法保证当Java虚拟机(JVM)读取数据时是从主内存读取到CPU缓存,也无法保证在写数据时将CPU缓存中的数据写入到主内存中。这回造成一系列的问题。

想象如下的一个情况,多个线程有权访问一个共享对象,该对象包含一个计数变量声明如下:

public class SharedObject {

    public int counter = 0;

}

想象一下,只有一个线程1增加计数变量,但是线程1和线程2都可以不时地去读取这个计数变量。

如果这个计数变量没有被声明成volatile,那么就没有保证计数变量的值从CPU缓存中写入到主内存中。这意味着,这个计数变量的值在CPU缓存中可能和内存中并不相同。

image-20200128150207313

这种因为变量没有从其他线程写回到主内存中,从而导致的多线程间不能获取到变量的最新值的问题被称为“不可见”问题。一个线程对变量的更新对其他线程不可见。

Java volatile可见性保证

Java中的volatile关键字的存在就是为了解决可见性问题。通过声明计数变量为volatile,所有对变量的写操作都会被立刻回写到主内存中。同时,所有对计数变量的写操作都会直接从主内存中读取。

以下是如何声明一个volatile变量:

public class SharedObject {

    public volatile int counter = 0;

}

因此,声明一个变量为volatile保证了对该变量的读写对其他线程的可见性。

在以上给出的场景中,一个线程(T1)修改了计数,另外一个线程(T2)读取了该计数(但是绝不会修改它),声明这个计数变量volitile已经足够保证T2线程对计数变量的可见性。

但是,如果T1,T2线程都增加这个计数变量,通过声明变量为volatile的方式就不足以保证了。这个后面再说。

完全的可见性保证

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

  • 如果线程A写入了volatile变量,随后线程B读取了同一个变量,则在写入volatile变量之前,所有变量对线程A可见,同时在读取volatile变量之后对线程B可见
  • 如果线程A读取了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;
    }
}

update()方面写入了三个变量,其中只有days变量为volatile。

完全的volatile可见性保证意味着,当一个值被写入到days,则所有对线程可见的变量都要被写入到主内存中。这意味着,如果一个值被写入到days,那么years和months的值也要被写入到主内存中。

当读取years的值时,months和days的值你可以这样做:

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变量。当读取days的值时,months和years的值也会读取到主内存中。因此利用以上的读取顺序,你可以保证获取到days,months和years的最新值。

指令重新排序的挑战

Java虚拟机和CPU允许程序出于性能原因对指令进行重新排序,只要指令的语义相同即可。例如,请看如下指令:

int a = 1;
int b = 2;

a++;
b++;

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

int a = 1;
a++;

int b = 2;
b++;

然而,指令重排对变量中的一个volatile变量构成了挑战:

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虚拟机对指令进行了重排,如下所示:

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

当days的值被修改时,years和months的值依然被写入到主内存中,但是是发生在新值被写入到months和years值之前的。新值因此没法正确的被其他线程可见。指令重排后语义已经被修改。

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

Java volatile发生前保证

为了解决指令重新排序的挑战,除了可见性保证之外,Java volatile关键字还提供了“先于先后”保证。 事前担保保证:

  • 如果读取/写入最初发生在写入volatile变量之前,读取和写入到其他变量不可以重排在写入一个volatile变量之后。读取/写入发生在写入volatile变量之前可以保证“发生前”写入volatile变量。注意,仍然有可能发生这样的情况,例如,读取/写入其他变量位于写入到一个volatile变量之后被重排到写入volatile变量之前。只是不是相反。允许从之后到之前,但是不允许从之前到之后。
  • 如果读取/写入最初发生在读取volatile变量之后,读取和写入到其他变量不可以重排再写入一个volatile变量之前。注意,可能会在读取volatile变量之后重新排序读取volatile变量之前发生的其他变量。只是不是相反。 允许从之前到之后,但不允许从之后到之前。

上面所说的发生前保证确保了volatile关键字被执行时的可见性保证。

volatile并不一定可靠

尽管volatile关键字保证了所有对volatile变量的读取都直接读取主内存中的值,并且所有对volatile变量的写入都直接写入到主内存中,但是依然有情况不足以保证一个变量volatile。

在前面说明的情况下,只有线程1向共享计数器变量写入数据,声明计数器变量volatile就足以确保线程2始终看到最新的写入值。

事实上,如果新值在吸入到变量时并不依赖它的之前的值,多线程甚至可能正在写入到共享volatile变量,并且依然可以将争取的值保存到主内存中。换句话说,如果一个线程写入一个值到共享volatile变量,则不需要先读取它的值就可以获得下一个值。

一旦一个线程需要现读取volatile变量的值,然后基于这个值来为共享变量生成一个新的值,那么一个volatile变量就不足以保证正确的可见性了。在读取volatile变量和写入新值之间有一个很短的时间间隔,这就在多个线程读取同一个volatile值,为变量生成新值,并且写入主内存时产生了一个竞争关系,多个线程彼此覆盖了写入的值。

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

想像一下,线程1读取了一个共享计数变量到它的CPU缓存中,值为0,将它的值增量为1然后并不将这个值回写到主内存中。线程2可能从主内存中读到了同一个计数变量到自己的CPU缓存中,这时这个变量值依旧为0,线程2这时也将计数增量为1,并且没有回写到主内存中。这种情况如下图所示:

线程1和线程2现在几乎是同步的。这个共享的计数变量真实值应该为2,但是每个线程在他们的CPU缓存中这个值都为1,并且在主内存中依旧为0。这是一个混乱的状态!即使线程最终将共享计数器变量的值写回到主内存,该值也将是错误的。

什么情况下volatile是足够的呢

如同我之前所述,如果两个线程都对一个共享变量进行读写,那么使用volatile关键字是不足以保证可见性的。在这种情况下,你需要使用synchronized关键字来保证读取和写入这个变量的操作是原子操作。读取或者写入一个volatile变量并不会阻塞线程的读写,为此,你必须在关键部分周围使用synced关键字。

作为synchronized块的替代,你也可以使用在java.util.concurrent package中的原子数据类型。例如AtomicLong或者AtomicReference或者其他之一。

如果只有一个线程会对volatile变量进行读写,其他变成了只对该变量进行读取,那么读取可以保证可见到写入到volatile变量的最新值。如果不使变量volatile,则无法保证。

volatile关键字保证可以在32位和64位变量上使用。

volatile的性能考虑

Volatile变量的读取和写入会使变量读取或写入到主内存中。从主内存中读取和写入会比访问CPU缓存更加耗费性能。访问volatile变量也会防止指令重排这种正常的性能增强技术。所以,你应该只在确实需要增强可见性的情况下使用volatile变量。

原文链接:Java Volatile Keyword

发表评论

电子邮件地址不会被公开。 必填项已用*标注