代码改变世界

Java volatile 关键字深入浅出

2018-08-03 15:37  码年  阅读(1516)  评论(0编辑  收藏  举报

Java volitile关键字

Java volatile 关键字用来标记一个Java变量为“存储于主内存”。更准确地说是,每一次针对volatile变量的读操作将会从主内存读取而不是从CPU的缓存读取;每一次针对volatile变量的写操作都会写入主内存,而不仅仅是写入CPU缓存。

实际上,从Java 5开始,volatile关键字除了保证从主内存读写volatile变量以外,还保证了其他的一些东西。我将会在后面的部分进行解释。

变量可见性问题

Java volatile关键字保证变量值的变化在多个线程间的可见性。这个描述有些抽象,所以让我详细的解释一下。

在一个多线程的程序里,如果线程操作一些非volatile的变量,为了提高性能,每一个线程都可能会从主内存复制变量值到CPU缓存。如果你的电脑的CPU数量多于一个,不同的线程可能会运行于不同的CPU上。这意味着不同的线程可能会把变量复制到不同CPU的缓存中,如图所示:

 

对于使用非volatile的变量,Java虚拟机(JVM)将不会保证何时从主内存读取数据到CPU缓存,也不会保证何时把CPU缓存的数据写回到主内存。这将会造成一些问题。后面我将会详细解释。

设想以下情形,有两个或者两个以上的线程可以访问到一个包含了一个计数器的共享对象:

 

再设想一下,只有线程1增加counter变量,但是线程1和线程2会不时的读取counter变量。

如果counter变量没有被声明为volatile,counter变量的值将不会被保证何时才能从CPU缓存写回到主内存。这意味着counter变量在CPU缓存中的值可能和主内存中的值不一样。这个情形如图所示:

 

因一个线程还没有把变量的值写回主内存,其他线程不能读取到这个变量最新的值的问题被称为“可见性”问题。一个线程的更改对于其他线程不可见。

Java volatile可见性保证

Java volatile关键字的目标就是解决变量的可见性问题。声明了带volatile的counter变量,所有对counter的写操作将会理解被写回到主内存。所有对counter变量的读操作也会从主内存读取。

以下是带了volatile的counter的声明:

 

声明一个变量为volatile由此可以保证其他线程对该变量的写操作的可见性。

在上面的情形中,一个线程(线程1)修改了counter,另一个线程(线程2)读取了counter(但是从不会修改它),声明counter变量为volatile足以保证线程2对于针对counter变量写操作的可见性。

但是如果线程1和线程2都修改了counter的值,那么仅仅声明counter变量为volatile是不够的。后面会详细解释。

完全的volatile可见性保证

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

  • 如果线程A写入volatile变量,而后线程B读取同一个volatile变量,那么所有在线程A写入volatile变量之前对线程A可见的变量(译者:不一定是volatile变量)将会在线程B读取此volatile变量后对线程B可见。
  • 如果线程A读取了一个volatile变量,那么所有的当线程A读取此volatile变量时对线程A可见的变量(译者:不一定是volatile变量)将也会从主内存读取。

让我们来看一个代码的例子:

 

update()方法写入三个变量,其中只有days是volatile的。

完全的volatile可见性保证的意思是,当一个值被写入days的时候,所有对此线程可见的变量们将也会被写入主内存。也就是说,当一个值被写入days的时候,years和months的值也会被写入主内存。

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

 

注意totalDays()方法一上来就先读取days的值到total变量。当读取days的值,months和years也会从主内存读取。因此,使用上面的读取顺序,可以确保读取到days,months和years的最新的值。

指令重排序带来的挑战

由于性能方面的原因,JVM和CPU只要能够保证指令的语义保持一致,是可以对指令进行重新排序的。比如下面的代码:

 

这些指令可以按照下面的顺序重新排序,但是并没有丧失掉程序原来的语义:

 

但是当一些变量中的一个为volatile变量时,指令重排带来了挑战。让我们看一下前面例子中的MyClass类。

 

当update()方法写入值到days的时候,years和months的新写入值也会写入主内存中。但是如果JVM像下面一样重排了这些指令的顺序怎么办:

 

当days变量更改时,months和years的值仍然会写入主内存,但是这时新的值还没有写入months和years。新的值因此没有适当的对其他线程可见。重新排序的指令的语义发生了改变。

Java针对此问题有一个解决方案。我们将会在下一节看到。

Java volatile “之前发生(Happens-Before)”保证

为了应对指令重排序带来的挑战,除了可见性保证,Java volatile关键字还提供了“之前发生”(Happens-Before)保证。之前发生保证:

  • 如果对其他一些变量的读取/写入操作原本就发生在对一个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变量相同的值,为这个变量生成新值,当把值写回主内存时多个线程覆盖掉彼此的值。

多个线程同时增加同一个counter的值正是这样一个volatile变量不足以保证正确性的情形。后续将会详细解释这种情形。

假设线程1读取共享的counter变量值0到CPU缓存,增加这个值为1但是还没有把更改的值写回到主内存。线程2可能读取到此counter变量的值也是0,并放到它自己的CPU缓存。线程2接下来可能也增加counter的值为1,并且也不把更新的值写回到主内存。这个情形如图所示:

 

线程1和线程2实际上已经不同步了。这个共享的counter变量的值本应该是2,但是每一个线程在他们的CPU缓存中的值都是1,而主内存中的值还依然是0。这已经乱了。即使两个线程把值从CPU缓存写入主内存,值还是错的。

什么时候volatile是足够的

正如我前面说的,如果两个线程会同时读取写入一个共享的变量,仅仅声明变量为volatile是不够的。这种情形你需要使用synchronized关键字来保证从读取到写入变量的原子性。读取或者写入一个volatile变量并不会阻塞其他线程的读写。如果想阻塞,你必须在临界区周围使用synchronized关键字。

作为synchronized关键字的替代,你也可以使用java.util.concurrent包中的原子数据类型,比如AtomicLong或者AtomicReference等。

如果只有一个线程会读取和写入volatile变量,而其他的线程只会读取变量的值,那么读取值的线程将被保证能读到最新写入volatile变量的值。如果变量不声明为volatile,这将不能被保证。

volatile关键字支持32位和64位的变量。

volatile与性能

对volatile变量的读写会造成读写发生于主内存。对主内存读写的开销远远大于对CPU缓存的开销。对volatile变量的访问也会导致指令不能被重排序,而重排序是一种常规的提高性能的技术。因此你应该只在真正需要保证变量可见性的时候使用volatile变量。

译者总结:

  1. volatile用于保证在多CPU环境中多线程对于共享变量值变化的可见性
  2. 可见性问题是由CPU缓存造成的
  3. 如果多个变量都需要解决可见性问题,不一定所有变量都需要声明为volatile。以下情形也可以保证可见性:

只声明一个变量为volatile,然后读取的时候最先读取volatile变量,写入的时候最后写入volatile变量。

 

作者公众号(码年)扫码关注: 

 

英文网址:

http://tutorials.jenkov.com/java-concurrency/volatile.html