死磕Java多线程(四)--- 指令重排、内存屏障、Happens-before

5. 指令重排:

执行代码时jvm会进行指令重排序,处理器为了提高效率,可以对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,

意义: 根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,可以使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

常见的重排序有3个层面:

编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

5.1 指令重排对单线程和多线程的影响

单线程:

对于单线程而言,因为编译器、runtime和处理器都必须遵守as-if-serial语义。不管怎么重排序,单线程的执行结果不会改变。所以我们不需要考虑指令重排带来的危害,只需要享受它带给我们的好处就可以了。

多线程:

问题: 对于多线程来说,指令重排就可能会给我们带来极大的危害(参照单例模式双重检查机制)。
解决方法: 通过内存屏障禁止重排序:JMM通过插入特定类型的内存屏障,来禁止特定类型的编译器重排序和处理器重排序。

学习指令重排,让我们明白,因为指令重排,多线程开发中,是存在很多有序性和可见性问题的。

5.2 内存屏障

内存屏障或内存栅栏(Memory Barrier),是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。

内存屏障有两个能力:
就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。

内存屏障有三种类型和一种伪类型:

lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。

sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。

mfence,即全能屏障,具备ifence和sfence的能力。

Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

注:在Java中:实现了内存屏障的技术有volatile。volatile就是用Lock前缀方式的内存屏障伪类型来实现的(具体实现另述)

6.Happens-before(先行发生原则)

刚才就说jvm会对我们的程序为了提高运行效率进行指令重排序优化,但是指令重排序需要遵守happens-before规则,不能说你想怎么排就怎么排,如果那样岂不是乱了套。

6.1 happens-before规则

1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

2.锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作

3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

4.传递规则:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出Ahappen—before操作C。

5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

6.2 时间上先后顺序和happen—before原则

1. 一个操作时间上先发生于另一个操作“并不代表”一个操作happen—before另一个操作

2. 一个操作happen—before另一个操作“并不代表”一个操作时间上先发生于另一个操作

7. 简单理解指令重排、Happens-before之DCL单例模式

针对延迟加载法(懒汉式单例模式)的同步实现所产生的性能低的问题,采用DCL,即双重检查加锁(Double Check Lock)的方法来避免每次调用getInstance()方法时都同步。实现方式如下:

public class LazySingleton {

    private static LazySingleton instance;    
    
    private LazySingleton() {}
    
    public static LazySingleton getInstance() {                 
        if (instance == null) {  
  
         synchronized(LazySingleton.class){
   
                if(instance == null){
    
                    instance = new LazySingleton();       
               }      
      }   
  }
       return instance;                                      
        }
}

DCL对instance进行了两次null判断,第一层判断主要是为了避免不必要的同步,第二层的判断则是为了在null的情况下创建实例。

但是DCL是具有不安全性的,让我们来看看如下场景:

假设线程A执行到instance = new LazySingleton()这句,这里看起来是一句话,但实际上它并不是一个原子操作,我们只要看看这句话被编译后在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或多或少也会影响到性能。

发布了45 篇原创文章 · 获赞 3 · 访问量 2328

猜你喜欢

转载自blog.csdn.net/weixin_44046437/article/details/99093145