目录:html
《Java并发编程之三:volatile关键字解析 转载》java
volatile这个关键字可能不少朋友都据说过,或许也都用过。在Java 5以前,它是一个备受争议的关键字,由于在程序中使用它每每会致使出人意料的结果。在Java 5以后,volatile关键字才得以重获生机。设计模式
volatile关键字虽然从字面上理解起来比较简单,可是要用好不是一件容易的事情。因为volatile关键字是与Java的内存模型有关的,所以在讲述volatile关键以前,咱们先来了解一下与内存模型相关的概念和知识,而后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。数组
如下是本文的目录大纲:缓存
一.内存模型的相关概念安全
二.并发编程中的三个概念多线程
三.Java内存模型架构
四..深刻剖析volatile关键字并发
五.使用volatile关键字的场景
你们都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程当中,势必涉及到数据的读取和写入。因为程序运行过程当中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,因为CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,所以若是任什么时候候对数据的操做都要经过和内存的交互来进行,会大大下降指令执行的速度。所以在CPU里面就有了高速缓存。
也就是,当程序在运行过程当中,会将运算须要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就能够直接从它的高速缓存读取数据和向其中写入数据,当运算结束以后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,好比下面的这段代码:
1
|
i = i +
1
;
|
当线程执行这个语句时,会先从主存当中读取i的值,而后复制一份到高速缓存当中,而后CPU执行指令对i进行加1操做,而后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,可是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不一样的CPU中,所以每一个线程运行时有本身的高速缓存(对单核CPU来讲,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文咱们以多核CPU为例。
好比同时有2个线程执行这段代码,假如初始时i的值为0,那么咱们但愿两个线程执行完以后i的值变为2。可是事实会是这样吗?
可能存在下面一种状况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,而后线程1进行加1操做,而后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值仍是0,进行加1操做以后,i的值为1,而后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。一般称这种被多个线程访问的变量为共享变量。
也就是说,若是一个变量在多个CPU中都存在缓存(通常在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,一般来讲有如下2种解决方法:
1)经过在总线加LOCK#锁的方式
2)经过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是经过在总线上加LOCK#锁的形式来解决缓存不一致的问题。由于CPU和其余部件进行通讯都是经过总线来进行的,若是对总线加LOCK#锁的话,也就是说阻塞了其余CPU对其余部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。好比上面例子中 若是一个线程在执行 i = i +1,若是在执行这段代码的过程当中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码彻底执行完毕以后,其余CPU才能从变量i所在的内存读取变量,而后进行相应的操做。这样就解决了缓存不一致的问题。
可是上面的方式会有一个问题,因为在锁住总线期间,其余CPU没法访问内存,致使效率低下。
因此就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每一个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,若是发现操做的变量是共享变量,即在其余CPU中也存在该变量的副本,会发出信号通知其余CPU将该变量的缓存行置为无效状态,所以当其余CPU须要读取这个变量时,发现本身缓存中缓存该变量的缓存行是无效的,那么它就会从内存从新读取。
在并发编程中,咱们一般会遇到如下三个问题:原子性问题,可见性问题,有序性问题。咱们先看具体看一下这三个概念:
1.原子性
原子性:即一个操做或者多个操做 要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行帐户转帐问题:
好比从帐户A向帐户B转1000元,那么必然包括2个操做:从帐户A减去1000元,往帐户B加上1000元。
试想一下,若是这2个操做不具有原子性,会形成什么样的后果。假如从帐户A减去1000元以后,操做忽然停止。而后又从B取出了500元,取出500元以后,再执行 往帐户B加上1000元 的操做。这样就会致使帐户A虽然减去了1000元,可是帐户B没有收到这个转过来的1000元。
因此这2个操做必需要具有原子性才能保证不出现一些意外的问题。
一样地反映到并发编程中会出现什么结果呢?
举个最简单的例子,你们想一下假如为一个32位的变量赋值过程不具有原子性的话,会发生什么后果?
1
|
i =
9
;
|
倘若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种状况:当将低16位数值写入以后,忽然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。
举个简单的例子,看下面这段代码:
1
2
3
4
5
6
|
//线程1执行的代码
int
i =
0
;
i =
10
;
//线程2执行的代码
j = i;
|
倘若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,而后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有当即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值仍是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了以后,线程2没有当即看到线程1修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的前后顺序执行。举个简单的例子,看下面这段代码:
1
2
3
4
|
int
i =
0
;
boolean
flag =
false
;
i =
1
;
//语句1
flag =
true
;
//语句2
|
上面代码定义了一个int型变量,定义了一个boolean类型变量,而后分别对两个变量进行赋值操做。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1必定会在语句2前面执行吗?不必定,为何呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,通常来讲,处理器为了提升程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行前后顺序同代码中的顺序一致,可是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
好比上面的代码中,语句1和语句2谁先执行对最终的程序结果并无影响,那么就有可能在执行过程当中,语句2先执行而语句1后执行。
可是要注意,虽然处理器会对指令进行重排序,可是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
1
2
3
4
|
int
a =
10
;
//语句1
int
r =
2
;
//语句2
a = a +
3
;
//语句3
r = a*a;
//语句4
|
这段代码有4个语句,那么可能的一个执行顺序是:
那么可不多是这个执行顺序呢: 语句2 语句1 语句4 语句3
不可能,由于处理器在进行重排序时是会考虑指令之间的数据依赖性,若是一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2以前执行。
虽然重排序不会影响单个线程内程序执行的结果,可是多线程呢?下面看一个例子:
1
2
3
4
5
6
7
8
9
|
//线程1:
context = loadContext();
//语句1
inited =
true
;
//语句2
//线程2:
while
(!inited ){
sleep()
}
doSomethingwithconfig(context);
|
上面代码中,因为语句1和语句2没有数据依赖性,所以可能会被重排序。假如发生了重排序,在线程1执行过程当中先执行语句2,而此是线程2会觉得初始化工做已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并无被初始化,就会致使程序出错。
从上面能够看出,指令重排序不会影响单个线程的执行,可是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必需要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会致使程序运行不正确。
在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面咱们来看一下Java内存模型,研究一下Java内存模型为咱们提供了哪些保证以及在java中提供了哪些方法和机制来让咱们在进行多线程编程时可以保证程序执行的正确性。
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操做系统的内存访问差别,以实现让Java程序在各类平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了得到较好的执行性能,Java内存模型并无限制执行引擎使用处理器的寄存器或者高速缓存来提高指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定全部的变量都是存在主存当中(相似于前面说的物理内存),每一个线程都有本身的工做内存(相似于前面的高速缓存)。线程对变量的全部操做都必须在工做内存中进行,而不能直接对主存进行操做。而且每一个线程不能访问其余线程的工做内存。
举个简单的例子:在java中,执行下面这个语句:
1
|
i =
10
;
|
执行线程必须先在本身的工做线程中对变量i所在的缓存行进行赋值操做,而后再写入主存当中。而不是直接将数值10写入主存当中。
那么Java语言 自己对 原子性、可见性以及有序性提供了哪些保证呢?
1.原子性
在Java中,对基本数据类型的变量的读取和赋值操做是原子性操做,即这些操做是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,可是理解起来并非那么容易。看下面一个例子i:
请分析如下哪些操做是原子性操做:
1
2
3
4
|
x =
10
;
//语句1
y = x;
//语句2
x++;
//语句3
x = x +
1
;
//语句4
|
咋一看,有些朋友可能会说上面的4个语句中的操做都是原子性操做。其实只有语句1是原子性操做,其余三个语句都不是原子性操做。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工做内存中。
语句2实际上包含2个操做,它先要去读取x的值,再将x的值写入工做内存,虽然读取x的值以及 将x的值写入工做内存 这2个操做都是原子性操做,可是合起来就不是原子性操做了。
一样的,x++和 x = x+1包括3个操做:读取x的值,进行加1操做,写入新的值。
因此上面4个语句只有语句1的操做具有原子性。
也就是说,只有简单的读取、赋值(并且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操做)才是原子操做。
不过这里有一点须要注意:在32位平台下,对64位数据的读取和赋值是须要经过两个操做来完成的,不能保证其原子性。可是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操做了。
从上面能够看出,Java内存模型只保证了基本读取和赋值是原子性操做,若是要实现更大范围操做的原子性,能够经过synchronized和Lock来实现。因为synchronized和Lock可以保证任一时刻只有一个线程执行该代码块,那么天然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会当即被更新到主存,当有其余线程须要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,由于普通共享变量被修改以后,何时被写入主存是不肯定的,当其余线程去读取时,此时内存中可能仍是原来的旧值,所以没法保证可见性。
另外,经过synchronized、Lock和final都能保证可见性,即同一时刻只有一个线程获取锁而后执行同步代码,同步块的可见性是由“对一个变量执行unlock操做以前,必须先把此变量同步回主内存中(执行store、write操做)”这条规则得到的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,而且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其余线程有可能经过这个引用访问到“初始化了一半”的对象),那在其余线程中就能看见final字段的值。如代码清单12-7所示,变量i与j都具有可见性,它们无须同步就能被其余线程正确访问。
清单12-7
public static final int i; public final int j; static{ i=0; //do something }{//也能够选择在构造函数中初始化 j=0; //do something }
3.有序性
在Java内存模型中,容许编译器和处理器对指令进行重排序,可是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,能够经过volatile关键字来保证必定的“有序性”(具体原理在下一节讲述)。另外也能够经过synchronized和Lock来保证有序性,很显然,volatile关键字自己就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只容许一条线程对其进行lock操做”这条规则得到的,这条规则决定了持有同一个锁的两个同
步块只能串行地进入。
另外,Java内存模型具有一些先天的“有序性”,即不须要经过任何手段就可以获得保证的有序性,这个一般也称为 happens-before 原则。若是两个操做的执行次序没法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机能够随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
这8条原则摘自《深刻理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面咱们来解释一下前4条规则:
对于程序次序规则来讲,个人理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操做先行发生于书写在后面的操做”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,由于虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,可是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。所以,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但没法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说不管在单线程中仍是多线程中,同一个锁若是出于被锁定的状态,那么必须先对锁进行了释放操做,后面才能继续进行lock操做。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,若是一个线程先去写一个变量,而后一个线程去进行读取,那么写入操做确定会先行发生于读操做。
第四条规则实际上就是体现happens-before原则具有传递性。
在前面讲述了不少东西,其实都是为讲述volatile关键字做铺垫,那么接下来咱们就进入主题。
1.volatile关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰以后,那么就具有了两层语义:
1)保证了不一样线程对这个变量进行操做时的可见性,即一个线程修改了某个变量的值,这新值对其余线程来讲是当即可见的。
2)禁止进行指令重排序。
先看一段代码,假如线程1先执行,线程2后执行:
//线程1 boolean stop = false; while(!stop){ doSomething(); } //线程2 stop = true;
这段代码是很典型的一段代码,不少人在中断线程时可能都会采用这种标记办法。可是事实上,这段代码会彻底运行正确么?即必定会将线程中断么?不必定,也许在大多数时候,这个代码可以把线程中断,可是也有可能会致使没法中断线程(虽然这个可能性很小,可是只要一旦发生这种状况就会形成死循环了)。
下面解释一下这段代码为什么有可能致使没法中断线程。在前面已经解释过,每一个线程在运行过程当中都有本身的工做内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在本身的工做内存当中。
那么当线程2更改了stop变量的值以后,可是还没来得及写入主存当中,线程2转去作其余事情了,那么线程1因为不知道线程2对stop变量的更改,所以还会一直循环下去。
可是用volatile修饰以后就变得不同了:
第一:使用volatile关键字会强制将修改的值当即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会致使线程1的工做内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:因为线程1的工做内存中缓存变量stop的缓存行无效,因此线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(固然这里包括2个操做,修改线程2工做内存中的值,而后将修改后的值写入内存),会使得线程1的工做内存中缓存变量stop的缓存行无效,而后线程1读取时,发现本身的缓存行无效,它会等待缓存行对应的主存地址被更新以后,而后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
2.volatile保证原子性吗?
从上面知道volatile关键字保证了操做的可见性,可是volatile能保证对变量的操做是原子性吗?
下面看一个例子:
public class Test { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } }
你们想一下这段程序的输出结果是多少?也许有些朋友认为是10000。可是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。
可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操做,因为volatile保证了可见性,那么在每一个线程中对inc自增完以后,在其余线程中都能看到修改后的值啊,因此有10个线程分别进行了1000次操做,那么最终inc的值应该是1000*10=10000。
这里面就有一个误区了,volatile关键字能保证可见性没有错,可是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,可是volatile没办法保证对变量的操做的原子性。
在前面已经提到过,自增操做是不具有原子性的,它包括读取变量的原始值、进行加1操做、写入工做内存。那么就是说自增操做的三个子操做可能会分割开执行,就有可能致使下面这种状况出现:
假如某个时刻变量inc的值为10,
线程1对变量进行自增操做,线程1先读取了变量inc的原始值,而后线程1被阻塞了;
而后线程2对变量进行自增操做,线程2也去读取变量inc的原始值,因为线程1只是对变量inc进行读取操做,而没有对变量进行修改操做,因此不会致使线程2的工做内存中缓存变量inc的缓存行无效,因此线程2会直接去主存读取inc的值,发现inc的值时10,而后进行加1操做,并把11写入工做内存,最后写入主存。
而后线程1接着进行加1操做,因为已经读取了inc的值,注意此时在线程1的工做内存中inc的值仍然为10,因此线程1对inc进行加1操做后inc的值为11,而后将11写入工做内存,最后写入主存。
那么两个线程分别进行了一次自增操做后,inc只增长了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?而后其余线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,可是要注意,线程1对变量进行读取操做以后,被阻塞了的话,并无对inc值进行修改。而后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,可是线程1没有进行修改,因此线程2根本就不会看到修改的值。
根源就在这里,自增操做不是原子性操做,并且volatile也没法保证对变量的任何操做都是原子性的。
把上面的代码改为如下任何一种均可以达到效果:
采用synchronized:
public class Test { public int inc = 0; public synchronized void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } }
采用Lock:
public class Test { public int inc = 0; Lock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally{ lock.unlock(); } } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } }
采用AtomicInteger:
public class Test { public AtomicInteger inc = new AtomicInteger(); public void increase() { inc.getAndIncrement(); } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } }
在java 1.5的java.util.concurrent.atomic包下提供了一些原子操做类,即对基本数据类型的 自增(加1操做),自减(减1操做)、以及加法操做(加一个数),减法操做(减一个数)进行了封装,保证这些操做是原子性操做。atomic是利用CAS来实现原子性操做的(Compare And Swap),CAS其实是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操做。
3.volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,因此volatile能在必定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操做或者写操做时,在其前面的操做的更改确定所有已经进行,且结果已经对后面的操做可见;在其后面的操做确定尚未进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量 //flag为volatile变量 x = 2; //语句1 y = 0; //语句2 flag = true; //语句3 x = 4; //语句4 y = -1; //语句5
因为flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句一、语句2前面,也不会讲语句3放到语句四、语句5后面。可是要注意语句1和语句2的顺序、语句4和语句5的顺序是不做任何保证的。
而且volatile关键字能保证,执行到语句3时,语句1和语句2一定是执行完毕了的,且语句1和语句2的执行结果对语句三、语句四、语句5是可见的。
那么咱们回到前面举的一个例子:
//线程1: context = loadContext(); //语句1 inited = true; //语句2 //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1以前执行,那么久可能致使context还没被初始化,而线程2中就使用未初始化的context去进行操做,致使程序出错。
这里若是用volatile关键字对inited变量进行修饰,就不会出现这种问题了,由于当执行到语句2时,一定能保证context已经初始化完毕。
4.volatile的原理和实现机制
前面讲述了源于volatile关键字的一些使用,下面咱们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
下面这段话摘自《深刻理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上至关于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障以前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操做已经所有完成;
2)它会强制将对缓存的修改操做当即写入主存;
3)若是是写操做,它会致使其余CPU中对应的缓存行无效。
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些状况下性能要优于synchronized,可是要注意volatile关键字是没法替代synchronized关键字的,由于volatile关键字没法保证操做的原子性。一般来讲,使用volatile必须具有如下2个条件:
1)对变量的写操做不依赖于当前值
2)该变量没有包含在具备其余变量的不变式中
实际上,这些条件代表,能够被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,个人理解就是上面的2个条件须要保证操做是原子性操做,才能保证使用volatile关键字的程序在并发时可以正确执行。
第一个条件的限制使 volatile 变量不能用做线程安全计数器。虽然增量操做(x++
)看上去相似一个单独操做,实际上它是一个由(读取-修改-写入)操做序列组成的组合操做,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操做须要使x
的值在操做期间保持不变,而 volatile 变量没法实现这点。(然而,若是只从单个线程写入,那么能够忽略第一个条件。)
1.状态标记量(模式 #1:状态标志)
这种类型的状态标记的一个公共特性是:一般只有一种状态转换;
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
示例2
volatile boolean inited = false; //线程1: context = loadContext(); inited = true; //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
示例3
shutdownRequested
标志从false
转换为true
,而后程序中止。这种模式能够扩展到来回转换的状态标志,可是只有在转换周期不被察觉的状况下才能扩展(从false
到true
,再转换到false
)。此外,还须要某些原子状态转换机制,例如原子变量。
也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
volatile boolean shutdownRequested; ... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } }
线程1执行doWork()的过程当中,可能有另外的线程2调用了shutdown,因此boolean变量必须是volatile。
而若是使用 synchronized
块编写循环要比使用 volatile 状态标志编写麻烦不少。因为 volatile 简化了编码,而且状态标志并不依赖于程序内任何其余状态,所以此处很是适合使用 volatile。
2.double check(一次性安全发布(one-time safe publication))
在缺少同步的状况下,可能会遇到某个对象引用的更新值(由另外一个线程写入)和该对象状态的旧值同时存在。
这就是形成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的状况下进行读操做,产生的问题是您可能会看到一个更新的引用,可是仍然会经过该引用看到不彻底构造的对象。参见:【设计模式】5. 单例模式(以及多线程、无序写入、volatile对单例的影响)
若是不用volatile,则由于内存模型容许所谓的“无序写入”,可能致使失败。——某个线程可能会得到一个未彻底初始化的实例。
1 class Singleton{ 2 private volatile static Singleton instance = null; 3 4 private Singleton() { 5 6 } 7 8 public static Singleton getInstance() { 9 if(instance==null) { 10 synchronized (Singleton.class) { 11 if(instance==null) 12 instance = new Singleton(); 13 } 14 } 15 return instance; 16 } 17 }
考察上述代码中的 //3 行。此行代码建立了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在Singleton 构造函数体执行以前,变量instance 可能成为非 null 的!
什么?这一说法可能让您始料未及,但事实确实如此。
在解释这个现象如何发生前,请先暂时接受这一事实,咱们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行如下事件序列:
安全使用 volatile 的另外一种简单模式是:按期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器可以感受环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。而后,其余线程能够读取这个变量,从而随时可以看到最新的温度值。
使用该模式的另外一种应用程序就是收集程序的统计信息。【例】以下代码展现了身份验证机制如何记忆最近一次登陆的用户的名字。将反复使用lastUser
引用来发布值,以供程序的其余部分使用。
public class UserManager { public volatile String lastUser; //发布的信息 public boolean authenticate(String user, String password) { boolean valid = passwordIsValid(user, password); if (valid) { User u = new User(); activeUsers.add(u); lastUser = user; } return valid; } }
volatile bean 模式的基本原理是:不少框架为易变数据的持有者(例如 HttpSession
)提供了容器,可是放入这些容器中的对象必须是线程安全的。
在 volatile bean 模式中,JavaBean 的全部数据成员都是 volatile 类型的,而且 getter 和 setter 方法必须很是普通——即不包含约束!
@ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } }
若是读操做远远超过写操做,您能够结合使用内部锁和 volatile 变量来减小公共代码路径的开销。
以下显示的线程安全的计数器,使用 synchronized
确保增量操做是原子的,并使用 volatile
保证当前结果的可见性。若是更新不频繁的话,该方法可实现更好的性能,由于读路径的开销仅仅涉及 volatile 读操做,这一般要优于一个无竞争的锁获取的开销。
@ThreadSafe public class CheesyCounter { // Employs the cheap read-write lock trick // All mutative operations MUST be done with the 'this' lock held @GuardedBy("this") private volatile int value; //读操做,没有synchronized,提升性能 public int getValue() { return value; } //写操做,必须synchronized。由于x++不是原子操做 public synchronized int increment() { return value++; }
}
使用锁进行全部变化的操做,使用 volatile 进行只读操做。
其中,锁一次只容许一个线程访问值,volatile 容许多个线程执行读操做
大多数编程情形都会与这两个条件的其中之一冲突,使得 volatile 变量不能像 synchronized
那样广泛适用于实现线程安全。
【反例:volatile变量不能用于约束条件中】 下面是一个非线程安全的数值范围类。它包含了一个不变式 —— 下界老是小于或等于上界。
将 lower
和 upper 字段定义为 volatile 类型不可以充分实现类的线程安全;而仍然须要使用同步——使setLower()
和 setUpper()
操做原子化。
不然,若是凑巧两个线程在同一时间使用不一致的值执行 setLower
和 setUpper
的话,则会使范围处于不一致的状态。例如,若是初始状态是(0, 5)
,同一时间内,线程 A 调用setLower(4)
而且线程 B 调用setUpper(3)
,显然这两个操做交叉存入的值是不符合条件的,那么两个线程都会经过用于保护不变式的检查,使得最后的范围值是(4, 3)
—— 一个无效值。
在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。
它在某些状况下比synchronized的开销更小,本文将深刻分析在硬件层面上Inter处理器是如何实现Volatile的,经过深刻分析能帮助咱们正确的使用Volatile变量。
术语 |
英文单词 |
描述 |
共享变量 |
在多个线程之间可以被共享的变量被称为共享变量。共享变量包括全部的实例变量,静态变量和数组元素。他们都被存放在堆内存中,Volatile只做用于共享变量。 |
|
内存屏障 |
Memory Barriers |
是一组处理器指令,用于实现对内存操做的顺序限制。 |
缓冲行 |
Cache line |
缓存中能够分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,须要使用多个主内存读周期。 |
原子操做 |
Atomic operations |
不可中断的一个或一系列操做。 |
缓存行填充 |
cache line fill |
当处理器识别到从内存中读取操做数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或全部) |
缓存命中 |
cache hit |
若是进行高速缓存行填充操做的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操做数,而不是从内存。 |
写命中 |
write hit |
当处理器将操做数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,若是存在一个有效的缓存行,则处理器将这个操做数写回到缓存,而不是写回到内存,这个操做被称为写命中。 |
写缺失 |
write misses the cache |
一个有效的缓存行被写入到不存在的内存区域。 |
Java语言规范第三版中对volatile的定义以下: java编程语言容许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保经过排他锁单独得到这个变量。Java语言提供了volatile,在某些状况下比锁更加方便。若是一个字段被声明成volatile,java线程内存模型确保全部线程看到这个变量的值是一致的。
Volatile变量修饰符若是使用恰当的话,它比synchronized的使用和执行成本会更低,由于它不会引发线程上下文的切换和调度。
那么Volatile是如何来保证可见性的呢?在x86处理器下经过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操做CPU会作什么事情。
Java代码: |
instance = new Singleton();//instance是volatile变量 |
汇编代码: |
0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: lock addl $0x0,(%esp); |
有volatile变量修饰的共享变量进行写操做的时候会多第二行汇编代码,经过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引起了两件事情。
处理器为了提升处理速度,不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其余)后再进行操做,但操做完以后不知道什么时候会写到内存,若是对声明了Volatile变量进行写操做,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。可是就算写回到内存,若是其余处理器缓存的值仍是旧的,再执行计算操做就会有问题,因此在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每一个处理器经过嗅探在总线上传播的数据来检查本身缓存的值是否是过时了,当处理器发现本身缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操做的时候,会强制从新从系统内存里把数据读处处理器缓存里。
这两件事情在IA-32软件开发者架构手册的第三册的多处理器管理章节(第八章)中有详细阐述。
Lock前缀指令会引发处理器缓存回写到内存。Lock前缀指令致使在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器能够独占使用任何共享内存。(由于它会锁住总线,致使其余CPU不能访问总线,不能访问总线就意味着不能访问系统内存),可是在最近的处理器里,LOCK#信号通常不锁总线,而是锁缓存,毕竟锁总线开销比较大。在8.1.4章节有详细说明锁定操做对处理器缓存的影响,对于Intel486和Pentium处理器,在锁操做时,老是在总线上声言LOCK#信号。但在P6和最近的处理器中,若是访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操做被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会致使其余处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其余处理器缓存的一致性。在多核处理器系统中进行操做的时候,IA-32 和Intel 64处理器能嗅探其余处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其余处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,若是经过嗅探一个处理器来检测其余处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。
转自:http://www.cnblogs.com/dolphin0520/p/3920373.html