zoukankan      html  css  js  c++  java
  • volatile

    volatile 如果解决内存可见性和重排序? https://www.jianshu.com/p/64240319ed60
    一文解决内存屏障
    内存屏障是硬件之上、操作系统或JVM之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;
    向上是操作系统或JVM对内存屏障作出的各种封装。内存屏障是一种标准,
    本文仅为了帮助理解JVM提供的并发机制。首先,从volatile的语义引出可见性与重排序问题;接下来,阐述问题的产生原理,
    了解为什么需要内存屏障;然后,浅谈内存屏障的标准、厂商对内存屏障的支持,并以volatile为例讨论内存屏障如何解决这些问题;
    最后,补充介绍JVM在内存屏障之上作出的几个封装。为了帮助理解,会简要讨论硬件架构层面的一些基本原理
    (特别是CPU架内存屏障的实现涉及大量硬件架构层面的知识,又需要操作系统或JVM的配合才能发挥威力,单纯从任何一个层面都无法理解。本文整合了这三个层面的大量知识,篇幅较长,希望能在一篇文章内,把内存屏障的基本问题讲述清楚。
    如有疏漏,还望指正!

    volatile变量规则
    一个用于引出内存屏障的好例子是volatile变量规则。

    volatile关键字可参考猴子刚开博客时的文章volatile关键字的作用、原理。
    volatile变量规则描述了volatile变量的偏序语义;这里从volatile变量规则的角度来讲解,顺便做个复习。

    定义
    volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。
    volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

    保持可见性
    禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)
    补充:

    程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
    传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
    后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,
    因此,不会出现单独讨论重排序的场景

    正确姿势
    之前的文章多次涉及volatile变量规则的用法。

    简单的仅利用volatile变量规则对volatile变量本身的可见性保证:

    面试中单例模式有几种写法?:“饱汉 - 变种 3”在DCL的基础上,使用volatile修饰单例,以保证单例的可见性。
    复杂的利用volatile变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:

    源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock:exclusiveOwnerThread借助于volatile变量state保证其相对于state的偏序。
    源码|并发一枝花之CopyOnWriteArrayList:CopyOnWriteArrayList借助于volatile变量array,对外提供偏序语义。
    可见性与重排序
    前文多次提到可见性与重排序的问题,内存屏障的存在就是为了解决这些问题。到底什么是可见性?什么是重排序?为什么会有这些问题?

    可见性
    定义
    可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

    从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,
    在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:
    当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值。
    可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,
    但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。

    问题来源
    一个最简单的可见性问题来自计算机内部的缓存架构:

    缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:

    L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。
    L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。
    L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。
    准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。

    单核时代的一切都是那么完美。然而,多核时代出现了可见性问题。一个badcase如下:

    Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。
    最开始,Core0与Core1都在友善的读取这份数据。
    突然,Core0要使坏了,它修改了这份数据,使得两份缓存中的数据不同了,更确切的说,Core1 L1 Cache中的数据失效了。
    单核时代只有Core0,Core0修改Core0读,没什么问题;但是,现在Core0修改后,Core1并不知道数据已经失效,继续傻傻的使用,
    轻则数据计算错误,重则导致死循环、程序崩溃等。

    实际的可见性问题还要扩展到两个方向:

    除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。
    各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了。
    以上只是最简单的可见性问题,不涉及重排序等。

    重排序也会导致可见性问题;同时,缓存上的可见性也会引起一些看似重排序导致的问题。


    重排序
    定义
    重排序并没有严格的定义。整体上可以分为两种:

    真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
    伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。
    重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。
    在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。
    然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

    第一次接触重排序的概念一定很迷糊,耐心,耐心。

    问题来源
    重排序问题无时无刻不在发生,源自三种场景:

    编译器编译时的优化
    处理器执行时的乱序优化
    缓存同步顺序(导致可见性问题)
    场景1、2属于真·重排序;场景3属于伪·重排序。场景3也属于可见性问题,为保持连贯性,我们先讨论场景3。


    可见性导致的伪·重排序
    缓存同步顺序本质上是可见性问题。

    假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:

    Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。
    Core0读取v1(假设使用LRU协议淘汰缓存)。
    Core0的缓存满,将最远使用的v2写回内存。
    Core1的缓存中本来存有v1,现在将v2加载入缓存。
    重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。

    此时,尽管“更新v1”的事件早于“更新v2”发生,但Core1只看到了v2的最新值,却看不到v1的最新值。这属于可见性导致的伪·重排序:虽然没有实际上没有重排序,但看起来发生了重排序。

    可以看到,缓存可见性不仅仅导致可见性问题,还会导致伪·重排序。因此,只要解决了缓存上的可见性问题,也就解决了伪·重排序。

    MESI协议
    回到可见性问题中的例子和可见性的定义。要解决这个问题很简单,套用可见性的定义,只需要:在Core0修改了数据v后,让Core1在使用v前,能得到v最新的修改值。

    这个要求很弱,既可以在每次修改v后,都同步修改值到其他缓存了v的Cache中;又可以只同步使用前的最后一次修改值。后者性能上更优,如何实现呢:

    Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。
    Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。
    Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。
    以上即是MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理,不算严谨,但对于理解缓存可见性(更常见的称呼是“缓存一致性”)已经足够。

    MESI协议解决了CPU缓存层面的可见性问题。

    以下是MESI协议的缓存状态机,简单看看即可:

    内存屏障相当于消除编译器对于变量存入寄存器的优化

    image.png
    状态:

    M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
    E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
    S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
    I(无效, Invalid): 缓存行失效, 不能使用。

    ==================================================== volatile ==========================================================

    volatile 写 - 读建立的 happens before 关系
    上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,
    也更需要我们去关注。

    从 JSR-133 开始,volatile 变量的写 - 读可以实现线程之间的通信。

    从内存语义的角度来说,volatile 与监视器锁有相同的效果:
    volatile 写和监视器的释放有相同的内存语义;
    volatile 读与监视器的获取有相同的内存语义。


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

    volatile 写 - 读的内存语义
    volatile 写的内存语义如下:

    当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
    以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,
    初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:
    如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,
    写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。

    下面对 volatile 写和 volatile 读的内存语义做个总结:

    线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息。
    线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
    线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

    volatile 内存语义的实现
    下面,让我们来看看 JMM 如何实现 volatile 写 / 读的内存语义。

    前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。
    下面是 JMM 针对编译器制定的 volatile 重排序规则表:
    是否能重排序 第二个操作 第一个操作 普通读 / 写 volatile 读 volatile 写 普通读 / 写 NO volatile 读 NO NO NO volatile 写 NO NO 举例来说,
    第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

    上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。
    这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

    这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。
    因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。
    为了保证能正确实现 volatile 的内存语义,
    JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。
    从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:
    一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,
    选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到 JMM 在实现上的一个特点:
    首先确保正确性,然后再去追求执行效率。


    锁是 java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

    下面是锁释放 - 获取的示例代码:

    class MonitorExample {
    int a = 0;

    public synchronized void writer() { //1
    a++; //2
    } //3

    public synchronized void reader() { //4
    int i = a; //5
    ……
    } //6
    }

    假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为两类:

    根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
    根据监视器锁规则,3 happens before 4。
    根据 happens before 的传递性,2 happens before 5。
    上述 happens before 关系的图形化表现形式如下:



    public class FinalExample {
    int i; // 普通变量
    final int j; //final 变量
    static FinalExample obj;

    public void FinalExample () { // 构造函数
    i = 1; // 写普通域
    j = 2; // 写 final 域
    }

    public static void writer () { // 写线程 A 执行
    obj = new FinalExample ();
    }

    public static void reader () { // 读线程 B 执行
    FinalExample object = obj; // 读对象引用
    int a = object.i; // 读普通域
    int b = object.j; // 读 final 域
    }
    }

    写 final 域的重排序规则
    写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:

    JMM 禁止编译器把 final 域的写重排序到构造函数之外。
    编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
    现在让我们分析 writer () 方法。writer () 方法只包含一行代码:finalExample = new FinalExample ()。这行代码包含两个步骤:

    构造一个 FinalExample 类型的对象;
    把这个对象的引用赋值给引用变量 obj。


    JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,
    而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
    JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:
    只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
    比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,
    如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。





    Volatile的重排序

    1、当第二个操作为volatile写操做时,不管第一个操作是什么(普通读写或者volatile读写),都不能进行重排序。
    这个规则确保volatile写之前的所有操作都不会被重排序到volatile之后;

    2、当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile之前;

    3、当第一个操作是volatile写操作时,第二个操作是volatile读操作,不能进行重排序。

    这个规则和前面两个规则一起构成了:两个volatile变量操作不能够进行重排序;

    除以上三种情况以外可以进行重排序。

    比如:

    1、第一个操作是普通变量读/写,第二个是volatile变量的读;
    2、第一个操作是volatile变量的写,第二个是普通变量的读/写;
    ————————————————
    版权声明:本文为CSDN博主「qinjianhuang」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/sinat_35512245/article/details/60325685



    volatile总结

    监视器锁的 happens-before 规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,
    总是能看到(任意线程)对这个 volatile 变量最后的写入。

    volatile 变量自身具有下列特性:

    可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
    原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

    编译器重排序 -- https://blog.csdn.net/CringKong/article/details/99759216
    java内存模型 -- https://www.infoq.cn/news/java-memory-model-4
    内存屏障 -- https://www.jianshu.com/p/64240319ed60
    Volatile的重排序 -- https://blog.csdn.net/sinat_35512245/article/details/60325685
    https://blog.csdn.net/ityouknow/article/details/88840168
    java interview -- https://blog.csdn.net/sufu1065/article/details/88051083
    纸上学来终觉浅,觉知此事需躬行
  • 相关阅读:
    链接<a href="javascript:void(0)" />
    JS 不定函数参数argument的用法
    java编译期常量
    对于代码设计的思考
    java交集retainAll 和 Sets.intersection 性能比较
    java 递归调整为队列
    前端跨域问题springboot
    guava的map中value转换问题和排序问题
    MAC安装 Office2016
    股市中的资金流入流出
  • 原文地址:https://www.cnblogs.com/dreamHighMjc/p/15074352.html
Copyright © 2011-2022 走看看