Java内存模型(JMM)

Java内存模型(JMM)规定了线程如何访问共享变量,确保多线程环境下的正确性。它通过内存屏障防止指令重排序,利用MESI协议保证缓存一致性,volatile关键字则是实现可见性的一种方式,防止编译器和处理器的优化导致的并发问题。JMM通过happens-before原则保证操作的有序性,确保并发编程的正确性。
摘要由CSDN通过智能技术生成

1、概述

Java内存模型是Java虚拟机为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果从而定义的一套规范。JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
概念区分:JVM内存模式指的是JVM的内存分区;而Java内存模式是一种虚拟机规范。

2、为什么要规定内存模型

1)缓存一致性

计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
在这里插入图片描述

2)指令重排序

指令重排序是指:为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。重排序的类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将依赖性多条指令重叠执行。如果不存在数据,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
    在这里插入图片描述

导致的问题:单线程可以保证该结果与顺序执行的结果是一致的(如果两个操作之间存在数据依赖性,编译器和处理器不会做重排序这个优化),但多线程可能会导致结果不可预期。

public class Singleton {  
    private volatile static Singleton singleton;  

    public static Singleton getSingleton() {  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    //非原子操作:1 为对象分配内存;2 初始化实例对象;3 把引用instance指向分配的内存空间
                    //三个步骤不能保证按序执行,处理器回进行指令重排序优化,可能为1,3,2的顺序,在执行分配内存空间时,别的线程会直接返回还没初始化完毕的instance引用,可能回造成程序奔溃。
                    singleton = new Singleton();  
                }  
            }  
        }  
        return singleton;  
    }  
}
3)缓存一致性和指令重排序怎么解决

方案一:废除编译器和处理器的优化技术、禁止使用 CPU 缓存,让 CPU 直接和主存交互。这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。
方案二:Java内存模型

3、内存交互

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

1)内存模型的结构

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存。实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象,现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成,具体每个cache,实际上是有很多缓存行组成:
在这里插入图片描述

线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
在这里插入图片描述

注:主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中:

在这里插入图片描述

2)交互协议

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

4、缓存一致性的保证

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
    对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

5、重排序下的线程安全:volatile

1)指令集的内存屏障

重排序的问题不止在Java中存在,毕竟一切的尽头都是机器指令,所以只要运行在计算机上都会有这种问题,而指令集针对乱序在多线程时出现的问题做出了拓展,使用内存屏障来重排序。内存屏障,又称内存栅栏,是一个CPU指令。如x86内存屏障如下:

  • sfence: 内存写屏障,保证这条指令前的所有的存储指令必须在这条指令之前执行,并且在执行此条指令时把写入到CPU的私有缓存的数据刷到公有内存(以下均简称主存)
  • lfence: 内存读屏障,保证这条指令后的所有读取指令在这条指令后执行,并且执行此条指令时,清空CPU的读取缓存,也就是说强制接下来的load从主存中取数据
  • mfence: full barrier,代价最大的barrier,有上述两种barrier的效果,当然也是最稳健的的barrier
  • lock: 这个是一种同步指令,也可以禁止lock前的指令和之后的指令重排序(有兴趣的同学可以去看一看这个指令,这个指令稍微复杂一些,可以实现的功能也很多,我就不贴了),lock也许是很多JVM底层使用的指令

注:不存在任何一种指令能够禁止乱序执行,我们能做到的只是把这一堆指令根据”分段”,比如在指令中插入一条full barrier指令,然后所有指令被分成了两段,barrier前面的我们称之为程序段A,后面的称之为程序段B,通过barrier我们能够保证A所有指令的执行都在B之前,也就是说,程序段A和B分别都是乱序执行的。

full barrier;  
instance = new Singleton(); //C
full barrier;
2)Java的抽象

为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad和StoreStore四种:
在这里插入图片描述

3)JVM的处理
屏障点描述
每个volatile写的前面插入一个store-store屏障禁止上面的普通写和下面的volatile写重排序
每个volatile写的后面插入一个store-load屏障禁止上面的volatile写与下面的volatile读/写重排序
每个volatile读的前面插入一个load-load屏障禁止下面的普通读和上面的volatile读重排序
每个volatile读的后面插入一个load-store屏障禁止下面的普通写和上面的volatile读重排序

6、volatile可见性的原理

1)MESI缓存一致性协议
1.四种状态

缓存一致性协议给缓存行(通常为64字节)定义了四个状态用来描述该缓存行是否被多处理器共享、是否修改。所以缓存一致性协议也称MESI协议。MESI表示的四种状态如下所示:

状态描述
M 修改(Modified)此时缓存行中的数据与主内存中的数据不一致,数据只存在于本工作内存中。
其他线程从主内存中读取共享变量值的操作会被延迟执行,直到该缓存行将数据写回到主内存后。
E 独享(Exclusive)此时缓存行中的数据与主内存中的数据一致,数据只存在于本工作内存中。
此时会监听其他线程读主内存中共享变量的操作,如果发生,该缓存行需要变成共享状态
S 共享(Shared)此时缓存行中的数据与主内存中的数据一致,数据存在于很多工作内存中。
此时会监听其他线程使该缓存行无效的请求,如果发生,该缓存行需要变成无效状态
I 无效(Invalid)此时该缓存行无效
2.协议协作

在这里插入图片描述

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
  • 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
  • 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
3.MESI协议状态扭转流程

假如说当前有一个cpu去主内存拿到一个变量x的值初始为1,放到自己的工作内存中。此时它的状态就是独享状态E。
然后此时另外一个cpu也拿到了这个x的值,放到自己的工作内存中。此时之前那个cpu会不断地监听内存总线,发现这个x有多个cpu在获取,那么这个时候这两个cpu所获得的x的值的状态就都是共享状态S。
然后第一个cpu将自己工作内存中x的值带入到自己的ALU计算单元去进行计算,返回来x的值变为2,接着会告诉给内存总线,将此时自己的x的状态置为修改状态M。
而另一个cpu此时也会去不断的监听内存总线,发现这个x已经有别的cpu将其置为了修改状态,所以自己内部的x的状态会被置为无效状态I,等待第一个cpu将修改后的值刷回到主内存后,重新去获取新的值。
注:这个谁先改变x的值可能是同一时刻进行修改的,此时cpu就会通过底层硬件在同一个指令周期内进行裁决,裁决是谁进行修改的,就置为修改状态,而另一个就置为无效状态,被丢弃或者是被覆盖(有争论)。

4.状态的监听

嗅探机制:每个处理器会通过嗅探器来监控总线上的数据来检查自己缓存内的数据是否过期,如果发现自己缓存行对应的地址被修改了,就会将此缓存行置为无效。当处理器对此数据进行操作时,就会重新从主内存中读取数据到缓存行。
总线风暴:在java中使用unsafe实现cas,而其底层由cpp调用汇编指令实现的,如果是多核cpu是使用lock cmpxchg指令,单核cpu 使用compxch指令。如果在短时间内产生大量的cas操作在加上 volatile的嗅探机制则会不断地占用总线带宽,导致总线流量激增,就会产生总线风暴。

2)volatile的作用

多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见。处理器对于失效状态的处理如下:

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

volatile可见性是通过汇编加上Lock前缀指令, lock触发硬件缓存锁定机制从而触发底层的MESI缓存一致性协议来实现的。缓存锁定机制有两种: 总线锁和缓存一致性协议,因为总线索会使CPU无法并行,所以现在使用最普通的是MESI缓存协议。

7、内存模型解决的问题

1)原子性

基本数据的读写是由read、load、assign、use、store和write来保证。更大范围的原子性可以使用lock和unlock来保证,lock和unlock对应字节码指令中的monitorenter和monitorexit,反应到Java代码中就是synchronized关键字。

2)可见性

volatile、synchronize和final关键字

3)有序性

volatile:禁止指令重排序

synchronize:一个变量在同一个时刻只允许一条线程对其进行lock操作。

8、并行发生原则(happens-before)

从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
  • 监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
  • volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
  • 线程启动法则:在同一个线程里,对Thread.start的调用会happens-before于每一个启动线程中的动作。
  • 线程终结法则:线程中的所有动作都happens-before于其它线程检测到这个线程已经终结,即如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断(通过抛出InterruptedException 或者调用isInterrupted和interrupted)
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始 传递性规则:如果 A
    happens-before B,且 B happens-before C,那么A happens-before C。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值