前言

volatile是Java程序员必备的根底,也是面试官十分喜爱问的一个话题,本文跟大家一起开启volatile学习之旅,如果有不正确的中央,也麻烦大家指出哈,一起互相学习~

  • 1.volatile的用法
  • 2.volatile变量的作用
  • 3.古代计算机的内存模型(计算机模型,总线,MESI协定,嗅探技术)
  • 4.Java内存模型(JMM)
  • 5.并发编程的3个个性(原子性、可见性、有序性、happen-before、as-if-serial、指令重排)
  • 6.volatile的底层原理(如何保障可见性,如何保障指令重排,内存屏障)
  • 7.volatile的典型场景(状态标记,DCL单例模式)
  • 8.volatile常见面试题&&答案解析
  • 公众号:捡田螺的小男孩

github 地址

https://github.com/whx123/Jav...

1.volatile的用法

volatile关键字是Java虚拟机提供的的最轻量级的同步机制,它作为一个修饰符呈现,用来润饰变量,然而这里不包含局部变量哦。咱们来看个demo吧,代码如下:

/** *  @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;

2. vlatile润饰变量的作用

从以上例子,咱们能够发现变量stop,加了vlatile润饰之后,线程t1对stop就可见了。其实,vlatile的作用就是:保障变量对所有线程可见性。当然,vlatile还有个作用就是,禁止指令重排,然而它不保障原子性

所以当面试官问你volatile的作用或者个性,都能够这么答复:

  • 保障变量对所有线程可见性;
  • 禁止指令重排序
  • 不保障原子性

3. 古代计算机的内存模型(计算机模型,MESI协定,嗅探技术,总线)

为了更好了解volatile,先回顾一下计算机的内存模型与JMM(Java内存模型)吧~

计算机模型

计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。

因为计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,而后等主内存慢吞吞存取数据吧,
所以古代计算机系统退出一层读写速度靠近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。

在多路处理器零碎中,每个处理器都有本人的高速缓存,而它们共享同一主内存。计算机形象内存模型如下:

  • 程序执行时,把须要用到的数据,从主内存拷贝一份到高速缓存。
  • CPU处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
  • 当程序运算完结,把高速缓存的数据刷新会主内存。

随着科学技术的倒退,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3);

当多个处理器的运算工作都波及同一块主内存区域,可能导致缓存数据不统一问题。如何解决这个问题呢?有两种计划

  • 1、通过在总线加LOCK#锁的形式。
  • 2、通过缓存一致性协定(Cache Coherence Protocol)

总线

总线(Bus)是计算机各种性能部件之间传送信息的公共通信支线,它是由导线组成的传输线束, 依照计算机所传输的信息品种,计算机的总线能够划分为数据总线、地址总线和管制总线,别离用来传输数据、数据地址和管制信号。

CPU和其余性能部件是通过总线通信的,如果在总线加LOCK#锁,那么在锁住总线期间,其余CPU是无法访问内存,这样一来,效率就比拟低了

MESI协定

为了解决一致性问题,还能够通过缓存一致性协定。即各个处理器拜访缓存时都遵循一些协定,在读写时要依据协定来进行操作,这类协定有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协定是如何实现的?如何保障以后处理器的外部缓存、主内存和其余处理器的缓存数据在总线上保持一致的?多处理器总线嗅探

嗅探技术

在多处理器下,为了保障各个处理器的缓存是统一的,就会实现缓存缓存一致性协定,每个处理器通过嗅探在总线上流传的数据来查看本人的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被批改,就会将以后处理器的缓存行设置有效状态,当处理器对这个数据进行批改操作的时候,会从新从零碎内存中把数据库读到处理器缓存中。

4. Java内存模型(JMM)

  • Java虚拟机标准试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存拜访差别,以实现让Java程序在各种平台上都能达到统一的内存拜访成果。
  • Java内存模型类比于计算机内存模型。
  • 为了更好的执行性能,java内存模型并没有限度执行引擎应用处理器的特定寄存器或缓存来和主内存打交道,也没有限度编译器进行调整代码程序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题的
  • Java内存模型规定所有的变量都是存在主内存当中(相似于计算机模型中的物理内存),每个线程都有本人的工作内存(相似于计算机模型的高速缓存)。这里的变量包含实例变量和动态变量,然而不包含局部变量,因为局部变量是线程公有的。
  • 线程的工作内存保留了被该线程应用的变量的主内存正本,线程对变量的所有操作都必须在工作内存中进行,而不能间接操作操作主内存。并且每个线程不能拜访其余线程的工作内存。

举个例子吧,假如i的初始值是0,执行以下语句:

i = i+1;

首先,执行线程t1从主内存中读取到i=0,到工作内存。而后在工作内存中,赋值i+1,工作内存就失去i=1,最初把后果写回主内存。因而,如果是单线程的话,该语句执行是没问题的。然而呢,线程t2的本地工作内存还没过期,那么它读到的数据就是脏数据了。如图:

Java内存模型是围绕着如何在并发过程中如何解决原子性、可见性和有序性这3个特色来建设的,咱们再来一起回顾一下~

5.并发编程的3个个性(原子性、可见性、有序性)

原子性

原子性,指操作是不可中断的,要么执行实现,要么不执行,根本数据类型的拜访和读写都是具备原子性,当然(long和double的非原子性协定除外)。咱们来看几个小例子:

i =666; // 语句1i = j;   // 语句2i = i+1;  //语句 3i++;   // 语句4
  • 语句1操作显然是原子性的,将数值666赋值给i,即线程执行这个语句时,间接将数值666写入到工作内存中。
  • 语句2操作看起来也是原子性的,然而它实际上波及两个操作,先去读j的值,再把j的值写入工作内存,两个操作离开都是原子操作,然而合起来就不满足原子性了。
  • 语句3读取i的值,加1,再写回主存,这个就不是原子性操作了。
  • 语句4 等同于语句3,也是非原子性操作。

可见性

  • 可见性就是指当一个线程批改了共享变量的值时,其余线程可能立刻得悉这个批改。
  • Java内存模型是通过在变量批改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的形式来实现可见性的,无论是一般变量还是volatile变量都是如此。
  • volatile变量,保障新值能立刻同步回主内存,以及每次应用前立刻从主内存刷新,所以咱们说volatile保障了多线程操作变量的可见性。
  • synchronized和Lock也可能保障可见性,线程在开释锁之前,会把共享变量值都刷回主存。final也能够实现可见性。

有序性

Java虚拟机这样形容Java程序的有序性的:如果在本线程内察看,所有的操作都是有序的;如果在一个线程中,察看另一个线程,所有的操作都是无序的。

后半句意思就是,在Java内存模型中,容许编译器和处理器对指令进行重排序,会影响到多线程并发执行的正确性;前半句意思就是as-if-serial的语义,即不管怎么重排序(编译器和处理器为了进步并行度),(单线程)程序的执行后果不会被扭转。

比方以下程序代码:

double pi  = 3.14;    //Adouble r   = 1.0;     //Bdouble 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):

  • 程序秩序规定:在一个线程内,依照控制流程序,书写在后面的操作后行产生于书写在前面的操作。
  • 管程锁定规定:一个unLock操作后行产生于前面对同一个锁额lock操作
  • volatile变量规定:对一个变量的写操作后行产生于前面对这个变量的读操作
  • 线程启动规定:Thread对象的start()办法后行产生于此线程的每个一个动作
  • 线程终止规定:线程中所有的操作都后行产生于线程的终止检测,咱们能够通过Thread.join()办法完结、Thread.isAlive()的返回值伎俩检测到线程曾经终止执行
  • 线程中断规定:对线程interrupt()办法的调用后行产生于被中断线程的代码检测到中断事件的产生
  • 对象终结规定:一个对象的初始化实现后行产生于他的finalize()办法的开始
  • 传递性:如果操作A后行产生于操作B,而操作B又后行产生于操作C,则能够得出操作A后行产生于操作C

依据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);    } }
  • 首先呢,flag加上volatile关键字,那就禁止了指令重排,也就是1 happens-before 2了
  • 依据volatile变量规定,2 happens-before 3
  • 程序秩序规定,得出 3 happens-before 4
  • 最初由传递性,得出1 happens-before 4,因而妥妥的输入sum=2啦~

6.volatile底层原理

以上探讨学习,咱们晓得volatile的语义就是保障变量对所有线程可见性以及禁止指令重排优化。那么,它的底层是如何保障可见性和禁止指令重排的呢?

图解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单例模式(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.如果是写入动作,会导致其余处理器中对应的缓存有效。

显然,第2、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写操作的后面插入一个StoreStore屏障。
  • 在每个volatile写操作的前面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的前面插入一个LoadStore屏障。

有些小伙伴,可能对这个还是有点纳闷,内存屏障这玩意太形象了。咱们照着代码看下吧:


内存屏障保障后面的指令先执行,所以这就保障了禁止了指令重排啦,同时内存屏障保障缓存写入内存和其余处理器缓存生效,这也就保障了可见性,哈哈~

7.volatile的典型场景

通常来说,应用volatile必须具备以下2个条件:

  • 1)对变量的写操作不依赖于以后值
  • 2)该变量没有蕴含在具备其余变量的不变式中

实际上,volatile场景个别就是状态标记,以及DCL单例模式

7.1 状态标记

深刻了解Java虚拟机,书中的例子:

Map configOptions;char[] configText;// 此变量必须定义为 volatilevolatile 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();

7.2 DCL单例模式

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;    }}

8. volatile相干经典面试题

  • 谈谈volatile的个性
  • volatile的内存语义
  • 说说并发编程的3大个性
  • 什么是内存可见性,什么是指令重排序?
  • volatile是如何解决java并发中可见性的问题
  • volatile如何避免指令重排
  • volatile能够解决原子性嘛?为什么?
  • volatile底层的实现机制
  • volatile和synchronized的区别?

8.1 谈谈volatile的个性

8.2 volatile的内存语义

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为有效。线程接下来将从主内存中读取共享变量。

8.3 说说并发编程的3大个性

  • 原子性
  • 可见性
  • 有序性

8.4 什么是内存可见性,什么是指令重排序?

  • 可见性就是指当一个线程批改了共享变量的值时,其余线程可能立刻得悉这个批改。
  • 指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令程序进行从新排序。

8.5 volatile是如何解决java并发中可见性的问题

底层是通过内存屏障实现的哦,volatile能保障润饰的变量后,能够立刻同步回主内存,每次应用前立刻先从主内存刷新最新的值。

8.6 volatile如何避免指令重排

也是内存屏障哦,跟面试官讲下Java内存的激进策略:

  • 在每个volatile写操作的后面插入一个StoreStore屏障。
  • 在每个volatile写操作的前面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的前面插入一个LoadStore屏障。

再讲下volatile的语义哦,重排序时不能把内存屏障前面的指令重排序到内存屏障之前的地位

8.7 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的区别?

  • volatile润饰的是变量,synchronized个别润饰代码块或者办法
  • volatile保障可见性、禁止指令重排,然而不保障原子性;synchronized能够保障原子性
  • volatile不会造成线程阻塞,synchronized可能会造成线程的阻塞,所以前面才有锁优化那么多故事~
  • 哈哈,你还有补充嘛~

举荐之前写的一篇文章:
Synchronized解析——如果你违心一层一层剥开我的心

公众号

参考与感激

  • <<深刻了解Java虚拟机>>
  • Java并发编程:volatile关键字解析
  • 面试官最爱的volatile关键字
  • 面试官没想到一个Volatile,我都能跟他扯半小时
  • 再有人问你Java内存模型是什么,就把这篇文章发给他。
  • 【并发编程】MESI--CPU缓存一致性协定
  • 漫画:volatile对指令重排的影响
  • volatile三大个性详解