??volatile是一个在java并发编程中耳熟能详的关键字。即使从来没有使用过,你也偶尔会在技术书籍或博客中见到。对volatile关键字的解释常常被一笔带过:被修饰的变量具有可见性,但不能保证原子性。但是到底如何保证可见性,可见性是什么……诸如此类的问题在碰到这种凝练的解释时会产生一种知其然不知其所以然的困惑。那么我将尽力在这一篇文章中将volatile关键字的意义彻底击穿。
可见性
??可见性,顾名思义,可见程度。假如你在被窝里打开了手电筒,只有你自己知道手电筒打开了,那么可见性就非常差了,但是你在被窝里放了摄像头,打开手电筒,在新闻联播中插播了你打开摄像头的过程,这个可见性就非常高了。
??在cpu串行计算的情况下,完全独裁了程序的内存和计算,任何变化都被他完全控制。但在并发编程中就不是这样了,多个cpu各自为政,对于共享变量,看到的可能是过时的数据,计算的结果可能会被立即覆盖,造成了严重的混乱。来看一段代码public class TestVolatile implements Runnable{ //自增变量i public int i = 0; @Override public void run() { while (true){ i++; //不断自增 } } public static void main(String[] args) throws InterruptedException { TestVolatile tv = new TestVolatile(); SeeSome ss = new SeeSome(); ss.v = tv; Thread t1 = new Thread(tv); Thread t2 = new Thread(ss); t1.start(); t2.start(); Thread.sleep(10); //打印 i 和 s System.out.println("tv.i = " + tv.i + "-------------ss.s = " + ss.s); System.exit(0); } } class SeeSome implements Runnable{ public TestVolatile v; //关键成员变量s public int s; @Override public void run() { while (true){ s = v.i;//不断将v.i的值赋给s } } }
??这是输出结果:
tv.i = 4048335-------------ss.s = 547818
Process finished with exit code 0
??大体解释下这个程序,TestVolatile类中一个int成员变量i,run方法用来自增i。SeeSome类中两个成员变量,一个是TestVolatile,另一个是一个int变量s,run方法用来将成员TestVolatile的i赋值给s。即启动两个线程,TestVolatile用来自增自己的成员i,SeeSome用来将TestVolatile中的i赋值给自己的成员变量s。结果中很明显可以看到,两个值相去甚远。
??再来看一段程序,其他都不用改变只把TestVolatile中的i改成被volatile修饰
public class TestVolatile implements Runnable{
volatile int i = 0;
@Override
public void run() {
while (true){
i++;
}
}
??再来看一下输出:
tv.i = 362312-------------ss.s = 362790
Process finished with exit code 0
??这时候已经非常接近了,这就是volatile的威力,实际上,虽然输出语句在一行之中,但是输出的时候是先取i的值,取完i的值之后,i还在线程中自增,所以看到s的值比i的值大一些。看到这里你大体对volatile关键字有些感官的认识了,用文字朦胧地概括一下:多线程中,好像能拿到volatile最新的值。究其根源,volatile到底如何保证其可见性呢,如果更深一步,可能理解的更透彻。
??我们再来看一段代码,把volatile修饰的Integer i的自增操作转换成jvm指令,如下图:
mov
0xc(%r10),%r8d
; Load
inc
%r8d ; Increment
mov
%r8d,0xc(%r10)
; Store
lock //这里这里
addl $0x0,(%rsp)
; StoreLoad Barrier
??不用太担心看不懂,你只需要把目光聚焦在lock这个词上就好了。下面是lock的解释(如果觉得拗口可以跳过,摘自《IA-32卷3:系统编程指南》)
在修改内存操作时,LOCK前缀调用锁住的读-修改-写操作(原子的)。这个机制在多处理器系统中用于处理器之间进行可靠的通讯。在Pentium和早期的IA-32处理器中,LOCK前缀会使处理器在执行那些总是引起显式总线锁出现的指令时,检测LOCK#信号。在Pentium 4、Intel Xeon和P6系列处理器中,锁操作是通过一个Cache锁或总线锁来处理。如果内存访问是可以缓存的话,并且只影响一个单独的缓存线,那么就会调用缓存锁,系统总线和系统中内存中真正的内存位置在操作中不会被锁定。这里,其它的总线上的Pentium 4、Intel Xeon或者P6系列处理器回写所有的已修改数据并使它们的缓存无效,以保证系统内存的一致性。如果内存访问不能缓存且/或它跨越了缓存线的边界,那么这个处理器的LOCK#信号就会被检查并且处理器在上锁期间不会响应总线控制请求。RSM(从SMM返回)指令还原处理器(从上下文中)到系统管理模式(SMM)中断之前的状态。
??其实概括来讲,lock做了两件事,一是把当前处理器缓存的数据写回内存,二是写回内存的操作会引起其他cpu里缓存该地址的数据无效。举个例子来讲,有三个人,小明,小黑,小白在不同的教室,操场上有个LED显示器,LED显示器的数字是10,大家都把这个数字记在自己的笔记本上。小明打算把数字更改一下,他把笔记本上的数字改成了十一,在同步到LED显示板的过程中,他对小黑和小白大喊,你们的数字无效了,于是小黑和小白则把笔记本上的数字划掉,如果他们需要更改LED数字他们会去LED显示板上再看一看。如果你能够在上面的故事中找到内存、cpu缓存和cpu对应的事物并且能对应上lock做的两件事,你应该也理解了lock和volatile的可见性原理。关于volatile的可见性的问题就讲到这里,接下来我们将继续探讨volatile防止重排序。
重排序
??重排序其实是个很简单的问题,我们在写代码的时候虽然是一行一行有条不紊,字节码中编译的结果也是按照顺序条理清晰,可是cpu或jvm优化之后,在并发程序中可能会带来意想不到的惊喜。
??单例模式大家应该都比较清楚,让我们来看一个双重检测锁的单例模式。public class Singleton { //单例对象 private static Singleton instance = null; //私有化构造器,避免外部通过构造器构造对象 private Singleton(){} //这是静态工厂方法,用来产生对象 public static Singleton getInstance(){ if(instance ==null){ //同步锁防止多次new对象 synchronized (Singleton.class){ //锁内非空判断也是为了防止创建多个对象 if(instance == null){ instance = new Singleton(); } } } return instance; } }
??这段看似繁琐缜密的单例方法其实隐藏了一个无法控制的bug。为什么呢?
instance = new Singleton()并不是一步完成的,他被分为这几步:
??1.分配对象空间;
??2.初始化对象;
??3.设置instance指向刚刚分配的地址。
??按照这三个步骤执行,感觉这个单例模式也没有问题啊,如果你这样认为,其实你已经理解了百分之八十。剩下百分之二十就让我们回归到jvm指令重排,基于jvm优化,这三个指令可能会变成:
??1.分配对象空间
??3.设置instance指向刚刚分配的地址
??2.初始化对象
??如果按照这个顺序执行,你能想象到会带来怎么样的后果吗?(红先黄后)
??有可能会导致返回一个有地址的空对象,引起程序的异常,那有什么办法能防止重排序呢。肯定是今天的主角--volatile,解决办法就是将instance变量用volatile修饰。那么volatile怎么防止重排序的呢?让我们再看一下这段代码mov 0xc(%r10),%r8d ; Load inc %r8d ; Increment mov %r8d,0xc(%r10) ; Store lock addl $0x0,(%rsp) ; StoreLoad Barrier
??上面的StoreLoad Barrier就是叫做内存屏障的东西,简而言之就是防止了cpu乱序访问即防止了重排序,保证了程序的顺序执行。重排序是一个方面,在双重检测锁单例模式中是不是也利用了可见性的威力?我想答案已经在你心中了。在防止了重排序和保证可见性的情况下,程序稳定健壮地运行着。
??好了,对volatile关键字的击穿就到这里了。