万字总结什么是JMM、内存屏障及其原理

您所在的位置:网站首页 java内存屏障原理 万字总结什么是JMM、内存屏障及其原理

万字总结什么是JMM、内存屏障及其原理

2023-08-26 03:28| 来源: 网络整理| 查看: 265

image-20230207174959971

在面试中,面试官经常喜欢问:『说说什么是Java内存模型(JMM)?』

面试者内心狂喜,这题刚背过:『Java内存主要分为五大块:堆、方法区、虚拟机栈、本地方法栈、PC寄存器,balabala……』

面试官会心一笑,露出一道光芒:『好了,今天的面试先到这里了,回去等通知吧』

一般听到等通知这句话,这场面试大概率就是凉凉了。为什么呢?因为面试者弄错了概念,面试官是想考察JMM,但是面试者一听到Java内存这几个关键字就开始背诵八股文了。Java内存模型(JMM)和 Java 运行时内存区域区别可大了呢,不要走开接着往下看,答应我要看完。

为什么要有内存模型?

要想回答这个问题,我们需要先弄懂传统计算机硬件内存架构。好了,我要开始画图了。

硬件内存架构

计算机存储结构,从本地磁盘到主存到CPU缓存,也就是从硬盘到内存,到CPU。 一般对应的程序的操作就是从数据库查数据到内存然后到CPU进行计算

硬件内存架构

(1)CPU

去过机房的同学都知道,一般在大型服务器上会配置多个CPU,每个CPU还会有多个核,这就意味着多个CPU或者多个核可以同时(并发)工作。如果使用Java 起了一个多线程的任务,很有可能每个 CPU 都会跑一个线程,那么你的任务在某一刻就是真正并发执行了。

(2)CPU Register

CPU Register也就是 CPU 寄存器。CPU 寄存器是 CPU 内部集成的,在寄存器上执行操作的效率要比在主存上高出几个数量级。

(3)CPU Cache Memory

CPU Cache Memory也就是 CPU 高速缓存,相对于寄存器来说,通常也可以成为 L2 二级缓存。相对于硬盘读取速度来说内存读取的效率非常高,但是与 CPU 还是相差数量级,所以在 CPU 和主存间引入了多级缓存,目的是为了做一下缓冲。

(4)Main Memory

Main Memory 就是主存,主存比 L1、L2 缓存要大很多。

注意:部分高端机器还有 L3 三级缓存。

缓存一致性问题

由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再讲运算结果同步到主存中。

使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。

缓存一致性问题

在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,CPU 会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。

因此需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等。

处理器优化和指令重排序

为了提升性能在 CPU 和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢?答案是:处理器优化。

为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化。

除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序。

图片

处理器优化其实也是重排序的一种类型,这里总结一下,重排序可以分为三种类型:

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

上面讲了一堆硬件相关的东西,有些同学可能会有点懵,绕了这么大圈,这些东西跟 Java 内存模型有啥关系吗?不要急咱们慢慢往下看。

熟悉 Java 并发的同学肯定对这三个问题很熟悉:『可见性问题』、『原子性问题』、『有序性问题』。如果从更深层次看这三个问题,其实就是上面讲的『缓存一致性』、『处理器优化』、『指令重排序』造成的。

图片

缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题,你看是不是都联系上了。

出了问题总是要解决的,那有什么办法呢?首先想到简单粗暴的办法,干掉缓存让 CPU 直接与主内存交互就解决了可见性问题,禁止处理器优化和指令重排序就解决了原子性和有序性问题,但这样一夜回到解放前了,显然不可取。

所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。

Java内存模型Java Memory Model

因为有这么多级的缓存(cpu和物理主内存的速度不一致的), CPU的运行并不是直接操作内存而是先把内存里边的数据读到缓存,而内存的读和写操作的时候就会造成缓存一致性问题 在这里插入图片描述 Java虚拟机规范中试图定义一种Java内存模型(java Memory Model,简称JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。推导出我们需要知道JMM

JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程(并发)的原子性、可见性和有序性展开的。

原则: JMM的关键技术点都是围绕多线程的原子性、可见性和有序性展开的

能干嘛? 1 通过JMM来实现线程和主内存之间的抽象关系。 2 屏蔽各个硬件平台和操作系统的内存访问差异以实现让Java程序在各种平台下都能达到一致的内存访问效果。

JMM规范下,三大特性 可见性

是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。 可见性 可见性 Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

在这里插入图片描述 线程脏读:如果没有可见性保证 线程脏读:如果没有可见性保证

主内存中有变量 x,初始值为 0线程 A 要将 x 加 1,先将 x=0 拷贝到自己的私有内存中,然后更新 x 的值线程 A 将更新后的 x 值回刷到主内存的时间是不固定的刚好在线程 A 没有回刷 x 到主内存时,线程 B 同样从主内存中读取 x,此时为 0,和线程 A 一样的操作,最后期盼的 x=2 就会变成 x=1 原子性

指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰。

有序性

对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。 指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读",简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。 jMM 有序性 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性,多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

案例 public void mySort() { int x = 11; //语句1 int y = 12; //语句2 x = x + 5; //语句3 y = x * x; //语句4 }

问题:请问语句4可以重排后变成第一个条吗?

JVM 保证单条JVM指令原子性,不保证多条指令原子性

JMM规范下,多线程对变量的读写过程 读取过程

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图: JMM 读取过程

JMM定义了线程和主内存之间的抽象关系

1 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条) 2 每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)

3 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。

4 不同的线程之间无法直接访问对方本地内存中的变量。

看文字太枯燥了,我又画了一张图:

线程间通信

如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作。

为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:

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

注意:工作内存也就是本地内存的意思。

有态度的总结 我们定义的所有共享变量都储存在物理主内存中每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存中读写(不能越级)不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问) JMM规范下,多线程先行发生原则之happens-before

在JMM中,如果一个操作执行的结果需要对另一个操作可见性或者代码重排序,那么这两个操作之间必须存在happens-before关系。

案例说明 x = 5 线程A执行y = x 线程B执行上述称之为:写后读

问题?

y是否等于5呢?

如果线程A的操作(x= 5)happens-before(先行发生)线程B的操作(y = x),那么可以确定线程B执行后y = 5 一定成立;

如果他们不存在happens-before原则,那么y = 5 不一定成立。

这就是happens-before原则的威力。-------------------》包含可见性和有序性的约束

先行发生原则(happens-before)说明

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。

我们没有时时、处处、次次,添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下有一个“先行发生”(Happens-Before)的原则限制和规矩

这个原则非常重要: 它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。

happens-before总原则 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。 比如 1+2+3 = 3+2+1值日。周一张三周二李四,假如有事情调换班可以的 happens-before之8条 次序规则: 一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量X赋值为1,那后面一个操作肯定能知道X已经变成了1。 锁定规则:一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作;volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后。传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;可以通过Thread.interrupted()检测到是否发生中断。线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。对象没有完成初始化之前,是不能调用finalized()方法的 案例说明

image-20230118142315713

假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 可以忽略,因为他们和这段代码毫无关系): 1 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序次序规则; 2 两个方法都没有使用锁,所以不满足锁定规则; 3 变量不是用volatile修饰的,所以volatile变量规则不满足; 4 传递规则肯定不满足;

所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?

方式一: 把getter/setter方法都定义为synchronized方法方式二: 把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景 volatile与Java内存模型 被volatile修改的变量有2大特点

可见性,有序性

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量所有volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。 内存屏障(面试重点必须拿下)

内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。

内存屏障之前的所有写操作都要回写到主内存,

内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。

image-20230118143531659

因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。 一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。

volatile凭内存屏障 (Memory Barriers / Fences)可以保证可见性和有序性

JVM中提供了四类内存屏障指令 四大屏障分别是什么意思

image-20230118144044025

image-20230118144117902

C++源码分析 IDEA工具里面找Unsafe.class

image-20230118143827376

Unsafe.java

image-20230118143855032

Unsafe.cpp

image-20230118143909991

OrderAccess.hpp

image-20230118143953820

orderAccess_linux_x86.inline.hpp

image-20230118144015851

happens-before 之 volatile 变量规则

image-20230118145127439

当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。 JMM 就将内存屏障插⼊策略分为 4 种 写

在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障

在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障

image-20230118145258577

image-20230118145315903

读 在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障 复习下对比图

image-20230118145446733

参考

https://blog.csdn.net/agonie201218/article/details/106792228



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3