volatile一般被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。和synchronized不一样,volatile是一个变量修饰符,只能用来修饰变量。没法修饰方法及代码块等。html
volatile的用法比较简单,只须要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就能够了。java
如如下代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton。c++
public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
为了提升处理器的执行速度,在处理器和内存之间增长了多级缓存来提高。可是因为引入了多级缓存,就存在缓存数据不一致问题。算法
可是,对于volatile变量,当对volatile变量进行写操做的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。编程
可是就算写回到内存,若是其余处理器缓存的值仍是旧的,再执行计算操做就会有问题,因此在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议缓存
缓存一致性协议:每一个处理器经过嗅探在总线上传播的数据来检查本身缓存的值是否是过时了,当处理器发现本身缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操做的时候,会强制从新从系统内存里把数据读处处理器缓存里。服务器
因此,若是一个变量被volatile所修饰的话,在每次数据变化以后,其值都会被强制刷入主存。而其余处理器的缓存因为遵照了缓存一致性协议,也会把这个变量的值从主存加载到本身的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。网络
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。数据结构
Java内存模型规定了全部的变量都存储在主内存中,每条线程还有本身的工做内存,线程的工做内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存。多线程
不一样的线程之间也没法直接访问对方工做内存中的变量,线程间变量的传递均须要本身的工做内存和主存之间进行数据同步进行。因此,就可能出现线程1改了某个变量的值,可是线程2不可见的状况。
前面的关于volatile的原理中介绍过了,Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后能够当即同步到主内存,被其修饰的变量在每次是用以前都从主内存刷新。所以,可使用volatile来保证多线程操做时变量的可见性。
有序性即程序执行的顺序按照代码的前后顺序执行。
除了引入了时间片之外,因为处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,好比load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。
而volatile除了能够保证数据的可见性以外,还有一个强大的功能,那就是他能够禁止指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程当中所依赖的赋值结果的地方都能得到正确的结果,而不能保证变量的赋值操做的顺序与程序代码中的执行顺序一致。
volatile能够禁止指令重排,这就保证了代码的程序会严格按照代码的前后顺序执行。这就保证了有序性。被volatile修饰的变量的操做,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。
volatile与原子性原子性是指一个操做是不可中断的,要所有执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不一样的调度算法进行线程调度。当一个线程得到时间片以后开始执行,在时间片耗尽以后,就会失去CPU使用权。因此在多线程场景下,因为时间片在线程间轮换,就会发生原子性问题。
为了保证原子性,须要经过字节码指令monitorenter和monitorexit,可是volatile和这两个指令之间是没有任何关系的。
因此,volatile是不能保证原子性的。
在如下两个场景中可使用volatile来代替synchronized:
除以上场景外,都须要使用其余方式来保证原子性,如synchronized或者concurrent包。
咱们来看一下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); } }
以上代码比较简单,就是建立10个线程,而后分别执行1000次i++操做。正常状况下,程序的输出结果应该是10000,可是,屡次执行的结果都小于10000。这其实就是volatile没法知足原子性的缘由。
为何会出现这种状况呢,那就是由于虽然volatile能够保证inc在多个线程之间的可见性。可是没法inc++的原子性。
咱们介绍过了volatile关键字和synchronized关键字。如今咱们知道,synchronized能够保证原子性、有序性和可见性。而volatile却只能保证有序性和可见性。
咱们知道volatile关键字的做用是保证变量在多线程之间的可见性,它是java.util.concurrent包的核心,没有volatile就没有这么多的并发类给咱们使用。
本文详细解读一下volatile关键字如何保证变量在多线程之间的可见性,在此以前,有必要讲解一下CPU缓存的相关知识,掌握这部分知识必定会让咱们更好地理解volatile的原理,从而更好、更正确地地使用volatile关键字。
一、CPU缓存
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,由于CPU运算速度要比内存读写速度快得多,举个例子:
这种访问速度的显著差别,致使CPU可能会花费很长时间等待数据到来或把数据写入内存。
基于此,如今CPU大多数状况下读写都不会直接访问内存(CPU都没有链接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多可是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短期内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
二级缓存:简称L2 Cache,份内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
三级缓存:简称L3 Cache,部分高端CPU才有
每一级缓存中所存储的数据所有都是下一级缓存中的一部分,这三种缓存的技术难度和制形成本是相对递减的,因此其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,若是没有再从二级缓存中查找,若是仍是没有再从三级缓存中或内存中查找。通常来讲每级缓存的命中率大概都有80%左右,也就是说所有数据量的80%均可以在一级缓存中找到,只剩下20%的总数据量才须要从二级缓存、三级缓存或内存中读取。
二、使用CPU缓存带来的问题
用一张图表示一下CPU-->CPU缓存-->主内存数据读取之间的关系:
当系统运行时,CPU执行计算的过程以下:
程序以及数据被加载到主内存指令和数据被加载到CPU缓存CPU执行指令,把结果写到高速缓存高速缓存中的数据写回主内存
若是服务器是单核CPU,那么这些步骤不会有任何的问题,可是若是服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深刻理解计算机系统》):
试想下面一种状况:
核0读取了一个字节,根据局部性原理,它相邻的字节一样被被读入核0的缓存核3作了上面一样的工做,这样核0与核3的缓存拥有一样的数据核0修改了那个字节,被修改后,那个字节被写回核0的缓存,可是该信息并无写回主存核3访问该字节,因为核0并未将数据写回主存,数据不一样步。
为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其余CPU会被通知,它们的缓存将视为无效。因而,在上面的状况下,核3发现本身的缓存中数据已无效,核0将当即把本身的数据写回主存,而后核3从新读取该数据。
三、反汇编Java字节码,查看汇编层面对volatile关键字作了什么
有了上面的理论基础,咱们能够研究volatile关键字究竟是如何实现的。首先写一段简单的代码:
/** * @author 五月的仓颉http://www.cnblogs.com/xrq730/p/7048693.html */ public class LazySingleton { private static volatile LazySingleton instance = null; public static LazySingleton getInstance() { if (instance == null) { instance = new LazySingleton(); } return instance; } public static void main(String[] args) { LazySingleton.getInstance(); } }
首先反编译一下这段代码的.class文件,看一下生成的字节码:
没有任何特别的。要知道,字节码指令,好比上图的getstatic、ifnonnull、new等,最终对应到操做系统的层面,都是转换为一条一条指令去执行,咱们使用的PC机、应用服务器的CPU架构一般都是IA-32架构的,这种架构采用的指令集是CISC(复杂指令集),而汇编语言则是这种指令集的助记符。
所以,既然在字节码层面咱们看不出什么端倪,那下面就看看将代码转换为汇编指令能看出什么端倪。
Windows上要看到以上代码对应的汇编码不难(吐槽一句,说说不难,为了这个问题我找遍了各类资料,差点就准备安装虚拟机,在Linux系统上搞了),访问hsdis工具路径可直接下载hsdis工具,下载完毕以后解压,将hsdis-amd64.dll与hsdis-amd64.lib两个文件放在%JAVA_HOME%jrebinserver路径下便可,以下图:
而后跑main函数,跑main函数以前,加入以下虚拟机参数:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance
这么长长的汇编代码,可能你们不知道CPU在哪里作了手脚,没事不难,定位到5九、60两行:
0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之因此定位到这两行是由于这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,后面详细分析一下lock指令的做用和为何加上lock指令后就能保证volatile关键字的内存可见性。
四、lock指令作了什么
以前有说过IA-32架构,关于CPU架构的问题你们有兴趣的能够本身查询一下,这里查询一下IA-32手册关于lock指令的描述,没有IA-32手册的能够去这个地址下载IA-32手册下载地址,是个中文版本的手册。
我摘抄一下IA-32手册中关于lock指令做用的一些描述(由于lock指令的做用在手册中散落在各处,并非在某一章或者某一节专门讲):
在修改内存操做时,使用LOCK前缀去调用加锁的读-修改-写操做,这种机制用于多处理器系统中处理器之间进行可靠的通信,具体描述以下:
(1)在Pentium和早期的IA-32处理器中,LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,这种老是引发显式总线锁定出现
(2)在Pentium四、Inter Xeon和P6系列处理器中,加锁操做是由高速缓存锁或总线锁来处理。若是内存访问有高速缓存且只影响一个单独的高速缓存行,那么操做中就会调用高速缓存锁,而系统总线和系统内存中的实际区域内不会被锁定。同时,这条总线上的其它Pentium四、Intel Xeon或者P6系列处理器就回写全部已修改的数据并使它们的高速缓存失效,以保证系统内存的一致性。若是内存访问没有高速缓存且/或它跨越了高速缓存行的边界,那么这个处理器就会产生LOCK#信号,并在锁定操做期间不会响应总线控制请求
32位IA-32处理器支持对系统内存中的某个区域进行加锁的原子操做。这些操做经常使用来管理共享的数据结构(如信号量、段描述符、系统段或页表),两个或多个处理器可能同时会修改这些数据结构中的同一数据域或标志。处理器使用三个相互依赖的机制来实现加锁的原子操做:
IA-32处理器提供有一个LOCK#信号,会在某些关键内存操做期间被自动激活,去锁定系统总线。当这个输出信号发出的时候,来自其余处理器或总线代理的控制请求将被阻塞。软件可以经过预先在指令前添加LOCK前缀来指定须要LOCK语义的其它场合。
在Intel38六、Intel48六、Pentium处理器中,明确地对指令加锁会致使LOCK#信号的产生。由硬件设计人员来保证系统硬件中LOCK#信号的可用性,以控制处理器间的内存访问。
对于Pentinum四、Intel Xeon以及P6系列处理器,若是被访问的内存区域是在处理器内部进行高速缓存的,那么一般不发出LOCK#信号;相反,加锁只应用于处理器的高速缓存。
为显式地强制执行LOCK语义,软件能够在下列指令修改内存区域时使用LOCK前缀。当LOCK前缀被置于其它指令以前或者指令没有对内存进行写操做(也就是说目标操做数在寄存器中)时,会产生一个非法操做码异常(#UD)。
【1】位测试和修改指令(BTS、BTR、BTC)
【2】交换指令(XADD、CMPXCHG、CMPXCHG8B)
【3】自动假设有LOCK前缀的XCHG指令
【4】下列单操做数的算数和逻辑指令:INC、DEC、NOT、NEG
【5】下列双操做数的算数和逻辑指令:ADD、ADC、SUB、SBB、AND、OR、XOR
一个加锁的指令会保证对目标操做数所在的内存区域加锁,可是系统可能会将锁定区域解释得稍大一些。软件应该使用相同的地址和操做数长度来访问信号量(用做处理器之间发送信号的共享内存)。
例如,若是一个处理器使用一个字来访问信号量,其它处理器就不该该使用一个字节来访问这个信号量。总线锁的完整性不收内存区域对齐的影响。加锁语义会一直持续,以知足更新整个操做数所需的总线周期个数。
可是,建议加锁访问应该对齐在它们的天然边界上,以提高系统性能:
【1】任何8位访问的边界(加锁或不加锁)
【2】锁定的字访问的16位边界
【3】锁定的双字访问的32位边界
【4】锁定的四字访问的64位边界
对全部其它的内存操做和全部可见的外部事件来讲,加锁的操做都是原子的。全部取指令和页表操做可以越过加锁的指令。加锁的指令可用于同步一个处理器写数据而另外一个处理器读数据的操做。
IA-32架构提供了几种机制用来强化或弱化内存排序模型,以处理特殊的编程情形。这些机制包括:
【1】I/O指令、加锁指令、LOCK前缀以及串行化指令等,强制在处理器上进行较强的排序
【2】SFENCE指令(在Pentium III中引入)和LFENCE指令、MFENCE指令(在Pentium4和Intel Xeon处理器中引入)提供了
某些特殊类型内存操做的排序和串行化功能
...(这里还有两条就不写了)
这些机制能够经过下面的方式使用。
总线上的内存映射设备和其它I/O设备一般对向它们缓冲区写操做的顺序很敏感,I/O指令(IN指令和OUT指令)如下面的方式对这种访问执行强写操做的排序。在执行了一条I/O指令以前,处理器等待以前的全部指令执行完毕以及全部的缓冲区都被都被写入了内存。只有取指令和页表查询可以越过I/O指令,后续指令要等到I/O指令执行完毕才开始执行。
反复思考IA-32手册对lock指令做用的这几段描述,能够得出lock指令的几个做用:
锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,由于锁总线的开销比较大,锁总线期间其余CPU无法访问内存lock后的写操做会回写已修改的数据,同时让其它CPU相关缓存行失效,从而从新从主存中加载最新的数据不是内存屏障却能完成相似内存屏障的功能,阻止屏障两遍的指令重排序
(1)中写了因为效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是经过缓存一致性协议来保证的,咱们来看一下什么是缓存一致性协议。
五、缓存一致性协议
讲缓存一致性以前,先说一下缓存行的概念:
缓存是分段(line)的,一个段对应一块存储空间,咱们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,一般来讲是64字节。
当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,若是没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理。
上面说了,LOCK#会锁总线,实际上这不现实,由于锁总线效率过低了。所以最好能作到:使用多组缓存,可是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了作到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,可是平常处理的大多数计算机设备都属于"嗅探(snooping)"协议,它的基本思想是:
全部内存的传输都发生在一条共享的总线上,而全部的处理器都能看到这条总线:缓存自己是独立的,可是内存是共享资源,全部的内存访问都要通过仲裁(同一个指令周期中,只有一个CPU缓存能够读写内存)。
CPU缓存不只仅在作内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其余缓存在作什么。因此当一个缓存表明它所属的处理器去读写内存时,其它处理器都会获得通知,它们以此来使本身的缓存保持同步。只要某个处理器一写内存,其它处理器立刻知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每一个缓存行有4个状态,可用2个bit表示,它们分别是:
这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。因此这里新的知识点只有E状态,表明独占式访问,这个状态解决了"在咱们开始修改某块内存以前,咱们须要告诉其它处理器"这一问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。
当处理器想写某个缓存行时,若是它没有独占权,它必须先发送一条"我要独占权"的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(若是有)。只有在得到独占权后,处理器才能开始修改数据----而且此时这个处理器知道,这个缓存行只有一份拷贝,在我本身的缓存里,因此不会有任何冲突。
反之,若是有其它处理器想读取这个缓存行(立刻能知道,由于一直在嗅探总线),独占或已修改的缓存行必须先回到"共享"状态。若是是已修改的缓存行,那么还要先把内容回写到内存中。
六、由lock指令回看volatile变量读写
相信有了上面对于lock的解释,volatile关键字的实现原理应该是一目了然了。首先看一张图:
工做内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每一个线程的工做内存也能够简单理解为CPU寄存器和高速缓存。
那么当写两条线程Thread-A与Threab-B同时操做主存中的一个volatile变量i时,Thread-A写了变量i,那么:
Thread-A发出LOCK#指令发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效Thread-A向主存回写最新修改的i。
Thread-B读取变量i,那么:
Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值。
由此能够看出,volatile关键字的读和普通变量的读取相比基本没差异,差异主要仍是在变量的写操做上。
文源网络,仅供学习之用,若有侵权,联系删除。