作者: Jakob Jenkov
Java中的volatile关键是用于标记一个“存放在主存(内存)中的”变量。 更准确的说,是每次读取volatile变量都会从计算机主存(内存)读取,而不是从CPU的cache中读取。 而且,每次对volatile变量的写操作也是会立即写回主存,而不是仅仅写CPU cache。
事实上,从Java 5开始,volatile关键字不仅仅是保障了在主存中读/写,还有了其他的一些保障。下面就逐个来介绍这些特性。
volatile的可见性保障
volatile可以保障在跨线程的情况下对于变量的可见性。 接下来就详细说说这一点。
在多线程应用中,如果线程操作的是一个非volatile的普通变量,那么,出于性能的考虑,每个线程会将变量从主存中拷贝一份到CPU cache中来使用。 而且如果,计算机中有多个CPU(现在的CPU基本都是多核心),那每个线程还可能还会在不同的CPU上运行。 这就会导致,每个线程会将变量拷贝到不同的CPU中的cache里。例如下图:
在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中与主存中的值,是不一致的。如下图所示:
由于没有写回主存,这就会导致其他线程看不到这个变量最新的值。这种情况,就被称之为:可见性问题。 一个线程对数据的修改,对其他线程不可见。
而如果将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,并且还没有写回主存。如下图所示:
这种情况下,线程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。