并发编程从零开始(四)-volatile/final

并发编程从零开始(四)

4 JMM内存模型

4.1 JMM与happen-before

4.1.1 为什么会存在内存可见性问题

下图为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。

image-20211026161125606

因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有“内存可见性”问题。

缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:

image-20211026161206658

L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。

操作系统内核视角下的CPU缓存模型:

image-20211026161330419

多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑CPU。每个逻辑CPU都有自己的缓存,这些缓存和主内存之间不是完全同步的。

对应到Java里,就是JVM抽象内存模型,如下图所示:

image-20211026161459044

4.1.2 重排序与内存可见性的关系

Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。

重排序类型:

  1. 编译器重排序。

    对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。

  2. CPU指令重排序。

    在指令级别,让没有依赖关系的多条指令并行。

  3. CPU内存重排序。

    CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

在三种重排序中,第三类就是造成“内存可见性”问题的主因。

volatile如何保证内存可见性:实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。

4.1.3 内存屏障

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

编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。

image-20211026162300142

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

  1. LoadLoad:禁止读和读的重排序。

  2. StoreStore:禁止写和写的重排序。

  3. LoadStore:禁止读和写的重排序。

  4. StoreLoad:禁止写和读的重排序。

Unsafe中的方法:

  1. loadFence=LoadLoad+LoadStore

  2. storeFence=StoreStore+LoadStore

  3. fullFence=loadFence+storeFence+StoreLoad

4.1.4 as-if-serial语义

重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?

1.单线程程序的重排序规则

无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。

即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。

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

2.多线程程序的重排序规则

编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。

对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。

编译器和CPU只能保证每个线程的as-if-serial语义

线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。

上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。

4.1.5 happen-before是什么

使用happen-before描述两个操作之间的内存可见性。

java内存模型(JMM)是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。

关于happen-before:

如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。

基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:

  1. 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保证)。

  2. 对volatile变量的写入,happen-before对应后续对这个变量的读取。

  3. 对synchronized的解锁,happen-before对应后续对这个锁的加锁。

……

JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。

4.1.6 happen-before的传递性

除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。

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

image-20211026163131317

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

操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happen-before操作4。又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen-before操作3。利用happen-before的传递性,就得到:

操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。

所以,操作1的结果,一定对操作4可见。

image-20211026164231188

假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5。

因为与volatile一样,synchronized同样具有happen-before语义。展开上面的代码可得到类似于下面的伪代码:

image-20211026164251911

根据synchronized的happen-before语义,操作4 happen-before 操作5,再结合传递性,最终就会得到:

操作1 happen-before 操作2……happen-before 操作7。所以,a、c都不是volatile变量,但仍然有内存可见性。


4.2 volatile关键字

4.2.1 64位写入的原子性(Half Write)

JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long/double前面加上volatile关键字。来保证读取的时候能完整的读到值,以此保证读写操作的原子性。注意++并不是原子操作。(可能与JVM实现有关,看别人博客说通过JVM的实现来决定是否是原子操作)。

volatile不能保证线程安全:例如开一百个线程,每个线程同时将使用volatile修饰的一个变量自增一百次。最后发现原本应该是一万次的操作,而变量并没有自增到10000。count++并不是一个原子性操作,它被拆分为load、use、assign三步,而这三步在多线程环境中,use和assgin是多次出现的,但这操作是非原子性的,也就是在read和load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,也就是私有内存和公有内存中的变量不同步,所以计算出来的值和预期不一样,就产生了线程安全的问题。所以需要在自增方法上使用synchronized进行修饰。如果没有修饰,则应该使用原子类,自增通过原子类中的原子操作进行。

4.2.2 重排序:DCL问题

单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking):

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

如果没有进行volatile进行修饰,上述的 instance = new Singleton(); 代码有问题:其底层会分为三个操作:

  1. 分配一块内存
  2. 在内存上初始化成员变量
  3. 把instance引用指向内存

在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。

volatile: 禁止重排序(实现内存屏障,防止CPU指令重排序和编译重排序),不可保障除64位写入的原子性(线程不安全),内存可见性

4.2.3 volatile 实现原理

由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。

这里只探讨为了实现volatile关键字的语义的一种参考做法:

  1. 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。

  2. 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。

  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。

具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。

4.2.4 JSR-133对volatile语义的增强

在JSR -133之前的旧内存模型中,一个64位long/ double型变量的读/ 写操作可以被拆分为两个32位的读/写操作来执行。从JSR -133内存模型开始 (即从JDK5开始),仅仅只允许把一个64位long/ double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。


4.3 final关键字

4.3.1 构造方法溢出问题

与DCL未使用volatile修饰类似:

public class MyClass {
    private int num1;
    private int num2;
    private static MyClass myClass;

    public MyClass(){
        num1 = 1;
        num2 = 2;
    }

    /**
     * 线程a先执行write()
     */
    public static void write(){
        myClass = new MyClass();
    }

    /**
     * 线程b接着执行read()
     */
    public static void read(){
        if (myClass==null){
            int num3 = myClass.num1;
            int num4 = myClass.num2;
        }
    }
}

num3和num4的值是否一定是1和2?

num3、num4不见得一定等于1,2。和DCL的例子类似,也就是构造方法溢出问题。

myClass = new MyClass()这行代码,分解成三个操作:

  1. 分配一块内存;

  2. 在内存上初始化i=1,j=2;

  3. 把myClass指向这块内存。

操作2和操作3可能重排序,因此线程B可能看到未正确初始化的值。对于构造方法溢出,就是一个对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”。

4.3.2 final的happen-before语义

要解决上述问题,不止有一种办法。

办法1:给num1,num2加上volatile关键字。

办法2:为read/write方法都加上synchronized关键字。

如果num1,num2只需要初始化一次,还可以使用final关键字。

之所以能解决问题,是因为同volatile一样,final关键字也有相应的happen-before语义:

  1. 对final域的写(构造方法内部),happen-before于后续对final域所在对象的读。

  2. 对final域所在对象的读,happen-before于后续对final域的读。

通过这种happen-before语义的限定,保证了final域的赋值,一定在构造方法之前完成,不会出现另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造方法溢出的问题。

4.3.3 happen-before 规则总结
  1. 单线程中的每个操作,happen-before于该线程中任意后续操作。

  2. 对volatile变量的写,happen-before于后续对这个变量的读。

  3. 对synchronized的解锁,happen-before于后续对这个锁的加锁。

  4. 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。

image-20211027092859082

posted @ 2021-10-27 09:39  会编程的老六  阅读(253)  评论(0编辑  收藏  举报