volatile是Java程序员必备的基础,也是面试官很是喜欢问的一个话题,本文跟你们一块儿开启volatile学习之旅,若是有不正确的地方,也麻烦你们指出哈,一块儿相互学习~html
github 地址java
https://github.com/whx123/Jav...
volatile关键字是Java虚拟机提供的的最轻量级的同步机制,它做为一个修饰符出现,用来修饰变量,可是这里不包括局部变量哦。咱们来看个demo吧,代码以下:git
/** * @Author 捡田螺的小男孩 * @Date 2020/08/02 * @Desc volatile的可见性探索 */ public class VolatileTest { public static void main(String[] args) throws InterruptedException { Task task = new Task(); Thread t1 = new Thread(task, "线程t1"); Thread t2 = new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1000); System.out.println("开始通知线程中止"); task.stop = true; //修改stop变量值。 } catch (InterruptedException e) { e.printStackTrace(); } } }, "线程t2"); t1.start(); //开启线程t1 t2.start(); //开启线程t2 Thread.sleep(1000); } } class Task implements Runnable { boolean stop = false; int i = 0; @Override public void run() { long s = System.currentTimeMillis(); while (!stop) { i++; } System.out.println("线程退出" + (System.currentTimeMillis() - s)); } }
运行结果:
能够发现线程t2,虽然把stop设置为true了,可是线程t1对t2的stop变量视而不可见,所以,它一直在死循环running中。若是给变量stop加上volatile修饰,线程t1是能够停下来的,运行结果以下:程序员
volatile boolean stop = false;
从以上例子,咱们能够发现变量stop,加了vlatile修饰以后,线程t1对stop就可见了。其实,vlatile的做用就是:保证变量对全部线程可见性。固然,vlatile还有个做用就是,禁止指令重排,可是它不保证原子性。github
因此当面试官问你volatile的做用或者特性,均可以这么回答:面试
为了更好理解volatile,先回顾一下计算机的内存模型与JMM(Java内存模型)吧~数据库
计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。编程
因为计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,而后等主内存慢悠悠存取数据吧,
因此现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),以做为来做为内存与处理器之间的缓冲。缓存
在多路处理器系统中,每一个处理器都有本身的高速缓存,而它们共享同一主内存。计算机抽象内存模型以下:多线程
随着科学技术的发展,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3);
当多个处理器的运算任务都涉及同一块主内存区域,可能致使缓存数据不一致问题。如何解决这个问题呢?有两种方案
- 一、经过在总线加LOCK#锁的方式。
- 二、经过缓存一致性协议(Cache Coherence Protocol)
总线(Bus)是计算机各类功能部件之间传送信息的公共通讯干线,它是由导线组成的传输线束, 按照计算机所传输的信息种类,计算机的总线能够划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。
CPU和其余功能部件是经过总线通讯的,若是在总线加LOCK#锁,那么在锁住总线期间,其余CPU是没法访问内存,这样一来,效率就比较低了。
为了解决一致性问题,还能够经过缓存一致性协议。即各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操做,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比较著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)协议,它的核心思想是:
当CPU写数据时,若是发现操做的变量是共享变量,即在其余CPU中也存在该变量的副本,会发出信号通知其余CPU将该变量的缓存行置为无效状态,所以当其余CPU须要读取这个变量时,发现本身缓存中缓存该变量的缓存行是无效的,那么它就会从内存从新读取。
CPU中每一个缓存行标记的4种状态(M、E、S、I),也了解一下吧:
缓存状态 | 描述 |
---|---|
M,被修改(Modified) | 该缓存行只被该CPU缓存,与主存的值不一样,会在它被其余CPU读取以前写入内存,并设置为Shared |
E,独享的(Exclusive) | 该缓存行只被该CPU缓存,与主存的值相同,被其余CPU读取时置为Shared,被其余CPU写时置为Modified |
S,共享的(Shared) | 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同 |
I,无效的(Invalid) | 该缓存行数据是无效,须要时需从新从主存载入 |
MESI协议是如何实现的?如何保证当前处理器的内部缓存、主内存和其余处理器的缓存数据在总线上保持一致的?多处理器总线嗅探
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每一个处理器经过嗅探在总线上传播的数据来检查本身的缓存值是否是过时了,若是处理器发现本身缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操做的时候,会从新从系统内存中把数据库读处处理器缓存中。
举个例子吧,假设i的初始值是0,执行如下语句:
i = i+1;
首先,执行线程t1从主内存中读取到i=0,到工做内存。而后在工做内存中,赋值i+1,工做内存就获得i=1,最后把结果写回主内存。所以,若是是单线程的话,该语句执行是没问题的。可是呢,线程t2的本地工做内存还没过时,那么它读到的数据就是脏数据了。如图:
Java内存模型是围绕着如何在并发过程当中如何处理原子性、可见性和有序性这3个特征来创建的,咱们再来一块儿回顾一下~
原子性,指操做是不可中断的,要么执行完成,要么不执行,基本数据类型的访问和读写都是具备原子性,固然(long和double的非原子性协定除外)。咱们来看几个小例子:
i =666; // 语句1 i = j; // 语句2 i = i+1; //语句 3 i++; // 语句4
Java虚拟机这样描述Java程序的有序性的:若是在本线程内观察,全部的操做都是有序的;若是在一个线程中,观察另外一个线程,全部的操做都是无序的。
后半句意思就是,在Java内存模型中,容许编译器和处理器对指令进行重排序,会影响到多线程并发执行的正确性;前半句意思就是as-if-serial的语义,即无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不会被改变。
好比如下程序代码:
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
步骤C依赖于步骤A和B,由于指令重排的存在,程序执行顺讯多是A->B->C,也多是B->A->C,可是C不能在A或者B前面执行,这将违反as-if-serial语义。
看段代码吧,假设程序先执行read方法,再执行add方法,结果必定是输出sum=2嘛?
bool flag = false; int b = 0; public void read() { b = 1; //1 flag = true; //2 } public void add() { if (flag) { //3 int sum =b+b; //4 System.out.println("bb sum is"+sum); } }
若是是单线程,结果应该没问题,若是是多线程,线程t1对步骤1和2进行了指令重排序呢?结果sum就不是2了,而是0,以下图所示:
这是为啥呢?指令重排序了解一下,指令重排是指在程序执行过程当中,为了提升性能, 编译器和CPU可能会对指令进行从新排序。CPU重排序包括指令并行重排序和内存系统重排序,重排序类型和重排序执行过程以下:
实际上,能够给flag加上volatile关键字,来保证有序性。固然,也能够经过synchronized和Lock来保证有序性。synchronized和Lock保证某一时刻是只有一个线程执行同步代码,至关因而让线程顺序执行程序代码了,天然就保证了有序性。
实际上Java内存模型的有序性并非仅靠volatile、synchronized和Lock来保证有序性的。这是由于Java语言中,有一个先行发生原则(happens-before):
根据happens-before的八大规则,咱们回到刚的例子,一块儿分析一下。给flag加上volatile关键字,look look它是如何保证有序性的,
volatile bool flag = false; int b = 0; public void read() { b = 1; //1 flag = true; //2 } public void add() { if (flag) { //3 int sum =b+b; //4 System.out.println("bb sum is"+sum); } }
以上讨论学习,咱们知道volatile的语义就是保证变量对全部线程可见性以及禁止指令重排优化。那么,它的底层是如何保证可见性和禁止指令重排的呢?
在这里,先看几个图吧,哈哈~
假设flag变量的初始值false,如今有两条线程t1和t2要访问它,就能够简化为如下图:
若是线程t1执行如下代码语句,而且flag没有volatile修饰的话;t1刚修改完flag的值,还没来得及刷新到主内存,t2又跑过来读取了,很容易就数据flag不一致了,以下:
flag=true;
若是flag变量是由volatile修饰的话,就不同了,若是线程t1修改了flag值,volatile能保证修饰的flag变量后,能够当即同步回主内存。如图:
细心的朋友会发现,线程t2不仍是flag旧的值吗,这不还有问题嘛?其实volatile还有一个保证,就是每次使用前当即先从主内存刷新最新的值,线程t1修改完后,线程t2的变量副本会过时了,如图:
显然,这里还不是底层,实际上volatile保证可见性和禁止指令重排都跟内存屏障有关,咱们编译volatile相关代码看看~
DCL单例模式(Double Check Lock,双重检查锁)比较经常使用,它是须要volatile修饰的,因此就拿这段代码编译吧
public class Singleton { private volatile static Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
编译这段代码后,观察有volatile关键字和没有volatile关键字时的instance所生成的汇编代码发现,有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33 ; {oop('Singleton')} 0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000 0x01a3de1a: shr $0x9,%esi ;...c1ee09 0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100 0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00 ;*putstatic instance ; - Singleton::getInstance@24
lock指令至关于一个内存屏障,它保证如下这几点:
- 1.重排序时不能把后面的指令重排序到内存屏障以前的位置
- 2.将本处理器的缓存写入内存
- 3.若是是写入动做,会致使其余处理器中对应的缓存无效。
显然,第二、3点不就是volatile保证可见性的体现嘛,第1点就是禁止指令重排列的体现。
内存屏障四大分类:(Load 表明读取指令,Store表明写入指令)
内存屏障类型 | 抽象场景 | 描述 |
---|---|---|
LoadLoad屏障 | Load1; LoadLoad; Load2 | 在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 |
StoreStore屏障 | Store1; StoreStore; Store2 | 在Store2写入执行前,保证Store1的写入操做对其它处理器可见 |
LoadStore屏障 | Load1; LoadStore; Store2 | 在Store2被写入前,保证Load1要读取的数据被读取完毕。 |
StoreLoad屏障 | Store1; StoreLoad; Load2 | 在Load2读取操做执行前,保证Store1的写入对全部处理器可见。 |
为了实现volatile的内存语义,Java内存模型采起如下的保守策略
有些小伙伴,可能对这个仍是有点疑惑,内存屏障这玩意太抽象了。咱们照着代码看下吧:
内存屏障保证前面的指令先执行,因此这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其余处理器缓存失效,这也就保证了可见性,哈哈~
一般来讲,使用volatile必须具有如下2个条件:
实际上,volatile场景通常就是状态标志,以及DCL单例模式。
深刻理解Java虚拟机,书中的例子:
Map configOptions; char[] configText; // 此变量必须定义为 volatile volatile boolean initialized = false; // 假设如下代码在线程 A 中运行 // 模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其余线程配置可用 configOptions = new HashMap(); configText = readConfigFile(fileName); processConfigOptions(configText, configOptions); initialized = true; // 假设如下代码在线程 B 中运行 // 等待 initialized 为 true, 表明线程 A 已经把配置信息初始化完成 while(!initialized) { sleep(); } // 使用线程 A 中初始化好的配置信息 doSomethingWithConfig();
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; } }
底层是经过内存屏障实现的哦,volatile能保证修饰的变量后,能够当即同步回主内存,每次使用前当即先从主内存刷新最新的值。
也是内存屏障哦,跟面试官讲下Java内存的保守策略:
再讲下volatile的语义哦,重排序时不能把内存屏障后面的指令重排序到内存屏障以前的位置
不能够,能够直接举i++那个例子,原子性须要synchronzied或者lock保证
public class Test { public volatile int race = 0; public void increase() { race++; } 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<100;j++) test.increase(); }; }.start(); } //等待全部累加线程结束 while(Thread.activeCount()>1) Thread.yield(); System.out.println(test.race); } }
#### 8.8 volatile底层的实现机制
能够看本文的第六小节,volatile底层原理哈,主要你要跟面试官讲述,volatile如何保证可见性和禁止指令重排,须要讲到内存屏障~
#### 8.9 volatile和synchronized的区别?
推荐以前写的一篇文章:
Synchronized解析——若是你愿意一层一层剥开个人心