并发编程-深入JMM内存模型与Volatile

·  阅读 738

一、CPU高速缓存的由来

CPU高速发展期间,内存和硬盘的发展速度远远跟不上CPU,这就导致了CPU去内存读写数据的速度相对缓慢。

针对这个问题,CPU厂商在CPU中内置了三级高速缓存(L1、L2、L3)来解决IO速度和CPU速度不匹配的问题,通过三级缓存,减少了CPU与内存的交互。

image.png

寄存器: CPU中的数据存储单元,数量有限

缓存行: 上面所说的三级高速缓存,它们的虽小存储单元叫做缓存行,缓存行的大小通常为64byte,比如说L1的缓存大小是512kb,而缓存行又占64byte,即L1区域有((512*1024)/64)个缓存行。

离CPU越近的速度越快,同时内存越小,即:

  • 按速度快慢排序:寄存器>L1>L2>L3>内存
  • 按内存大小排序:寄存器<L1<L2<L3<内存

CPU读取数据的过程

  1. 如果取的是寄存器的数据,直接读取即可
  2. 如果取的是L1的数据,会先锁住该数据对应的缓存行,然后把数据放入寄存器,最后解锁。
  3. 如果取的是L2的数据,会先去L1取,L1没有的话,会先锁住该数据在L2对应的缓存行,然后把数据放入L1,再执行上一步读取L1的过程,最后解锁。
  4. 读取L3同理。
  5. 如果取的是内存的数据,会先通知内存加一个总线锁,然后往内存发送读强求,响应的数据存放在L3,再重复上面的步骤,完成之后解除总线锁。

CPU访问内存的局部性原理:

主要是两点:时间和空间

  • 时间局部性:如果一个信息正在被访问,那么近期它可能还会被访问(比如循环、递归等等),通俗理解为热点数据的缓存。
  • 空间局部性:某一个数据被访问了,与之相邻的数据也会被访问到。

下面我们来看一下空间局部性的例子:

public class MyTest {

    private static int arr[][] = new int[1024*1024][6];

    static {
        for (int i=0;i<1024*1024;i++){
            for (int j=0;j<6;j++){
                arr[i][j]=j;
            }
        }
        System.err.println("arr二维数组初始化完成");
    }

    public static void main(String[] args) {
        //一行一行的读,地址是连续的,且CPU的空间局限性,CPU读取一行数据只需要访问一次内存,所以快
        int sum = 0;
        long startTime = new Date().getTime();
        for (int i=0;i<1024*1024;i++){
            for (int j=0;j<6;j++){
                sum+=arr[i][j];
            }
        }
        System.err.println("耗时:"+(new Date().getTime()-startTime)+"ms,结果: "+sum);

        //一列一列的读,地址不是连续的,CPU访问一次内存只能拿到一个数据,所以慢
        sum = 0;
        startTime = new Date().getTime();
        for (int i=0;i<6;i++){
            for (int j=0;j<1024*1024;j++){
                sum+=arr[j][i];
            }
        }
        System.err.println("耗时:"+(new Date().getTime()-startTime)+"ms,结果: "+sum);

    }
}
复制代码

结果:

arr二维数组初始化完成
耗时:15ms,结果: 15728640
耗时:152ms,结果: 15728640
复制代码

二、MESI缓存一致性协议

我们上面说了CPU的高速缓存,那么在多核多CPU的情况下,是不是又会引出缓存一致性的问题?

没错,很久之前的方案是加总线锁,但总线锁是不是就浪费了我们的多核多CPU的处理能力?随后而来的解决方案就是我们要说的MESI缓存一致性协议。

MESI每个字母分别代表四种状态,标记的是缓存行,如下:

  • M(Modified修改):对缓存行有效,说明数据被修改了,和内存中的数据不一致。且该数据只存在于当前缓存行。
  • E(Exclusive独占):对缓存行有效,数据和内存的数据一致。且该数据只存在于当前缓存中。
  • S(Share共享):对缓存行有效,数据和内存的数据一致。且该数据也存在于其他缓存中。
  • I(Invalid无效):对缓存行无效,即该数据为垃圾数据。

我们以 i++为例,来看一下具体是怎么执行的:

单核操作: CPU从内存读到数据之后,先放到CPU缓存,状态置为E(独占)状态,然后在寄存器中执行i+1操作,把结果返回给CPU缓存,并把i的状态由E(独占)改为M(修改),然后写会主内存,完成之后i又会置为E(独占)状态

image.png

多核操作

  1. CPU1从内存读到数据i之后,先放到CPU缓存,数据i的状态置为E(独占)状态。
  2. 同时CPU2也从内存中读取数据i, CPU1监测到地址冲突,则CPU1和CPU2上的该数据i都会置为S(共享)状态。
  3. 然后CPU1在寄存器中执行i+1操作,把结果返回给CPU缓存,并把数据i的状态由E(独占)改为M(修改),同时发给总线一个数据已经修改了的消息。
  4. 其他CPU监听到这个消息之后会把对应缓存行的数据置为I(无效)状态,并响应成功(相当于ACK)。
  5. CPU1收到响应成功的消息之后,会把数据写会主内存,完成之后i又会置为E(独占)状态。而其他线程得重新去主内存读取。

image.png

那么大家想想,既然数据状态的切换需要ACK来完成,那是不是可能会因为ACK的时间过长从而导致CPU阻塞呢?

为了避免这种问题,Store Bufferes(存储缓存)被引入使用,CPU把需要写入到内存中的值先写入到Store Bufferes中,然后继续去处理其他指令。当其他CPU都返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题:

  1. Store Bufferes缓存的时间没办法把控
  2. 当前CPU在执行其他指令时,会继续往Store Bufferes中读数据,而Store Bufferes的数据还没有提交,最终会导致结果有误。

三、JMM内存模型

JMM内存模型全称是Java Memory Model,它并不是真实存在的,只是一种抽象的概念,描述的是一组规则或规范,通过这些规范定义了程序中变量的 访问方式。

  1. JMM规定所有的变量都存储在主内存,主内存是共享区域,所有线程都可以访问
  2. 每个线程都有自己的工作内存,各个线程之间相互隔离,线程对变量的操作必须在工作内存中进行,首先将变量从主内存拷贝到自己的工作内存,然后进行读写操作,操作完成后写会主内存

如图:

image.png

那么大家想一想,如果多个线程操作的是同一个变量,同时往主内存写的话,是不是会存在并发问题?从而导致线程不安全?

JMM提供了八大原子操作来解决这个问题(必须按以下顺序执行

  1. lock(锁定): 作用于主内存的变量,把该变量标记为线程独占的状态
  2. read(读取): 读取主内存的变量,以便随后的load操作
  3. load(加载):把read的变量放到工作内存中
  4. use(使用): 作用于工作内存的变量,把工作内存的变量传递给CPU的执行引擎
  5. assign(赋值): 作用于工作内存的变量,把执行引擎处理之后的值传递到工作内存
  6. store(存储): 作用于工作内存的变量,把该变量传递到主内存中,以便随后的write操作
  7. write(写入): 作用于工作内存的变量,把store操作的变量从工作内存传递到主内存
  8. unlock(解锁): 作用于主内存的变量,把该变量从锁定的状态释放出来,释放之后才能被其他线程访问

如图:

image.png

我们来看一下并发编程中的可见性,原子性与有序性的问题

  • 原子性:操作不可中断,即时在多线程环境下,操作一旦开始就不会被其他线程影响。

  • 可见性: 一个线程对共享变量的操作,能够被其他线程立马感知到。

  • 有序性: 不管是单线程还是多线程,代码的执行顺序都是依次执行的(指令重排后的就不是依次执行)

那么JMM模型怎么解决并发编程的原子性、可见性、有序性问题呢?

原子性问题:除了JVM自身提供的Atomic操作基本数据类型以外,还可以通过 synchronizedLock实现原子性。

可见性问题volatile关键字保证可见性,当一个共享变量被volatile修饰后,它能保证某一个线程修改之后的值能够及时被其他线程看到。synchronizedLock也可以保证可见性,因为它们保证同一时间只能有一个线程操作共享资源。

有序性问题volatile关键字保证有序性,synchronizedLock也可以保证。

Happens-Before原则

如上所说,单纯的靠synchronizedvolatile来保证原子性、可见性、有序性,那么开发人员的工作会相当麻烦。我们的JDK爸爸想到了这一点,提供了一套happens-before原则来辅助保证程序执行的原子性、可见性、有序性,具体原则如下:

  1. 程序执行顺序规则:在一个线程内,必须保证按照代码的顺序执行。
  2. 锁规则:同一个锁的情况下,解锁操作必须发生在后一个加锁操作之前。
  3. volatile变量规则volatile变量的写操作必须发生在对这个变量的读操作之前,这保证了volatile的及时可见性。
  4. 传递性规则:如果操作A发生在操作B之前,而操作B又发生在操作C之前,则可以得出操作A发生在操作C之前
  5. 线程启动规则: 线程的start方法发生在此线程任意操作之前。
  6. 线程中断规则:线程的interrupt方法发生在线程中断之前。
  7. 线程终止规则: 线程中的任意操作都发生在线程终止之前。
  8. 对象终结规则:一个对象的初始化完成(构造函数执行结束)发生在它的finalize()方法开始之前。

了解一下指令重排

java语言规定,只要程序的最终结果与它有序执行的结果相等,那么指令的执行顺序可以与代码不一致,这个过程叫做指令重排

指令重排有什么意义

JVM根据CPU的特性(多级缓存、多核处理器)适当的对机器指令进行指令重排,使机器指令更符合CPU的执行特征,最大限度的发挥CPU的作用

指令重排的原则:as-if-serial

意思是不管怎样重排序,单线程的执行结果不能被改变。

四、Volatile关键字

volatile是JVM提供的轻量级的同步机制,有以下两个作用:

  • 保证被volatile修饰的共享变量能及时的被其他线程可见,也就是说当一个线程对volatile修饰的共享变量进行了修改,其他线程能立马感知到。
  • 禁止指令重排序优化

那么volatile的怎么实现这两个功能的呢?

  1. 及时可见性

我们看一段简单的代码:

    private volatile static int a = 1;
复制代码

这段代码啊在生成汇编代码时会在volatile修饰的共享变量进行写操作时,JVM会向CPU发出一条Lock前缀指令,而CPU识别到这个Lock前缀指令,就会触发我们上面所说的缓存一致性协议,通过总线的嗅探机制,及时通知其他处理器该共享变量已经被修改了,使得其他处理器的该变量变为失效状态,并且及时把数据写入主内存。

  1. 禁止指令重排序优化

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和CPU对指令顺序进行重排序,那如果想阻止重排序要怎么办?答案是可以添加内存屏障。

volatile就是通过内存屏障实现了禁止指令重排

Intel硬件级别的内存屏障:

  1. ifence: 是一种Load Barrier读屏障
  2. sfence: 是一种Store Barrier写屏障
  3. mfence: 是一种全能性屏障,具备ifence和sfence的能力
  4. 上面说的Lock前缀:Lock本身不是一种内存屏障,但可以实现类似内存屏障的功能。

不同硬件实现的内存屏障的方式不同,JMM屏蔽了这种硬件的差异,由JVM来为不同硬件平台生成对应的机器码,JVM提供了以下内存屏障:

image.png

java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

image.png

"NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障;
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障;
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序

LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序

LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

如图:

image.png

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改