全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)(上)

简介: 全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)(上)
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议欢迎留言提出。 本系列会不断更新,结合大家的问题以及这里的错误和疏漏,欢迎大家留言JMM 相关文档:


内存屏障,CPU 与内存模型相关:


x86 CPU 相关资料:


ARM CPU 相关资料:


各种一致性的理解:


Aleskey 大神的 JMM 讲解:


相信很多 Java 开发,都使用了 Java 的各种并发同步机制,例如 volatile,synchronized 以及 Lock 等等。也有很多人读过 JSR 第十七章 Threads and Locks(地址:https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html),其中包括同步、Wait/Notify、Sleep & Yield 以及内存模型等等做了很多规范讲解。但是也相信大多数人和我一样,第一次读的时候,感觉就是在看热闹,看完了只是知道他是这么规定的,但是为啥要这么规定,不这么规定会怎么样,并没有很清晰的认识。同时,结合 Hotspot 的实现,以及针对 Hotspot 的源码的解读,我们甚至还会发现,由于 javac 的静态代码编译优化以及 C1、C2 的 JIT 编译优化,导致最后代码的表现与我们的从规范上理解出代码可能的表现是不太一致的。并且,这种不一致,导致我们在学习 Java 内存模型(JMM,Java Memory Model),理解 Java 内存模型设计的时候,如果想通过实际的代码去试,结果是与自己本来可能正确的理解被带偏了,导致误解。

我本人也是不断地尝试理解 Java 内存模型,重读 JLS 以及各路大神的分析。这个系列,会梳理我个人在阅读这些规范以及分析还有通过 jcstress 做的一些实验而得出的一些理解,希望对于大家对 Java 9 之后的 Java 内存模型以及 API 抽象的理解有所帮助。但是,还是强调一点,内存模型的设计,出发点是让大家可以不用关心底层而抽象出来的一些设计,涉及的东西很多,我的水平有限,可能理解的也不到位,我会尽量把每一个论点的论据以及参考都摆出来,请大家不要完全相信这里的所有观点,如果有任何异议欢迎带着具体的实例反驳并留言


1. 理解“规范”与“实现”


首先,我想先参考 Aleksey Shipilëv 大神的理解思路,即首先分清楚规范(Specification)与实现(Implementation)的区别。前面提到的 JLS(Java Language Specification)其实就是一种规范,它规范了 Java 语言,并且所有能编译运行 Java 语言的 JDK 实现都要实现它里面规定的功能。但是对于实际的实现,例如 Hotspot JVM 的 JDK,就是具体的实现了,从规范到实际的实现,其实是有一定的差异的。首先是下面这个代码:


image.png


实际 HotSpot 最后编译并且经过 JIT 优化与 CPU 指令优化运行的代码其实是:


image.png


即将结果 3 放入寄存器并返回,这样与原始代码其实效果是一致的,省略了无用的本地变量操作,也是合理的。那么你可能会有疑问:不会呀,我打断点运行到这里的时候,能看到本地变量 x,y,result 呀。这个其实是 JVM 运行时做的工作,如果你是以 DEBUG 模式运行 JVM,那么其实 JIT 默认就不会启用,只会简单的解释执行,所以你能看到本地变量。但是实际执行中,如果这个方法是热点方法,经过 JIT 的优化,这些本地变量其实就不存在了。

还有一个例子是,Hotspot 会有锁膨胀机制(这个我们后面还会测试),即:


image.png


如果按照 JLS 的描述,那么 x = 1 与 y = 1 这两个操作是不能重排序的。但是 Hotspot 实际的实现会将上面的代码优化成:


image.png


那么这样,其实 x = 1 与 y = 1 这两个操作就可以重排序了,这个我们后面也会验证。

不同的 JVM 实现,实际的表现都会有些差异。并且就算是同一个 JVM 实现,在不同的操作系统,硬件环境等等,表现也有可能不一样。例如下面这个例子:


image.png


正常情况下,r1 的值应该只有 {-1, 0} 这两个结果之一。但是在某些 32 位的 JVM 上执行会有些问题,例如在 x86_32 的环境下,可能会有 {-1, 0, -4294967296, 4294967295} 这些结果。


所以,如果我们要全面的覆盖底层到 JMM 设计以及 Hotspot 实现和 JIT 优化等等等等,涉及的东西太多太多,一层逻辑套逻辑,面面俱到我真的做不到。并且我也没法保证我理解的百分百准确。如果我们要涉及太多的 HotSpot 实现,那么我们可能就偏离了我们这个系列的主题,我们其实主要关心的是 Java 本身内存模型的设计规范,然后从中总结出我们在实际使用中,需要知道并且注意的点的最小集合,这个也是本系列要梳理的,同时,为了保证本系列梳理出的这个最小集合准确,会加上很多实际测试的代码,大家也可以跑一下看看这里给出的结论以及对于 JMM 的理解是否正确。


2. 什么是内存模型


任何需要访问内存的语言,都需要有内存模型,描述如何访问内存:即我可以用哪些方式去写内存,可以用哪些方式去读取内存,不同的写入方式以及读取方式,会有什么不同的表现。当然,如果你的程序是一个简单的串行程序,你读取到的一定是最新写入的值,这样的情况下,其实你并不需要内存模型这种东西。一般是并发的环境下,才会需要内存模型这个东西。


Java 内存模型其实就是规定了在 Java 多线程环境下,以不同的特定方式读取或者写入内存的时候,能观察到内存的合理的值


也有是这么定义 Java 内存的,即 Java 指令是会重排序的,Java 内存模型规定了哪些指令是禁止重排序的,实际上这也是 JLS 第 17 章中 Java 内存模型中的主要内容。这其实也是实现观察到内存的合理的值的方式,即对于给定的源代码,可能的结果集是什么。

我们接下来看两个简单的入门例子,作为热身。分别是原子性访问,以及字分裂。


3. 原子性访问


原子性访问,对于一个字段的写入与读取,这个操作本身是原子的不可分割的。可能大家不经常关注的一点是根据 JLS 第 17 章中的说明,下面这两个操作,并不是原子性访问的:



image.png


因为大家当前的系统通常都是 64 位的,得益于此,这两个操作大多是原子性的了。但是其实根据 Java 的规范,这两个并不是原子性的,在 32 位的系统上就保证不了原子性。我这里直接引用 JLS 第 17 章的一段原话:

For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.Writes and reads of volatile long and double values are always atomic.

翻译过来,简单来说非 volatile 的 long 或者 double 可能会按照两次单独的 32 位写更新,所以是非原子性的。volatile 的 long 或者 double 读取和写入都是原子性的。

为了说明我们这里的原子性,我引用一个 jcstress 中的一个例子:


image.png


我们使用 Java 8 32bit (Java 9 之后就不再支持 32 位的机器了)的 JVM 运行这里的代码,结果是:


image.png


可以看到,结果不止 -1 和 0 这种我们代码中的指定的值,还有一些中间结果。


4. 字分裂(word tearing)


字分裂(word tearing)即你更新一个字段,数组中的一个元素,会影响到另一个字段,数组中的另一个元素的值。例如处理器没有提供写单个 byte 的功能,假设最小维度是 int,在这样的处理器上更新 byte 数组,若只是简单地读取 byte 所在的整个 int,更新对应的 byte,然后将整个 int 再写回,这种做法是有问题的。Java 中没有字分裂现象,字段之间以及数组元素之间是独立的,更新一个字段或元素不能影响任何其它字段或元素的读取与更新。

为了说明什么是字分裂,举一个不太恰当的例子,即线程不安全的 BitSet。BitSet 的抽象是比特位集合(一个一个 0,1 这样,可以理解为一个 boolean 集合),底层实现是一个 long 数组,一个 long 保存 64 个比特位,每次更新都是读取这个 long 然后通过位运算更新对应的比特位,再更新回去。接口层面是一位一位更新,但是底层却是按照 long 的维度更新的(因为是底层 long 数组),很明显,如果没有同步锁,并发访问就会并发安全问题从而造成字分裂的问题:


image.png


结果是:


image.png


这里用了一个不太恰当的例子来说明什么是字分裂,Java 中是可以保证没有字分裂的,对应上面的 BitSet 的例子就是我们尝试更新一个 boolean 数组,这样结果就只会是 true true:


image.png


这个结果只会是 true true

接下来,我们将进入一个比较痛苦的章节了,内存屏障,不过大家也不用太担心,从我个人的经验来看,内存屏障很难理解的原因是因为网上基本上不会从 Java 已经为你屏蔽的底层细节去给你讲,直接理解会很难说服自己,于是就会猜想一些东西然后造成误解,所以本文不会上来丢给你 Doug Lea 抽象的并一直沿用至今的 Java 四种内存屏障(就是 LoadLoad,StoreStore,LoadStore 和 StoreLoad 这四个,其实通过后面的分析也能看出来,这四个内存屏障的设计对于现在的 CPU 来说已经有些过时了,现在用的更多的是 acquire, release 以及 fence)希望能通过笔者看的一些关于底层细节的文章论文中提取出便于大家理解的东西供大家参考,更好地更容易的理解内存屏障。


5. 内存屏障


5.1. 为何需要内存屏障

内存屏障(Memory Barrier),也有叫内存栅栏(Memory Fence),还有的资料直接为了简便,就叫 membar,这些其实意思是一样的。内存屏障主要为了解决指令乱序带来了结果与预期不一致的问题,通过加入内存屏障防止指令乱序(或者称为重排序,reordering)。

那么为什么会有指令乱序呢?主要是因为 CPU 乱序(CPU乱序还包括 CPU 内存乱序以及 CPU 指令乱序)以及编译器乱序。内存屏障可以用于防止这些乱序。如果内存屏障对于编译器和 CPU 都生效,那么一般称为硬件内存屏障,如果只对编译器生效,那么一般被称为软件内存屏障。我们这里主要关注 CPU 带来的乱序,对于编译器的重排序我们会在最后简要介绍下。


5.2. CPU 内存乱序相关


我们从 CPU 高速缓存以及缓存一致性协议出发,开始分析为何 CPU 中会有乱序。我们这里假设一种简易的 CPU 模型请大家一定记住,实际的 CPU 要比这里列举的简易 CPU 模型复杂的多


5.2.1. 简易 CPU 模型 - CPU 高速缓存的出发点 - 减少 CPU Stall

我们在这里会看到,现代的 CPU 的很多设计,一切以减少 CPU Stall 出发。什么是 CPU Stall 呢?举一个简单的例子,假设 CPU 需要直接读取内存中的数据(忽略其他的结构,例如 CPU 缓存,总线与总线事件等等):


image.png


CPU 发出读取请求,在内存响应之前,CPU 需要一直等待,无法处理其他的事情。这一段 CPU 就是处于 Stall 状态。如果 CPU 一直直接从内存中读取,CPU 直接访问内存消耗时间很长,可能需要几百个指令周期,也就是每次访问都会有几百个指令周期内 CPU 处于 Stall 状态什么也干不了,这样效率会很低。一般需要引入若干个高速缓存(Cache)来减少 Stall:高速缓存即与处理器紧挨着的小型存储器,位于处理器和内存之间。

我们这里不关心多级高速缓存,以及是否存在多个 CPU 共用某一缓存的情况,我们就简单认为是下面这个架构:


image.png


当需要读取一个地址的值时,访问高速缓存看是否存在:存在代表命中(hit),直接读取。不存在被称为缺失(miss)。同样的,如果需要写一个值到一个地址,这个地址在缓存中存在也就不需要访问内存了。大部分程序都表现出较高的局部性(locality):

  • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写同一个地址
  • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写附近的地址

针对局部性,高速缓存一般会一次操作不止一个字,而是一组临近的字,称为缓存行

但是呢,由于告诉缓存的存在,就给更新内存带来了麻烦:当一个 CPU 需要更新一块缓存行对应内存的时候,它需要将其他 CPU 缓存中这块内存的缓存行也置为失效。为了维持每个 CPU 的缓存数据一致性,引入了缓存一致性协议(Cache Coherence Protocols)

5.2.2. 简易 CPU 模型 - 一种简单的缓存一致性协议(实际的 CPU 用的要比这个复杂) - MESI

现代的缓存一致性的协议以及算法非常复杂,缓存行可能会有数十种不同的状态。这里我们并不需要研究这种复杂的算法,我们这里引入一个最经典最简单的缓存一致性协议即 4 状态 MESI 协议(再次强调,实际的 CPU 用的协议要比这个复杂,MESI 其实本身有些问题解决不了),MESI 其实指的就是缓存行的四个状态:

  • Modified:缓存行被修改,最终一定会被写回入主存,在此之前其他处理器不能再缓存这个缓存行。
  • Exclusive:缓存行还未被修改,但是其他的处理器不能将这个缓存行载入缓存
  • Shared:缓存行未被修改,其他处理器可以加载这个缓存行到缓存
  • Invalid:缓存行中没有有意义的数据

根据我们前面的 CPU 缓存结构图中所示,假设所有 CPU 都共用在同一个总线上,则会有如下这些信息在总线上发送:

  1. Read:这个事件包含要读取的缓存行的物理地址。
  2. Read Response:包含前面的读取事件请求的数据,数据来源可能是内存或者是其他高速缓存,例如,如果请求的数据在其他缓存处于 modified 状态的话,那么必须从这个缓存读取缓存行数据作为 Read Response
  3. Invalidate:这个事件包含要过期掉的缓存行的物理地址。其他的高速缓存必须移除这个缓存行并且响应 Invalidate Acknowledge 消息。
  4. Invalidate Acknowledge:收到 Invalidate 消息移除掉对应的缓存行之后,回复 Invalidate Acknowledge 消息。
  5. Read Invalidate:是 Read 消息还有 Invalidate 消息的组合,包含要读取的缓存行的物理地址。既读取这个缓存行并且需要 Read Response 消息响应,同时发给其他的高速缓存,移除这个缓存行并且响应 Invalidate Acknowledge 消息。
  6. Writeback:这个消息包含要更新的内存地址以及数据。同时,这个消息也允许状态为 modified 的缓存行被剔除,以给其他数据腾出空间。

缓存行状态转移与事件的关系:


image.png


这里只是列出这个图,我们不会深入去讲的,因为 MESI 是一个非常精简的协议,具体实现的时候会有很多额外的问题 MESI 无法解决,如果详细的去讲,会把读者绕进去,读者会思考在某个极限情况下这个协议要怎么做才能保证正确,但是 MESI 实际上解决不了这些。在实际的实现中,CPU 一致性协议要比 MESI 复杂的多得多,但是一般都是基于 MESI 扩展的

举一个简单的 MESI 的例子:


image.png


1.CPU A 发送 Read 从地址 a 读取数据,收到 Read Response 将数据存入他的高速缓存并将对应的缓存行置为 Exclusive

2.CPU B 发送 Read 从地址 a 读取数据,CPU A 检测到地址冲突,CPU A 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存


image.png


3.CPU B 对于 a 马上要进行写操作,发送 Invalidate,等待 CPU A 的 Invalidate Acknowledge 响应之后,状态修改为 Exclusive。CPU A 收到 Invalidate 之后,将 a 所在的缓存行状态置为 Invalid 失效

4.CPU B 修改数据存储到包含地址 a 的缓存行上,缓存行状态置为 modified

5.这时候 CPU A 又需要 a 数据,发送 Read 从地址 a 读取数据,CPU B 检测到地址冲突,CPU B 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存

我们这里可以看到,MESI 协议中,发送 Invalidate 消息需要当前 CPU 等待其他 CPU 的 Invalidate Acknowledge,也就是这里有 CPU Stall。为了避免这个 Stall,引入了 Store Buffer


5.2.3. 简易 CPU 模型 - 避免等待 Invalidate Response 的 Stall - Store Buffer

为了避免这种 Stall,在 CPU 与 CPU 缓存之间添加 Store Buffer,如下图所示:


image.png


有了 Store Buffer,CPU 在发送 Invalidate 消息的时候,不用等待 Invalidate Acknowledge 的返回,将修改的数据直接放入 Store Buffer。如果收到了所有的 Invalidate Acknowledge 再从 Store Buffer 放入 CPU 的高速缓存的对应缓存行中。但是加入的这个 Store Buffer 又带来了新的问题:

假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0,a 现在位于 CPU A 的缓存行中,b 现在位于 CPU B 的缓存行中:

假设 CPU B 要执行下面的代码:


image.png


我们肯定是期望最后 b 会等于 2 的。但是真的会如我们所愿么?我们来详细看下下面这个运行步骤:


image.png


1.CPU B 执行 a = 1:

(1)由于 CPU B 缓存中没有 a,并且要修改,所以发布 Read Invalidate 消息(因为是要先把包含 a 的整个缓存行读取后才能更新,所以发的是 Read Invalidate,而不只是 Invalidate)。

(2)CPU B 将 a 的修改(a=1)放入 Storage Buffer

(3)CPU A 收到 Read Invalidate 消息,将 a 所在的缓存行标记为 Invalid 并清除出缓存,并响应 Read Response(a=0) 和 Invalidate Acknowlegde


image.png


2.CPU B 执行 b = a + 1:

(1)CPU B 收到来自于 CPU A 的 Read Response,这时候这里面 a 还是等于 0。

(2)CPU B 将 a + 1 的结果(0+1=1)存入缓存中已经包含的 b。

3.CPU B 执行 assert(b == 2) 失败

这个错误的原因主要是我们在加载到缓存的时候没考虑从 store buffer 最新的值,所以我们可以加上一步,在加载到缓存的时候从 store buffer 读取最新的值。这样,就能保证上面我们看到的结果 b 最后是 2:


image.png


5.2.4. 简易 CPU 模型 - 避免 Store Buffer 带来的乱序执行 - 内存屏障

我们下面再来看一个示例:假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假设 CPU A (缓存行里面包含 b,这个缓存行状态是 Exclusive)执行:


image.png


假设 CPU B 执行:


image.png


如果一切按照程序顺序预期执行,那么我们期望 CPU B 执行 assert(a == 1) 是成功的,但是我们来看下面这种执行流程:


image.png


1.CPU A 执行 a = 1:

(1)CPU A 缓存里面没有 a,并且要修改,所以发布 Read Invalidate 消息。

(2)CPU A 将 a 的修改(a=1)放入 Storage Buffer

2.CPU B 执行 while (b == 0) continue:

(1)CPU B 缓存里面没有 b,发布 Read 消息。


image.png


3.CPU A 执行 b = 1:

(1)CPU A 缓存行里面有 b,并且状态是 Exclusive,直接更新缓存行。

(2)之后,CPU A 收到了来自于 CPU B 的关于 b 的 Read 消息。

(3)CPU A 响应缓存中的 b = 1,发送 Read Response 消息,并且缓存行状态修改为 Shared

(4)CPU B 收到 Read Response 消息,将 b 放入缓存

(5)CPU B 代码可以退出循环了,因为 CPU B 看到 b 此时为 1

4.CPU B 执行 assert(a == 1),但是由于 a 的更改还没更新,所以失败了。

像这种乱序,CPU 一般是无法自动控制的,但是一般会提供内存屏障指令,告诉 CPU 防止乱序,例如:


image.png


smp_mb() 会让 CPU 将 Store Buffer 中的内容刷入缓存。加入这个内存屏障指令后,执行流程变成:


image.png


1.CPU A 执行 a = 1:

(1)CPU A 缓存里面没有 a,并且要修改,所以发布 Read Invalidate 消息。

(2)CPU A 将 a 的修改(a=1)放入 Storage Buffer

2.CPU B 执行 while (b == 0) continue:

(1)CPU B 缓存里面没有 b,发布 Read 消息。


image.png


3.CPU A 执行 smp_mb():

(1)CPU A 将当前 Store Buffer 的所有条目打上标记(目前这里只有 a,就是对 a 打上标记)

4.CPU A 执行 b = 1:

(1)CPU A 缓存行里面有 b,并且状态是 Exclusive,但是由于 Store Buffer 中有标记的条目 a,不直接更新缓存行,而是放入 Store Buffer(与 a 不同,没有标记)。并发出 Invalidate 消息。

(2)之后,CPU A 收到了来自于 CPU B 的关于 b 的 Read 消息。

(3)CPU A 响应缓存中的 b = 0,发送 Read Response 消息,并且缓存行状态修改为 Shared

(4)CPU B 收到 Read Response 消息,将 b 放入缓存

(5)CPU B 代码不断循环,因为 CPU B 看到 b 还是 0

(6)CPU A 收到前面对于 a 的 "Read Invalidate" 相关的消息响应,将 Store Buffer 中打好标记的 a 条目刷入缓存,这个缓存行状态为 modified。

(7)CPU B 收到 CPU A 发的 Invalidate b 的消息,将 b 的缓存行失效,回复 Invalidate Acknowledge

(8)CPU A 收到 Invalidate Acknowledge,将 b 从 Store Buffer 刷入缓存。

(9)由于 CPU B 不断读取 b,但是 b 已经不在缓存中了,所以发送 Read 消息。

(10)CPU A 收到 CPU B 的 Read 消息,设置 b 的缓存行状态为 shared,返回缓存中 b = 1 的 Read Response

(11)CPU B 收到 Read Response,得知 b = 1,放入缓存行,状态为 shared

5.CPU B 得知 b = 1,退出 while (b == 0) continue 循环

6.CPU B 执行 assert(a == 1)(这个比较简单,就不画图了):

(1)CPU B 缓存中没有 a,发出 Read 消息。

(2)CPU A 从缓存中读取 a = 1,响应 Read Response

(3)CPU B 执行 assert(a == 1) 成功

Store Buffer 一般都会比较小,如果 Store Buffer 满了,那么还是会发生 Stall 的问题。我们期望 Store Buffer 能比较快的刷入 CPU 缓存,这是在收到对应的 Invalidate Acknowledge 之后进行的。但是,其他的 CPU 可能在忙,没发很快应对收到的 Invalidate 消息并响应 Invalidate Acknowledge,这样可能造成 Store Buffer 满了导致 CPU Stall 的发生。所以,可以引入每个 CPU 的 Invalidate queue 来缓存要处理的 Invalidate 消息。


5.2.5. 简易 CPU 模型 - 解耦 CPU 的 Invalidate 与 Store Buffer - Invalidate Queues

加入 Invalidate Queues 之后,CPU 结构如下所示:


image.png


有了 Invalidate Queue,CPU 可以将 Invalidate 放入这个队列之后立刻将 Store Buffer 中的对应数据刷入 CPU 缓存。同时,CPU 在想主动发某个缓存行的 Invalidate 消息之前,必须检查自己的 Invalidate Queue 中是否有相同的缓存行的 Invalidate 消息。如果有,必须等处理完自己的 Invalidate Queue 中的对应消息再发。

同样的,Invalidate Queue 也带来了乱序执行。


5.2.6. 简易 CPU 模型 - 由于 Invalidate Queues 带来的进一步乱序 - 需要内存屏障

假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假设 CPU A (缓存行里面包含 a(shared), b(Exclusive))执行:


image.png


CPU B(缓存行里面包含 a(shared))执行:



image.png


image.png


1.CPU A 执行 a = 1:

(1)CPU A 缓存里面有 a(shared),CPU A 将 a 的修改(a=1)放入 Store Buffer,发送 Invalidate 消息。

2.CPU B 执行 while (b == 0) continue:

(1)CPU B 缓存里面没有 b,发布 Read 消息。

(2)CPU B 收到 CPU A 的 Invalidate 消息,放入 Invalidate Queue 之后立刻返回。

(3)CPU A 收到 Invalidate 消息的响应,将 Store Buffer 中的缓存行刷入 CPU 缓存

3.CPU A 执行 smp_mb():

(1)因为 CPU A 已经把 Store Buffer 中的缓存行刷入 CPU 缓存,所以这里直接通过


image.png


4.CPU A 执行 b = 1:

(1)因为 CPU A 本身包含 b 的缓存行 (Exclusive),直接更新缓存行即可。

(2)CPU A 收到 CPU B 之前发的 Read 消息,将 b 的缓存行状态更新为 Shared,之后发送 Read Response 包含 b 的最新值

(3)CPU B 收到 Read Response, b 的值为 1

5.CPU B 退出循环,开始执行 assert(a == 1)

(1)由于目前关于 a 的 Invalidate 消息还在 Invalidate queue 中没有处理,所以 CPU B 看到的还是 a = 0,assert 失败

所以,我们针对这种乱序,在 CPU B 执行的代码中也加入内存屏障,这里内存屏障不仅等待 CPU 刷完所有的 Store Buffer,还要等待 CPU 的 Invalidate Queue 全部处理完。加入内存屏障,CPU B 执行的代码是:


image.png


这样,在前面的第 5 步,CPU B 退出循环,执行 assert(a == 1) 之前需要等待 Invalidate queue 处理完:

(1)处理 Invalidate 消息,将 b 置为 Invalid

(2)继续代码,执行 assert(a == 1),这时候缓存内不存在 b,需要发 Read 消息,这样就能看到 b 的最新值 1 了,assert 成功。




相关文章
|
10天前
|
Java
Java中ReentrantLock释放锁代码解析
Java中ReentrantLock释放锁代码解析
25 8
|
3天前
|
Java API 数据库
深入解析:使用JPA进行Java对象关系映射的实践与应用
【4月更文挑战第17天】Java Persistence API (JPA) 是Java EE中的ORM规范,简化数据库操作,让开发者以面向对象方式处理数据,提高效率和代码可读性。它定义了Java对象与数据库表的映射,通过@Entity等注解标记实体类,如User类映射到users表。JPA提供持久化上下文和EntityManager,管理对象生命周期,支持Criteria API和JPQL进行数据库查询。同时,JPA包含事务管理功能,保证数据一致性。使用JPA能降低开发复杂性,但需根据项目需求灵活应用,结合框架如Spring Data JPA,进一步提升开发便捷性。
|
8天前
|
Java
Java 15 神秘登场:隐藏类解析未知领域
Java 15 神秘登场:隐藏类解析未知领域
12 0
|
8天前
|
安全 Java 编译器
接口之美,内部之妙:深入解析Java的接口与内部类
接口之美,内部之妙:深入解析Java的接口与内部类
25 0
接口之美,内部之妙:深入解析Java的接口与内部类

推荐镜像

更多