深入理解JMM(Java 内存模型)

MESI缓存一致性协议

为什么要有MESI?

现在的处理器都是多核处理器,并且每个核都带有多个缓存(指令缓存和数据缓存,见下图)。为什么需要缓存呢,这是因为CPU访问内存的速度比较慢,所以在CPU和内存之间加了个缓存以提高访问速度。既然每个核都有缓存,那么假设两个核或者多个核同时访问同一个变量时这些缓存是如何进行同步的呢(缓存细分为一个个缓存行),这就有了MESI协议

MESI来保证多核间Cache的一致性。
在这里插入图片描述

MESI

CPU缓存的最小单位就是Cache Line(缓存行),MESI描述了Cache Line的四种状态。

状态描述监听任务
M 修改(Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Calche中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S (共享)状态之前被延迟执行。
E 独享、互斥(Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其他缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享(Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行业必须监听其他缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效. (Invalid)。
I 无效(Invalid)该Cache line无效

通过一下案例来加深对MESI的理解。

假设有三个CPU1、CPU2、 CPU3,对应三个缓存分别是cache1、cache2、 cache3。 在主内存中定义
了x变量的值为1。CPU1h和CPU2并发的对x进行+1操作。

在这里插入图片描述

  • CPU1从内存读取变量x。CPU1会向总线发送一条读的消息,CPU1读取到数据之后,会将该缓存行cache1的状态设置为E(独享,在底层汇编中加lock信号,保证缓存的一致性)。
  • CPU2读取变量x。再CPU1还没有将x会写入内存的时候,CPU2也发送了一条读x变量的信号,通过总线嗅探机制,CPU1会嗅探到CPU2要读取CPU1中的缓存行对应于内存的区域,那么CPU1的缓存行的状态将会由E转换为S(共享状态),并且CPU2对应的缓存行也是S状态。
  • CPU1修改数据。CPU1又向总线发送消息要求修改x变量,那么CPU1将会锁住该缓存行,并将状态由S改为M(修改状态);CPU2嗅探到CPU1要修改变量x,那么CPU2会将相应的缓存行的状态由S改为I(无效)。
  • 同步数据。当CPU1将数据x写回内存后,其对应缓存行状态由M转换为E,当CPU2再次发送读消息时,CPU1状态由E改为S,CPU2的状态由I改为S

原始MESI协议实现时的性能问题:

1.对于进行本地写事件的CPU,远端CPU处理失效并进行响应确认相对处理器自身的指令执行速度来说是相当耗时的,在等待所有CPU响应的过程中令处理器空转效率并不高。

2.对于响应远程写事件的CPU,在其高速缓存压力很大时,要求实时的处理失效事件也存在一定的困难,会有一定的延迟。

不进行优化的MESI协议在实际工作中效率会非常的低下,因此CPU的设计者在实现时对MESI协议进行了一定的改良。
  
例如:

  1. 本地缓存行将会通过寄存器控制器向远程拥有相同缓存行的寄存器发送一个RFO请求(Request For Owner),告诉其他CPU里面的缓存把缓存里面的值为valid状态,然后待收到各个缓存的(valid ack)已经完成无效状态修改的回应之后,
  2. 再把自己的状态改为Exclusive,之后再进行修改。
  3. 修改后再改为Modified状态,数据写入缓存行。

上面这几步大家可以看到第一步的时候,CPU需要在等待所有的valid ack之后才会进行下面的操作。这部分就会让CPU产生一定的阻塞,无法充分利用CPU。这个时候就印出来了存储缓冲区 storeBuffer。

存储缓存(Store Bufferes)
  针对上述本地写事件需要等待远端处理器ACK确认,阻塞本地处理器的问题,引入了存储缓存机制。
  修改一个变量的时候,直接执行修改的操作不直接镇对缓存行,而是针对一个叫制作storeBuffer的位置来操作的。这样CPU在执行修改操作的时候,**直接把数据写入到storeBuffer里面,并发出广播告知其他CPU,你们的缓存里面需要变为validate状态,然后去执行其他的操作,**等接受到validate ack的时候才会回来把缓冲区里面的值写入到缓存行里面。

在这里插入图片描述

存储缓存是属于每个CPU核心的。当使用了存储缓存后,每当发生本地写事件时,本地CPU不再阻塞的等待远程核的确认响应,而是将写入的新值放入存储缓存中,继续执行后面的指令。存储缓存会替处理器接受远端CPU的ACK确认,当对应本地写事件广播得到了全部远程CPU的确认后,再提交事务,将其新值写入本地高速缓存中。存储缓存的大小是十分有限的,当堆积的事务满了之后,依然会阻塞CPU,直到有事务提交释放出新的空间。

存储缓存的引入将本地写事件—>等待远程写通知确认消息并提交这一事务,从同步、强一致性变成了异步、最终一致性,提高了本地写事件的处理效率。

本地处理器在进行本地读事件时,由于可能存储缓存中新修改的数据还未提交到本地缓存中,这就会造成一个核心内,对于同一缓存行其后续指令的读操作无法读取到之前写操作的最新值。为此,在进行本地读操作时,处理器会先在存储缓存中查询对应记录是否存在,如果存在则会从存储缓存中直接获取,这一机制被称为Store Fowarding。

失效队列(Invalid Queue)
  针对上述远端核心响应远程写事件,实时的将对应缓存行设置为Invalid无效状态延迟高的问题,引入了失效队列机制。

失效队列同样是属于每个CPU核心的。当使用了失效队列后,每当监听到远程写事件时,对应的高速缓存不再同步的处理失效缓存行后返回ACK确认信息,而是将失效通知存入失效队列,立即返回ACK确认消息。对于失效队列中的写失效通知,会在空闲时逐步的进行处理,将对应的高速缓存中的缓存行设置为无效。失效队列的引入在很大程度上缓解了存储缓存空间有限,容易阻塞的问题。

处理失效的缓存也不是简单的,需要读取主存。并且存储缓存也不是无限大的,那么当存储缓存满的时候,处理器还是要等待失效响应的。为了解决上面两个问题,引进了失效队列(invalidate queue0)。

处理失效的工作如下:

  1. 收到失效消息时,放到失效队列中去。
  2. 为了不让处理器久等失效响应,收到失效消息需要马上回复失效响应。
  3. 为了不频繁阻塞处理器,不会马上读主存以及设置缓存为invlid,合适的时候再一块处理失效队列。

在这里插入图片描述

失效队列的引入将监听到远程写事件处理失效缓存行—>返回ACK确认消息这一事务,从同步、强一致性变成了异步、最终一致性,提高了远程写事件的处理效率。

存储缓存和失效队列的引入在提升MESI协议实现的性能同时,也带来了一些问题。由于MESI的高速缓存一致性是建立在强一致性的总线串行事务上的,而存储缓存和失效队列将事务的强一致性弱化为了最终一致性,使得在一些临界点上全局的高速缓存中的数据并不是完全一致的。

即MESI在一个CPU缓存行修改了之后,并没有立即将缓存刷新到主内存,其他CPU缓存行嗅探到了cache的改变重新去主内存获取了旧值。

对于一般的缓存数据,基于异步最终一致的缓存间数据同步不是大问题。但对于并发程序,多核高速缓存间短暂的不一致将会影响共享数据的可见性,使得并发程序的正确性无法得到可靠保证,这是十分致命的。但CPU在执行指令时,缺失了太多的上下文信息,无法识别出缓存中的内存数据是否是并发程序的共享变量,是否需要舍弃性能进行强一致性的同步。

CPU的设计者提供了内存屏障机制将对共享变量读写的高速缓存的强一致性控制权交给了程序的编写者或者编译器,在缓存行修改。

参考链接:https://blog.csdn.net/qq_30055391/article/details/84892936
https://www.cnblogs.com/xiaoxiongcanguan/p/13184801.html

JMM

Java Memory Model简称JMM。
JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式, JMM是围绕原子性、有序性、可见性展开。

在这里插入图片描述

注意: JMMJVM内存区域是不同的概念,JMM是抽象的概念,不是真实的;而JVM内存模型是真实的。JMM与JVM内存区域和前面的多核CPU缓存架构很相似,不过是一种抽象概念。

Java内存模型与硬件内存架构的关系

JMM模型跟CPU缓存模型结构类似,是基于CPU缓存模型建立起来的,JMM模型是标准化的,屏蔽掉了底层不同计算机的区别。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响。

JMM存在的必要性

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。

JMM数据原子操作

8个原子操作描述了 主内存 与 工作内存 之间的交互细节。

  1. lock(锁定): 作用于主内存的变量,把一个变量标记为一条线程独占状态。
  2. unlock(解锁): 作用于主内存的变量,把一-个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取): 作用于主内存的变量,把-一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用): 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
  6. assign(赋值): 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
  7. store(存储): 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  8. write(写入): 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中。

同步规则

  1. 不允许一个线程无原因地(没有发生过任何assign操作) 把数据从工作内存同步回主内存中
  2. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者
    assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
  3. 一个变量在同一时刻只允许一条线程对其进行lock操作, 但lock操作可以被同一线程重复执行多次,
    多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  4. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需
    要重新执行load或assign操作初始化变量的值。
  5. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被
    其他线程锁定的变量。
  6. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

Demo练习

public class JMMDemo {

    public  boolean initFlag = false;
    public void load(){
        String name = Thread.currentThread().getName();
        while (!initFlag){
        }
        System.out.println("线程"+name+"嗅探到initFlag的状态改变");
    }
    public void refresh(){
        String name = Thread.currentThread().getName();
        initFlag = true;
        System.out.println("线程"+name+"将initFlag改变了");
    }
    public static void main(String[] args) {

        JMMDemo jmmDemo = new JMMDemo();
        Thread threadA = new Thread(()->{
            jmmDemo.refresh();
        },"threadA");
        Thread threadB = new Thread(() -> {
            jmmDemo.load();
        },"threadB");
        threadB.start();
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
}

两个线程的工作内存和主内存的交互流程如下:

在这里插入图片描述
运行结果如下:

在这里插入图片描述
说明线程2将数据改了,线程1并不知道,这是由于共享数据不可见导致的。

当对initFlag变量加了Volatile关键字修饰后,运行结果如下:
在这里插入图片描述

volatile原理与内存语义

volatile是Java虚拟机提供的轻量级的同步机制。
volatile语义有如下两个作用:

  • 可见性:保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了被volatile修饰的共享变量的值,新值总是可以被其他线程立即得知。
  • 有序性:禁止指令重排序优化。
可见性

volatile缓存可见性实现原理:

  • JMM内存交互层面: volatile修饰的变量的read. load. use操作和assign. store. write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile的可见性。
  • 底层实现:通过汇编lock前缀指令,它会锁定变量缓存行区域并写回主内存,这个操作称为”缓存锁定”,缓存一致性机制会阻止同时修改两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存失效。

总之,Volatile关键字的底层就是MESI和内存屏障实现的。 被volatile关键字修饰的变量,由于内存屏障底层的lock指令,就会触发MESI协议;开始时,两个线程都获得了数据,因此状态为S,当线程2要修改数据是,状态变为M,由于底层lock指令会立马将工作内存的值刷新到主内存;那么线程2通过总线嗅探机制会嗅探到变量的修改,则线程1的数据状态变为I;将会重新从主内存中获取值,从而实现数据一致性。

在这里插入图片描述
在早期使用的是总线加锁,而不是EMSI。
总线加锁(性能太低)
CPU从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他CPU就无法读或写这个数据,直到这个CPU使用完数据释放锁之后其他CPU才能获取该数据。这样会导致所有的线程会串行化执行,虽然解决了数据一致性问题,但是性能低。

和volatile相比,都有加锁lock,为什么总线加锁性能低?
volatile加锁的粒度极小,只有在数据向主内存写回的时才加锁,其余期间,其他CPU也能访问该数据。

原子性

直接来看一个自增的案例:

private static void atomicDemo() {
        System.out.println("原子性测试");
        MyData myData=new MyData();
        for (int i = 1; i <= 10; i++) {
            new Thread(()->{
                for (int j = 0; j <1000 ; j++) {
                    myData.addPlusPlus();
                }
            },String.valueOf(i)).start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+"\t int type finally number value: "+myData.number);
    }

class MyData{
    int number=0;
    public void addPlusPlus(){
        number++;
    }
}

10个线程,每个线程对number自增1000次;按照我们预期的想法以为最后输出10000,但实际结果总是 <= 10000;结果如下:

运行结果如下:
在这里插入图片描述
或许有的人会想到之前将的 共享数据的内存不可及所导致的,当然也有这个原因,我们加volatile关键字试试:

volatile int number=0;

结果如下:

在这里插入图片描述
加了volatile还是小于10000,这是什么原因呢?------volatile关键字不保证原子性操作。

不保证原子性操作的原因

volatile ++ 的操作分为4个步骤:
load、Increment、store、Memory Barriers 四个操作。

volatile关键字开启了EMSI协议,而EMSI协议的底层也是由内存屏障来实现的。

如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

从Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的。

在某一时刻线程1将number的值(0)load取出来,放置到cpu缓存中,然后再将此值放置到寄存器A中,然后寄存器A中的值自增1,寄存器A中保存的是中间值没有直接修改number(还没有进行assign操作,也就不是M状态),因此其他线程并不会获取到这个自增1的值 。如果在此时线程2也执行同样的操作,获取值number=0,自增1变为1,然后马上刷入主内存。此时由于线程2修改了number的值,实时的线程1中的number=0的值缓存失效,重新从主内存中读取值number=1。接下来线程1恢复。A寄存器先前已经操作过了,不会重新从缓存中获取number进行+1操作,将自增过后的A寄存器值1赋值给cpu缓存number。这样就出现了线程安全问题。

有序性

指令重排序
CPU为了提高程序运行的性能,允许编译器和处理器对指令的执行顺序进行重新编排。java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

指令重排序需要遵守 As-if-serial 语义

As-if-serial 语义

As-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守as-if-serial语义。

指令重排阶段:

  • 编译器优化重排序 :字节码翻译成机器码的阶段。
  • 指令级并行重排序 :CPU处理器的执行阶段。

案例分析

public class VolatileReOrderSample {
   static  int a,b;
   static  int x,y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (;;){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread thread1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread thread2 = new Thread(() -> {
               b = 1;
                y = a;
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            String result = "第"+i+"次("+x+","+y+")";
            if (x==0 && y==0){
                System.err.println(result);
                break;
            }else{
                System.out.println(result);
            }
        }
    }
}

按照正常代码的顺序执行的话,x和y的结果应该有以下可能:
(1,0),(0,1),(1,1)

在程序运行结果中出现了(0,0)的结果
在这里插入图片描述
这就是指令重排的原因,本来a = 1 在 x = b之前执行,重排后可能是a = 1 在 x = b之后执行,那么就可能出现(0,0)的结果。

Volatile关键字可以解决指令重排所导致的乱序的情况。
加了Volatile关键字后,就会按照指令的顺序执行,禁止了指令排序

volatile重排序规则
下图是JMM针对编译器制定的volatile重排序规则表

在这里插入图片描述

  • 当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3)
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前(第二行)
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序(3,2)
  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序(第三列)

volatile如何实现禁止指令重排序?
为了实现volatile关键字的内存语义,编译器在生成字节码时,会在指令的序列中通过插入内存屏障来禁止指令重排序。

内存屏障

内存屏障(memory barrier)是一个CPU指令lock; addl $0,0(%%esp)。它的作用有两个:
a) 确保一些特定操作执行的顺序;
b) 影响一些数据的可见性(可能是某些指令执行后的结果)

编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。在指令插入一个内存屏障,于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

总之,volatile关键字正式通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

内存屏障指令:

  • 写内存屏障(Store Memory Barrier):处理器将当前存储缓存的值写回主存,以阻塞的方式。
  • 读内存屏障(Load Memory Barrier):处理器处理失效队列,以阻塞的方式。

4种类型的内存屏障:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2Load1在Load2之前读取完成
StoreStoreBarriersStore1;StoreStore;Store2Store1在Store2之前写入完成,并对所有处理器可见
LoadStore BarriersLoad1;LoadStore;Store2Load1在Store2 之前读取完成
StoreLoad BarriersStore1;StoreLoad;Load2Store1在Load2 之前写入完成,并对所有处理器可见

下面是基于保守策略的JMM内存屏障插入策略。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个Storel oad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值