“工做 5 年了,居然不知道 volatile 关键字!”html
听着刚面试完的架构师一顿吐槽,其余几个同事也都参与此次吐槽之中。java
都说国内的面试是“面试造航母,工做拧螺丝”,有时候你就会由于一个问题被PASS。c++
你工做几年了?知道 volatile 关键字吗?面试
今天就让咱们一块儿来学习一下 volatile 关键字,作一个在能够面试中造航母的螺丝工!编程
Java语言规范第三版中对 volatile 的定义以下: 数组
java编程语言容许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保经过排他锁单独得到这个变量。缓存
Java语言提供了 volatile,在某些状况下比锁更加方便。安全
若是一个字段被声明成 volatile,java线程内存模型确保全部线程看到这个变量的值是一致的。架构
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰以后,那么就具有了两层语义:并发
保证了不一样线程对这个变量进行操做时的可见性,即一个线程修改了某个变量的值,这新值对其余线程来讲是当即可见的。
若是 final 变量也被声明为 volatile,那么这就是编译时错误。
ps: 一个意思是变化可见,一个是永不变化。天然水火不容。
//线程1 boolean stop = false; while(!stop){ doSomething(); } //线程2 stop = true;
这段代码是很典型的一段代码,不少人在中断线程时可能都会采用这种标记办法。
可是事实上,这段代码会彻底运行正确么?即必定会将线程中断么?
不必定,也许在大多数时候,这个代码可以把线程中断,可是也有可能会致使没法中断线程(虽然这个可能性很小,可是只要一旦发生这种状况就会形成死循环了)。
下面解释一下这段代码为什么有可能致使没法中断线程。
在前面已经解释过,每一个线程在运行过程当中都有本身的工做内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在本身的工做内存当中。
那么当线程 2 更改了 stop 变量的值以后,可是还没来得及写入主存当中,线程 2 转去作其余事情了,
那么线程 1 因为不知道线程 2 对 stop 变量的更改,所以还会一直循环下去。
第一:使用 volatile 关键字会强制将修改的值当即写入主存;
第二:使用 volatile 关键字的话,当线程2进行修改时,会致使线程1的工做内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:因为线程1的工做内存中缓存变量 stop 的缓存行无效,因此线程 1 再次读取变量 stop 的值时会去主存读取。
那么在线程 2 修改 stop 值时(固然这里包括 2 个操做,修改线程 2 工做内存中的值,而后将修改后的值写入内存),
会使得线程 1 的工做内存中缓存变量 stop 的缓存行无效,而后线程 1 读取时,
发现本身的缓存行无效,它会等待缓存行对应的主存地址被更新以后,而后去对应的主存读取最新的值。
那么线程 1 读取到的就是最新的正确的值。
从上面知道 volatile 关键字保证了操做的可见性,可是 volatile 能保证对变量的操做是原子性吗?
public class VolatileAtomicTest { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final VolatileAtomicTest test = new VolatileAtomicTest(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { test.increase(); } }).start(); } //保证前面的线程都执行完 while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(test.inc); } }
你可能以为是 10000,可是实际是比这个数要小。
可能有的朋友就会有疑问,不对啊,上面是对变量 inc 进行自增操做,因为 volatile 保证了可见性,
那么在每一个线程中对inc自增完以后,在其余线程中都能看到修改后的值啊,因此有10个线程分别进行了 1000 次操做,那么最终inc的值应该是 1000*10=10000。
这里面就有一个误区了,volatile 关键字能保证可见性没有错,可是上面的程序错在没能保证原子性。
可见性只能保证每次读取的是最新的值,可是 volatile 没办法保证对变量的操做的原子性。
使用 Lock synchronized 或者 AtomicInteger
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 已经初始化完毕。
而 volatile 关键字在某些状况下性能要优于 synchronized,
可是要注意 volatile 关键字是没法替代 synchronized 关键字的,由于 volatile 关键字没法保证操做的原子性。
一般来讲,使用 volatile 必须具有如下2个条件:
对变量的写操做不依赖于当前值
实际上,这些条件代表,能够被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,个人理解就是上面的2个条件须要保证操做是原子性操做,才能保证使用volatile关键字的程序在并发时可以正确执行。
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
public class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; } }
在 JSR-133 以前的旧 Java 内存模型中,虽然不容许 volatile 变量之间重排序,但旧的 Java 内存模型容许 volatile 变量与普通变量之间重排序。
在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 } } }
时间线:-----------------------------------------------------------------> 线程 A:(2)写 volatile 变量; (1)修改共享变量 线程 B: (3)读取 volatile 变量; (4)读共享变量
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4相似)。
其结果就是:读线程B执行4时,不必定能看到写线程A在执行1时对共享变量的修改。
所以在旧的内存模型中 ,volatile 的写-读没有监视器的释放-获所具备的内存语义。
为了提供一种比监视器锁更轻量级的线程之间通讯的机制,
JSR-133专家组决定加强 volatile 的内存语义:
严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和监视器的释放-获取同样,具备相同的内存语义。
从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,
这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
术语 | 英文单词 | 描述 |
---|---|---|
共享变量 | Shared variables | 在多个线程之间可以被共享的变量被称为共享变量。共享变量包括全部的实例变量,静态变量和数组元素。他们都被存放在堆内存中,volatile 只做用于共享变量 |
内存屏障 | Memory Barriers | 是一组处理器指令,用于实现对内存操做的顺序限制 |
缓冲行 | Cache line | 缓存中能够分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,须要使用多个主内存读周期 |
原子操做 | Atomic operations | 不可中断的一个或一系列操做 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操做数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或全部) |
缓存命中 | cache hit | 若是进行高速缓存行填充操做的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操做数,而不是从内存 |
写命中 | write hit | 当处理器将操做数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,若是存在一个有效的缓存行,则处理器将这个操做数写回到缓存,而不是写回到内存,这个操做被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
那么 volatile 是如何来保证可见性的呢?
在 x86 处理器下经过工具获取 JIT 编译器生成的汇编指令来看看对 volatile 进行写操做 CPU 会作什么事情。
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# 信号确保在声言该信号期间,处理器能够独占使用任何共享内存。(由于它会锁住总线,致使其余CPU不能访问总线,不能访问总线就意味着不能访问系统内存),可是在最近的处理器里,LOCK#信号通常不锁总线,而是锁缓存,毕竟锁总线开销比较大。
在8.1.4章节有详细说明锁定操做对处理器缓存的影响,对于Intel486和Pentium处理器,在锁操做时,老是在总线上声言LOCK#信号。
但在P6和最近的处理器中,若是访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。
相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操做被称为“缓存锁定”,
缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。
IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其余处理器缓存的一致性。
在多核处理器系统中进行操做的时候,IA-32 和Intel 64处理器能嗅探其余处理器访问系统内存和它们的内部缓存。
它们使用嗅探技术保证它的内部缓存,系统内存和其余处理器的缓存的数据在总线上保持一致。
例如在Pentium和P6 family处理器中,若是经过嗅探一个处理器来检测其余处理器打算写内存地址,而这个地址当前处理共享状态,
那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。
著名的 Java 并发编程大师 Doug lea 在 JDK7 的并发包里新增一个队列集合类 LinkedTransferQueue
,
他在使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能。
追加字节能优化性能?这种方式看起来很神奇,但若是深刻理解处理器架构就能理解其中的奥秘。
让咱们先来看看 LinkedTransferQueue
这个类,
它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),
而这个内部类 PaddedAtomicReference 相对于父类 AtomicReference 只作了一件事情,就将共享变量追加到 64 字节。
咱们能够来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。
/** head of the queue */ private transient final PaddedAtomicReference < QNode > head; /** tail of the queue */ private transient final PaddedAtomicReference < QNode > tail; static final class PaddedAtomicReference < T > extends AtomicReference < T > { // enough padding for 64bytes with 4byte refs Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; PaddedAtomicReference(T r) { super(r); } } public class AtomicReference < V > implements java.io.Serializable { private volatile V value; //省略其余代码 }
由于对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着若是队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每一个处理器都会缓存一样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的做用下,会致使其余处理器不能访问本身高速缓存中的尾节点,而队列的入队和出队操做是须要不停修改头接点和尾节点,因此在多处理器的状况下将会严重影响到队列的入队和出队效率。
Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。
不是的。
在两种场景下不该该使用这种方式。
第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
第二:共享变量不会被频繁的写。
由于使用追加字节的方式须要处理器读取更多的字节到高速缓冲区,这自己就会带来必定的性能消耗,共享变量若是不被频繁写的话,锁的概率也很是小,就不必经过追加字节的方式来避免相互锁定。
ps: 突然以为术业想专攻,博学与睿智缺一不可。
Java虚拟机规范定义的许多规则中的一条:全部对基本类型的操做,除了某些对long类型和double类型的操做以外,都是原子级的。
目前的JVM(java虚拟机)都是将32位做为原子操做,并不是64位。
当线程把主存中的 long/double类型的值读到线程内存中时,多是两次32位值的写操做,显而易见,若是几个线程同时操做,那么就可能会出现高低2个32位值出错的状况发生。
要在线程间共享long与double字段时,必须在synchronized中操做,或是声明为volatile。
volatile 做为 JMM 中很是重要的一个关键字,基本也是面试高并发必问的知识点。
但愿本文对你的工做学习面试有所帮助,若是有其余想法的话,也能够评论区和你们分享哦。
各位极客的点赞收藏转发,是老马写做的最大动力!
更多精彩内容,能够 嶶ィ訁関註【老马啸西风】