java volitate关键字_【11】Java中的volatile关键字

  • Post author:
  • Post category:java


作者: Jakob Jenkov

Java中的volatile关键是用于标记一个“存放在主存(内存)中的”变量。 更准确的说,是每次读取volatile变量都会从计算机主存(内存)读取,而不是从CPU的cache中读取。 而且,每次对volatile变量的写操作也是会立即写回主存,而不是仅仅写CPU cache。

事实上,从Java 5开始,volatile关键字不仅仅是保障了在主存中读/写,还有了其他的一些保障。下面就逐个来介绍这些特性。

volatile的可见性保障

volatile可以保障在跨线程的情况下对于变量的可见性。 接下来就详细说说这一点。

在多线程应用中,如果线程操作的是一个非volatile的普通变量,那么,出于性能的考虑,每个线程会将变量从主存中拷贝一份到CPU cache中来使用。 而且如果,计算机中有多个CPU(现在的CPU基本都是多核心),那每个线程还可能还会在不同的CPU上运行。 这就会导致,每个线程会将变量拷贝到不同的CPU中的cache里。例如下图:

64bce1f525d0faf347036ac280969ee3.png

在JVM从主存读取数据到CPU cache,或者从CPU cache中写回主存这两个过程中,对于非volatile的普通变量就无法得到保障。

(读取后可能就不会再次同步,写回的时间点也不确定)

想象一下这样的一个场景,有多个线程访问一个共享对象,其中包含一个counter变量。比如:

public class SharedObject {

public int counter = 0;

}

再想象一下,如果只有线程A对counter变量进行递增,而线程A和线程B可能间歇性读取counter的值。

如果counter变量不是定义成volatile的,就无法保障counter变量修改后能即时从CPU cache中写回主存。 这就会导致,counter在CPU cache中与主存中的值,是不一致的。如下图所示:

30f44ec3fb653116c0f04f80cfb91a18.png

由于没有写回主存,这就会导致其他线程看不到这个变量最新的值。这种情况,就被称之为:可见性问题。 一个线程对数据的修改,对其他线程不可见。

而如果将counter声明成volatile的,那么对于counter的修改就会立即写回主存。同样的,对于counter的读取也会直接从主存中读取。比如下面这样:

public class SharedObject {

public volatile int counter = 0;

}

声明volatile,就可以在变量修改时,保障对其他线程的可见性。

volatile的Happens-Before原则

实际上,对于volatile是保障了以下两点:

如果线程A对一个volatile进行了修改,而线程B随后进行读取,那么,在volatile变量写入之前,线程A所能看到的变量,在线程B读取volatile变量之后同样都是可见的。

对于volatile变量的读/写指令,JVM不能进行重排序优化。对于volatile的读写指令,JVM必须保障先后顺序。

接下来就深入的聊一聊这个原则。

当一个线程对一个volatile变量进行了写操作,那么,随后就不仅仅是这个volatile变量自己被写回主存了。而是这个线程,在此之前(写volatile变量)写过的所有变量,都会被刷新回主存。 而当一个线程读取一个volatile变量时,这个线程也会一起从主存中重新读取其他变量(那些随volatile一起刷新到主存的变量)

来看看这个例子:

Thread A:

sharedObject.nonVolatile = 123;

sharedObject.counter = sharedObject.counter + 1;

Thread B:

int counter = sharedObject.counter;

int nonVolatile = sharedObject.nonVolatile;

当线程A对普通变量sharedObject.nonVolatile写入123,而这个操作发生在volatile变量sharedObject.counter的写操作之前。所以,sharedObject.nonVolatile和sharedObject.counter都会在线程A对sharedObject.counter写操作时被写回主存。

当线程B开始读取sharedObject.counter变量时,sharedObject.nonVolatile变量也会一起从主存中读取到CPU cache中。 这就是说,线程B在读取sharedObject.nonVolatile时,也会看到线程A写入的值。

开发时,可以使用这个可见性保障来优化线程间的数据可见性问题。 而不需要把所有的变量都定义成volatile,有的时候只需要定义少量的volatile就可以了。 下面这个Exchanger的例子,就是利用这个原则的:

public class Exchanger {

private Object object = null;

private volatile hasNewObject = false;

public void put(Object newObject) {

while(hasNewObject) {

//wait – do not overwrite existing new object

}

object = newObject;

hasNewObject = true; //volatile write

}

public Object take(){

while(!hasNewObject){ //volatile read

//wait – don’t take old object (or null)

}

Object obj = object;

hasNewObject = false; //volatile write

return obj;

}

}

线程A会间歇性的调用put()方法。线程B会间歇性的调用take()方法。 而Exchanger类中,只是定义了一个volatile变量(没有使用synchronized同步块)。 如果只有线程A调用put并且只有线程B调用take,那这样就是足够安全的。

然而,JVM会为了优化性能,可能会在不破坏语义的前提下,对Java指令进行重排序优化。 如果JVM对put和take方法进行重排序,会怎样呢? 如果put方法变成下面这样:

while(hasNewObject) {

//wait – do not overwrite existing new object

}

hasNewObject = true; //volatile write

object = newObject;

把volatile变量hasNewObject放到了object之前。 这在JVM看来时完全可以接受的。因为object和hasNewObject没有直接的依赖关系。

所以,重排序就会破坏object变量的可见性。 首先,当线程A还没有更新object,线程B就可以看到hasNewObject设置成了true。 其次,这个时候,就没有什么能够保障object能写回主存了(可能需要等到线程A再次对volatile变量更新的时候)。

为了防止上述情况的发生,volatile关键字引入了一条“happens-before”保障。 happens-before保障了对volatile变量的读/写指令不能被重排序。 之前/之后的指令可以被重排序,但是volatile的读/写指令是不能改变的。

再来看看这个例子:

sharedObject.nonVolatile1 = 123;

sharedObject.nonVolatile2 = 456;

sharedObject.nonVolatile3 = 789;

sharedObject.volatile = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;

int someValue2 = sharedObject.nonVolatile5;

int someValue3 = sharedObject.nonVolatile6;

JVM可以对前3跳指令进行重排序,只要它们都是发生在volatile写指令之前的。

同样的,JVM可以对最后3条指令进行重排序。

这就是volatile的Happens-before原则的基本含义。

volatile并不总是够用

虽然,volatile关键字能够保障变量的读取操作都是直接从主存中读取的,并且写操作也会直接写回主存。 但是,仅仅把变量定义成volatile,并不能足够的保障变量的并发安全。

在上文中,说过只有线程A更新counter变量,那么,将counter定义成volatile就足以保障线程B能够看到最新值。

但是,实际中如果多个线程对共享volatile变量进行写操作时,如果新值不依赖旧值(值可以直接覆盖),那这样也行。但是如果这个新值是需要在旧值的基础上做一些更新(比如计数器的递增)那这就会出问题。

比如,一个线程先读取volatile变量,并在这个值的基础上产生一个新值,那就无法保障得到一个正确的可见性。因为,在读取值与写回新值这个间隙,多线程进行这个操作时,就是在这个间隙中产生一个竞态条件。多个线程可能会彼此覆盖对方的值。

比如,多个线程对一个volatile变量进行递增操作时,就会发生上述的问题。

想象一下,如果线程A读取了counter变量的值为0,并放到了CPU cache中,然后,对其递增了1 ,但是还没写回主存。 这个时候,线程B读取了同样的值。 然后,线程B也对其递增了1,并且还没有写回主存。如下图所示:

9f2b6c297c84e41fbf5977aca5982ed5.png

这种情况下,线程A和B实际上没有进行同步。counter变量的值,应该是2,但是每个线程都会在CPU cache中更新为1,而主存中还是0。 这就出问题了。即使,线程把counter写回主存,这个值也是错误的。

那什么时候volatile足够保障并发安全呢?

如果有两个线程同时读写共享变量,那volatile是不足以保障并发安全的。 这种情况下,需要使用synchronized来保障变量读写操作的原子性。 读/写volatile变量不会阻塞线程,所以,需要使用在临界区使用synchronized关键字。

除了使用synchronized同步块之外,还可以使用java.util.concurrent包下的原子数据类型(AtomicXxxx)。例如:AtomicLong,AtomicReference等。

如果是只有一个线程写,其他线程只是读取变量,那这个时候使用volatile是可以保障变量的最新值的可见性的。这种情况下,变量是并发安全的。

volatile关键字是可以保障32位和64位变量的。

JVM变量是使用了一个32bit的solt,来存放变量的。如果是64位变量(如:long,double),就需要占用两个solt,那么就需要两次操作来完成它们的读/写。在并发情况下,这些操作也可能出现问题。

volatile的性能问题

volatile变量的读/写操作,会引发主存的变量读/写。 而主存的读写要远远慢于CPU cache的。 另外,volatile还会阻止重排序,而重排序是一种常见的性能优化方法。 因此,要注意,只在有必要时(并且是正确的情况下,如:一写多读),才使用volatile。



版权声明:本文为weixin_30366435原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。