一文吃透Volatile,征服面试官

根据图所展现的知识点,有目的性的阅读如下内容!!!java

前情省略一千字....面试


此时,小黄内心十分紧张的坐在面试官面前,看着面试官来回翻动本身的简历,准备接受暴风骤雨的洗礼。缓存


此时,面试官抬起头,目光如炬,看着小黄,笑了笑。多线程


面试官:平时项目中有没有用到volatile关键字?并发


小黄:用到了,为了在多线程处理器环境下能保证共享变量的可见性。app


面试官:不错,那你以为什么是可见性?异步


小黄:在多线程状况下,读和写发生在不一样的线程中,而读线程未能及时的读到写线程写入的最新的值。oop


面试官:对的,那么你以为volatile关键字是如何保证线程的可见性呢?post


小黄:我以为,首先咱们须要从硬件层面了解可见性的本质。一台机算机最核心的组件是CPU,内存,以及I/O设备。可是这三者在处理速度上有很大的差别,可是最终总体的计算效率仍是取决于最慢的那个设备,为了平衡三者的速度差别,最大化的利用CPU提高性能,不管是硬件,操做系统仍是编译器都作了不少的优化。性能


  1. CPU增长了告诉缓存

  2. 操做系统增长了进程,线程,经过时间片切换最大化的提高CPU性能

  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存



面试官:什么是CPU高速缓存?


小黄:因为计算机的存储设备与处理器的运算速度差距很是大,因此现代计算机系统都会增长读写速度尽量接近处理器运算速度的高速缓存来做为内存和处理器之间的缓冲:将运算须要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步到内存之中。


面试官:很棒,可是使用CPU告诉缓存会不会带来一些问题呢?


小黄:是的,它虽然很好的解决了处理器与内存之间的速度矛盾,可是也引入了一个新的问题,缓存一致性。


有了高速缓存后,每一个CPU处理过程变成这样:先将计算机须要用到的数据缓存在CPU高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据而且计算完成以后写到缓存中,在整个运算完成后,再把缓存中的数据同步到内存。


因为在多CPU中,每一个线程可能会运行在不一样的CPU中,而且每一个线程都有本身的高速缓存,同一份数据可能会被缓存到多个CPU中,若是在不一样CPU中运行的不一样线程看到同一分内存的缓存值不同,就会存在缓存不一致的问题。


面试官:有没有什么解决方案?


小黄:1.总线锁。2.缓存锁。


面试官:解释一下什么是总线锁?


小黄:要不我画个图吧,(因而有了下面这张图),可是总线锁开销较大,因此须要优化,最好的方法就是控制锁的粒度,咱们只须要保证,对于被多个CPU缓存的同一份数据是一致的就行,因此引入了缓存锁,他的核心机制就是缓存一致性协议。




面试官:什么是缓存一致性协议?


小黄:为了达成数据访问的一致性,须要各个处理器在访问内存时,遵循一些协议,在读写时根据协议来操做,常见的协议有,MSI,MESI,MOSI等等,最多见的就是MESI协议;


MESI表示缓存行的四种状态,分别是:

    M(modify)表示共享数据只缓存当前CPU缓存中,而且是被修改状态,也就是缓存的数据和主内存中的数据不一致。

    E(Exclusive)表示线程的独占状态,数据只缓存在当前的CPU缓存中,而且没有被修改

    S(Shared)表示数据可能被多个CPU 缓存,而且各个缓存中的数据和主内存数据一致

    I(Invalid) 表示缓存已经失效

在MESI协议中,每一个缓存的缓存控制器不只知道本身的读写操做,并且监听(snoop)其余的Cache的读写操做


对于MESI协议,从CPU读写角度来讲会遵循如下原则:

CPU读请求:缓存处于M,E,S状态均可以被读取,I状态CPU还能从主存中读取数据

CPU写请求:缓存处于M,E状态下才能够被写,对于S状态的写,须要将其余CPU中缓存置于无效才可写


使用总线锁和缓存锁后,CPU对于内存的操做大概能够抽象成下面这样的结构,从而达成缓存一致性效果



面试官:既然说基于缓存一致性协议或者总线锁就能达到一致性的要求,那么为何还须要voliate关键字呢?


小黄:MESI优化带来了可见性的问题:MESI 协议虽然能够实现缓存的一致性,可是也会存在一些问题。就是各个 CPU 缓存行的状态是经过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其余缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。


因此为了不阻塞带来的资源浪费。在 cpu 中引入了 Store Bufferes。


CPU0 只须要在写入共享数据时,直接把数据写入到 store bufferes 中同时发送 invalidate 消息,而后继续去处理其余指令。当收到其余全部 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。


可是这种优化存在两个问题

    1. 数据何时提交是不肯定的,由于须要等待其余 cpu给回复才会进行数据同步。这里实际上是一个异步操做

    2. 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,若是 storebuffer 中有数据,则直接从storebuffer 中读取,不然就再从缓存行中读取


看下下面这个例子:

    int value=0;复制代码void exeToCPU0{复制代码  value=10;复制代码  isFinish=true;复制代码}复制代码void exeToCPU1{复制代码  if(isFinish){复制代码    assert value==10;  复制代码  }复制代码}复制代码

    exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。


    假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,而且状态为(E)、而 Value 多是(S)状态。


    那么这个时候,CPU0 在执行的时候,会先把 value=10 的指令写入到storebuffer中。而且通知给其余缓存了该value变量的 CPU。在等待其余 CPU 通知结果的时候,CPU0 会继续执行 isFinish=true 这个指令。而由于当前 CPU0 缓存了 isFinish 而且是 Exclusive 状态,因此能够直接修改 isFinish=true。这个时候 CPU1 发起 read操做去读取 isFinish 的值可能为 true,可是 value 的值不等于 10。


    这种状况咱们能够认为是 CPU 的乱序执行,也能够认为是一种重排序,而这种重排序会带来可见性的问题。


    面试官:如何解决重排序带来的可见性问题?


    小黄:CPU内存屏障用来解决这个问题,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其余访问同一共享内存的线程的可见性。


    X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)。


    Store Memory Barrier(写屏障) 告诉处理器在写屏障以前的全部已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来讲就是使得写屏障以前的指令的结果对屏障以后的读或者写是可见的。


    Load Memory Barrier(读屏障) 处理器在读屏障以后的读操做,都在读屏障以后执行。配合写屏障,使得写屏障以前的内存更新对于读屏障以后的读操做是可见的。


    Full Memory Barrier(全屏障) 确保屏障前的内存读写操做的结果提交到内存以后,再执行屏障后的读写操做有了内存屏障之后,对于上面这个例子,咱们能够这么来改,从而避免出现可见性问题。


    总的来讲,内存屏障的做用能够经过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性,可是这个屏障怎么来加呢?回到最开始咱们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就至关于实现了一种内存屏障。


    面试官: 说到volatile,那么你能说说什么是JMM吗?


    小黄:JMM全称是java内存模型,由于致使可见性的根本缘由是缓存和重排序,而JMM则合理的禁用了缓存和禁用了重排序,因此他最核心的价值就是解决了可见性和有序性。


    面试官:JMM是如何解决可见性和有序性的?


    小黄:JMM基于CPU层面提供的内存屏障指令来限制编译器的重排序,从而解决并发问题。


    JMM提供了一些禁止缓存和禁止重排序的方法,好比上面说的volatile,以及synchronize(后续会单独写一篇关于synchronize的文章),final;


    JMM 如何解决顺序一致性问题 

    重排序问题

    为了提升程序的执行性能,编译器和处理器都会对指令作重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序。编译器的重排序指的是程序编写的指令在编译以后,指令可能会产生重排序来优化程序的执行性能。从源代码到最终执行的指令,可能会通过三种重排序。


    2 和 3 属于处理器重排序。这些重排序可能会致使可见性问题。编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。


    处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序,固然并非全部的程序都会出现重排序问题。


    编译器的重排序和 CPU 的重排序的原则同样,会遵照数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序,好比下面的代码,


      a=1; b=a; 复制代码
      复制代码a=1;a=2;复制代码
      复制代码a=b;b=1;复制代码


      这三种状况在单线程里面若是改变代码的执行顺序,都会致使结果不一致,因此重排序不会对这类的指令作优化。

      这种规则也成为 as-if-serial。无论怎么重排序,对于单个线程来讲执行结果不能改变。好比

        int a=2; //1复制代码
        复制代码int b=3; //2复制代码
        复制代码int rs=a*b; //3复制代码

        1 和 三、2 和 3 存在数据依赖,因此在最终执行的指令中,3 不能重排序到 1 和 2 以前,不然程序会报错。因为 1 和 2不存在数据依赖,因此能够从新排列 1 和 2 的顺序。


        JMM 层面的内存屏障


        为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在 JMM 中把内存屏障分为四类

        HappenBefore,它的意思表示的是前一个操做的结果对于后续操做是可见的,因此它是一种表达多个线程之间对于内存的可见性。


        因此咱们能够认为在 JMM 中,若是一个操做执行的结果须要对另外一个操做课件,那么这两个操做必需要存在happens-before 关系。


        这两个操做能够是同一个线程,也能够是不一样的线程,JMM 中有哪些方法创建 happen-before 规则 呢?


        程序顺序规则 

        1. 一个线程中的每一个操做,happens-before 于该线程中的任意后续操做; 能够简单认为是 as-if-serial。单个线程中的代码顺序无论怎么变,对于结果来讲是不变的顺序规则表示 1 happenns-before 2;

          3 happens-before 4


        2. volatile 变量规则,对于 volatile 修饰的变量的写的操做,必定 happen-before 后续对于 volatile 变量的读操做;根据 volatile 规则,2 happens before 3


        3. 传递性规则,若是 1 happens-before 2; 2 happens-before 3; 那么传递性规则表示: 1 happens-before 3;


        4. start 规则,若是线程 A 执行操做 ThreadB.start(),那么线程 A 的 ThreadB.start()操做 happens-before 线程 B 中的任意操做


        5. join 规则,若是线程 A 执行操做 ThreadB.join()并成功返回,那么线程 B 中的任意操做 happens-before 于线程A 从 ThreadB.join()操做成功返回。

        6. 监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁

               假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执 行完自动释放锁),线程 B 进入代码块时,可以看到线程 A 对 x 的写操做,也就是线程 B 可以看到 x==12。


        面试官(os):这么厉害?用不起用不起,回去等通知吧!


        小黄内心一万只草泥马奔腾而过。。。。。。

        版权声明:本站原创文章,于2019-10-08,由 TopJavaer 发表。 转载请注明来源 juejin.im/post/5d9c8a…,谢谢。

        相关文章
        相关标签/搜索