- 原文地址:
- 作者: 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 writeobject = 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 variableint 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
。