volatile内存屏障、指令重排及双端检查机制

 内存屏障
  内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。也是一个让CPU 处理单元中的内存状态对其它处理单元可见的一项技术。
  CPU 使用了很多优化,使用缓存、指令重排等,其最终的目的都是为了性能,也就是说,当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。所以指令的执行时序并不是顺序执行的,而是乱序执行的,这就会带来很多问题,这也促使着内存屏障的出现。
  语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
  内存屏障的开销非常轻量级,但是再小也是有开销的,LazySet 的作用正是如此,它会以普通变量的形式来读写变量。
  防止指令重排 线程安全的单例
 
volidate 双端检查机制
  线程安全的单例模式
  validate
    1. 线程间可见
    2. 非线程安全的
    3. 禁止指令重排
 
  对象创建过程,分为 3 步:
    1. new 对象,申请内存空间,此时成员变量值为默认值
    2. 调用对象的 init方法,给成员变量赋值,此时成员变量值为对象的真实值
    3. 指向引用变量
 
  CPU 指令执行规则:
    不会按照字节码的顺序执行,为了达到效率最优,会自动跳行选择字节码执行。
 
  在对象创建过程中,CPU 不一定按照顺序执行创建的 3 步流程,可能执行顺序,例:1-3-2,当在 1-3 时,即对象为半初始化对象,此时如果被其他线程使用,就会发生问题,此时对象的成员变量并未初始化值,如果使用 validate,则会强制让 CPU 按照 1-2-3 的顺序创建对象,就可避免半初始化对象的产生。
 
什么是指令重排
  java语言规范 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的顺序就可以与代码顺序不同,此过程叫指令的重排序。
 
  指令重排发生阶段
    1. 执行器编译阶段
    2. CPU 运行时
 
  指令重排的意义
    适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
 
源码到最终执行的指令序列示意图

 

 
指令重排遵循的原则
  指令重排遵循 as-if-serial 语义。不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义。
 
内存屏障
  又称为内存栅栏,是一个CPU指令
  内存屏障的作用
    1. 保证特定操作的执行顺序,
    2. 保证某些变量的内存可见性
 
  由于编译器和处理器都能执行指令重排优化。如果在指令期间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。
 
  JMM 针对编译器制定的 volatile重排序规则表

 

  当第二个操作是volatile时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后;
  当第一个操作时 volatile 读时,不管第二个操作是什么,都不能重排序。则个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前;
  当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序;
 
  为了实现volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数,几乎不可能。为此,JMM 采取保守策略。
  在每个 volatile 写操作的前面插入一个 StoreStore 屏障;
  在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;
  在每个 volatile 读操作的后面插入一个 LoadLoad 屏障;
  在每个 volatile 读操作的后面插入一个 LoadStore 屏障;
 
 
  执行代码时 JVM 会进行指令重排,处理器为了提高效率,可以对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致。
  意义:根据处理器特性(CPU 多级缓存系统、多核处理器等)适当的对机器指令进行重排序,可以使机器指令能够更加符合 CPU 的执行特性,最大限度的发挥机器性能。
 
  常见的重排序有 3 个层面:
    编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
 
指令重排对单线程和多线程的影响
  单线程:对于单线程而言,runtime和处理器都必须遵守 as-if-serial 语义。不管怎么重排序,单线程的执行结果不会改变。所以我们不需要考虑重排带来的畏寒,只需要享受它带给我们的好处就可以了。
  多线程:对于多线程来说,指令重排就可能会给我们带来极大的危害(参照单例模式双重检查机制)
  解决方法:通过 内存屏障 禁止重排序,JMM 通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序。
 
内存屏障
  内存屏障或内存栅栏(Memory Barrier),是一个让 CPU 处理器单元中的内存状态对其它处理器单元可见的一项技术。
 
内存屏障有两个能力:
  就像一套栅栏分割前后的代码,组织栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证可见性。
内存屏障有三种类型和 一种伪类型:
  ifence:即读屏障(Load Barrier),在读指令插入前插入读屏障,让高速缓存中的数据失效,重新从主内存中加载数据,以保证读取的时最新的数据。
  sfence:即写屏障(Store Barrier),在写指令之后插入屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
  mfence,即全能屏障,具备 ifence 和 sfence 的能力。
 
  Lock 前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。
注:在Java中,实现了内存屏障技术有 volatile。volatile就是用 Lock 前缀方式的内存屏障伪类型来实现的。
 
Happens-before(先行发生原则)
  JVM会对我们的程序为了提高运行效率进行指令重排序优化,但是指令重排序需要遵守 happens-before规则,不能说你想怎么排就怎么排。
  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
  4. 传递规则:如果操作 A happen-before 操作 B,操作 B happen-befor 操作 C,那么可以得出 A happen-before 操作 C
  5. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作
  6. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的中止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止。
  8. 对象终结原则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始
 
时间上先后顺序和 happen-before原则
  1. 一个操作时间上先发生于另一个操作 ”并不代表“ 一个操作 happen-before 另一个操作
  2. 一个操作 happen-before 另一个操作 ”并不代表“ 一个操作时间上先发生于另一个操作
 
简单理解指令重排、Happens-before 之 DCL 单例模式
针对延迟加载法(懒汉式单例模式)的同步实现所产生的性能低的问题,采用 DCL,即双重检查枷锁(Double Check Lock)的方法来避免每次调用 getInstance() 方法时都同步。 实现方式如下:

 

  DCL 对 instance 进行了两次 null 判断,第一层判断主要是为了避免不必要的同步,第二层的判断是为了在 null 的情况下创建实例。
但是 DCL 是具有不安全性的
  假设线程 A 执行到 instance = new LazySingletom() 这句,这里看起来是一句话,但实际上它并不是一个原子操作,我们只要看看这句话被编译后在 JVM 执行的对应汇编代码就发现,这句话被编译成 8 条汇编指令,大制作了 3 件事情:
  1. 给 LazySingleton 的实例分配内存
  2. 初始化 LazySingleton() 的构造器
  3. 将 instance 对象指向分配的内存空间(注意到这步 instance 就非 null 了)
 
  但是,由于Java编译器会进行指令重排,以及 JDK1.5 之前JMM(Java Memory Medel,即Java内存模型)中 Cache、寄存器到主内存回写顺序的规定,上面的第二点和第三点的顺序是无法保证的,也就是说,执行顺序可能是 1-2-3 也可能是 1-3-2,如果是后者,并且在 3 执行完毕、2未执行之前,被切换到线程 B 上,这时候 instance 因为已经在线程 A 内执行过了第三点,instance已经是非空了,所以线程 B 直接拿走 instance,然后使用,然后这种难以跟踪难以重现的错误很可能会隐藏很久。
在 JDK1.5 之后,官方已经注意到这种问题,调整了 JMM、具体化了 volatile 关键字,因此如果 JDK 是 1.5 或之后的版本,只需要将 instance 的定义改成 ”private volatile static LazySingleton instance = null“ 就可以保证每次取 instance 都从主内存读取,就可以使用 DCL的写法来完成单例模式。当然 volatile 或多或少也会影响到性能。
 
 
 
posted @ 2022-11-02 15:33  茄子777  阅读(572)  评论(0)    收藏  举报