多线程--06--多线程安全问题解决--01--总述--03--java如何保证原子性、有序性、可见性--02--原理--JMM(happen-before)、volatile和内存屏障

本文详细解析了Java内存模型(JMM)中的as-if-serial原则和happen-before原则,阐述了它们在单线程和多线程环境下的应用。同时,介绍了volatile关键字的作用,它通过内存屏障防止指令重排序,确保可见性和有序性。文章还探讨了内存屏障的概念,以及其在Linux和JDK中的实现。
摘要由CSDN通过智能技术生成

一、as-if-serial 原则(看似串行原则)

           对开发者而言,当然希望不要有任何的重排序,这样理解起来最简单,指令执行顺序和代码顺序严格一致,写内存的顺序也严格地和代码顺序一致。但是,从编译器和CPU的角度来看,希望尽最大可能进行重排序,提升运行效率。于是,问题就来了,重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?

1.1 单线程程序的重排序规则

           无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。换句话说,只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾(看似串行),这也就是as-if-serial语义。

对于单线程程序来说,编译器和CPU可能做了重排序,但结果不会改变(开发者感知不到),也不存在内存可见性问题。

1.2 多线程程序的重排序规则

  • as-if-serial 对于单线程程序没有影响,但对多线程程序却有影响。对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。所以,编译器和CPU只能保证每个线程的as-if-serial语义。线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
  • 为了明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java 引入了JMM(Java Memory Model),也就是Java内存模型。
  • 这个模型就是一套规范,对上,是JVM和开发者之间的协定;对下,是JVM和编译器、CPU之间的协定。(可以理解为:为避免程序员需要深入理解JMM,使用happen-before来总结JMM控制)

这套规范,其实是要在开发者写程序的方便性和系统运行的效率之间找到一个平衡点。一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。

  • 为了描述这个规范,JMM引入了happen-before如果两个操作的执行次序无法从happens-before原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序,需要开发者显示地通过volatile、synchronized等线程机制来禁止重排序。

二、happen-before原则(先行发生原则)

happen-before原则是需要JMM保证(需要JVM开发者保证)的,即不需要java代码开发者通过任何手段就能够得到保证的有序性,进而保证可见性(如果A happen-before B,也意味着A的执行结果必须对B可见),happens-before原则如下:

  • 程序次序规则:单个线程内,可以认为程序是按照代码编写顺序进行顺序执行的,也不会存在可见性问题。
  • volatile变量规则:对一个volatile变量的写操作,先行发生于后面对这个变量的读操作。
  • 锁定规则:一个unLock操作,先行发生于后面对同一个锁的lock操作。(包括synchronized和lock接口锁)
  • 对final变量的写,先行发生于后面对这个final域对象的读,先行发生于后面对这个final变量的读。(该原则保证了final域的赋值,一定在构造函数之前完成,不会出现另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形)
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C,也意味着A的结果对C内存可见。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

这9条规则中,前5条规则是比较重要的,后4条规则都是显而易见的。

2.1 happen-before传递性进一步说明

  • happen-before的传递性,表示若A happen-before B,B happen-before C,则A happen-before C。
  • 如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功于happen-before的传递性。

传递性举例1:

(1)假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢?

(2)操作1和操作2是分别在同一个线程内存中执行的,所以操作1 happen-before 操作2,同理,操作3 happen-before操作4。

(3)因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen-before操作3。利用happen-before的传递性,就得到:操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。所以,操作1的结果,一定对操作4可见。

传递性举例2:

(1)假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5,为什么?

(2)因为synchronized同样具有happen-before语义(unlock发生于之后同一锁的lock之前),展开上面的代码可得到类似于下面的伪代码:

(3)根据synchronized的happen-before语义,操作4 happen-before操作5,再结合传递性,最终就会得到:操作1 happen-before 操作2……happen-before 操作7。所以虽然a、c都不是volatile变量,但仍然有内存可见性。

三、volatile的原理和功能

           观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令(注意是汇编代码,不是编译后的字节码,字节码没什么不同),lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面(即在执行到内存屏障这句指令时,在它前面的操作已经全部完成);
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。
  • volatile变量在写时,会加入lock,禁止指令重排;volatile变量在读时,和非volatile变量一样,都不会加lock
  • volatile保证可见性、禁止指令重排保证有序性,但不保证原子性

3.1 volatile使用举例--DCL

public class Singleton { 
​
    //私有构造方法
    private Singleton() {}
​
    private static Singleton instance;
​
   //对外提供静态方法获取该对象
    public static Singleton getInstance() {
        //第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
        if(instance == null) {                     //语句1
            synchronized (Singleton.class) {       //语句2
                //抢到锁之后再次判断是否为null
                if(instance == null) {             //语句3
                    instance = new Singleton();    //语句4
                }
            }
        }
        return instance;
    }
}

(1)如上代码实现的单例会不会不是单例?

           不会。因为synchronized 保证同一时刻只能有一个线程执行,并且synchronized 在释放锁之前,instance的值会刷回主存,所以是单例的。

(2)如上代码为什么会存在空指针异常?

           因为对象的初始化是非原子的,instance=new Instance()语句在底层会被分解为:分配对象的内存空间、初始化对象、把instance引用指向内存,被分解后的指令可能重排序,线程A执行语句4期间,线程B执行语句1,导致了空指针异常,如下图所示:

如何解决如上DCL代码中的空指针异常?有2种方式:

  • 禁止对象初始化时的指令重排---->volatile
  • 允许重排序,但不允许其他线程“看到”这个重排序---->基于类的初始化

(3)空指针解决方式1--禁止对象初始化时的指令重排---->volatile

           instance使用volatile修饰后,写时会加入内存屏障禁止指令重排,如下图所示:

(4)空指针解决方式2--允许重排序,但不允许其他线程“看到”这个重排序---->基于类的初始化

           参考:设计模式--03--创建型--01--单例--静态内部类

四、内存屏障

为了禁止编译器重排序和CPU 重排序,在编译器和CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier),这也正是JMM(happen-before)规则和volatile的底层实现原理

  • 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
  • CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。下面主要讲CPU的内存屏障。

4.1 Linux提供的内存屏障

           linux操作系统有自己实现的内存屏障

4.2 JDK提供的内存屏障

  • 从JDK1.5开始,jdk提供了volatile关键字可以实现内存屏障;

(1)在理论层面,可以把基本的CPU内存屏障分成四种:

  1. LoadLoad:禁止读和读的重排序。
  2. StoreStore:禁止写和写的重排序。
  3. LoadStore:禁止读和写的重排序。
  4. StoreLoad:禁止写和读的重排序。

(2)由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。比如在x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。

  • 从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数:

loadFence=LoadLoad+LoadStore

storeFence=StoreStore+LoadStore

fullFence=loadFence+storeFence+StoreLoad

 

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值