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

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

5.2.7. 简易 CPU 模型 - 更细粒度的内存屏障

我们前面提到,在我们前面提到的 CPU 模型中,smp_mb() 这个内存屏障指令,做了两件事:等待 CPU 刷完所有的 Store Buffer,等待 CPU 的 Invalidate Queue 全部处理完。但是,对于我们这里 CPU A 与 CPU B 执行的代码中的内存屏障,并不是每次都要这两个操作同时存在:


image.png


所以,一般 CPU 还会抽象出更细粒度的内存屏障指令,我们这里管等待 CPU 刷完所有的 Store Buffer 的指令叫做写内存屏障(Write Memory Buffer),等待 CPU 的 Invalidate Queue 全部处理完的指令叫做读内存屏障(Read Memory Buffer)。

5.2.8. 简易 CPU 模型 - 总结

我们这里通过一个简单的 CPU 架构出发,层层递进,讲述了一些简易的 CPU 结构以及为何会需要内存屏障,可以总结为下面这个简单思路流程图:

  1. CPU 每次直接访问内存太慢,会让 CPU 一直处于 Stall 等待。为了减少 CPU Stall,加入了 CPU 缓存
  2. CPU 缓存带来了多 CPU 间的缓存不一致性,所以通过 MESI 这种简易的 CPU 缓存一致性协议协调不同 CPU 之间的缓存一致性
  3. 对于 MESI 协议中的一些机制进行优化,进一步减少 CPU Stall:
  4. 通过将更新放入 Store Buffer,让更新发出的 Invalidate 消息不用 CPU Stall 等待 Invalidate Response。
  5. Store Buffer 带来了指令(代码)乱序,需要内存屏障指令,强制当前 CPU Stall 等待刷完所有 Store Buffer 中的内容。这个内存屏障指令一般称为写屏障。
  6. 为了加快 Store Buffer 刷入缓存,增加 Invalidate Queue,


5.3. CPU 指令乱序相关


CPU 指令的执行,也可能会乱序,我们这里只说一种比较常见的 - 指令并行化。


5.3.1. 增加 CPU 执行效率 - CPU 流水线模式(CPU Pipeline)

现代 CPU 在执行指令时,是以指令流水线的模式来运行的。因为 CPU 内部也有不同的组件,我们可以将执行一条指令分成不同阶段,不同的阶段涉及的组件不同,这样伪解耦可以让每个组件独立的执行,不用等待一个指令完全执行完再处理下一个指令。

一般分为如下几个阶段:取指(Instrcution Fetch,IF)、译码(Instruction Decode,ID)、执行(Execute,EXE)、存取(Memory,MEM)、写回(Write-Back, WB)


image.png


5.3.2. 进一步降低 CPU Stall - CPU 乱序流水线(Out of order execution Pipeline)

由于指令的数据是否就绪也是不确定的,比如下面这个例子:


image.png


倘若数据 a 没有就绪,还没有载入到寄存器,那么我们其实没必要 Stall 等待加载 a,可以先执行 c = 1; 由此,我们可以将程序中,可以并行的指令提取出来同时安排执行,CPU 乱序流水线(Out of order execution Pipeline)就是基于这种思路:


image.png


如图所示,CPU 的执行阶段分为:

  1. Instructions Fetch:批量拉取一批指令,进行指令分析,分析其中的循环以及依赖,分支预测等等
  2. Instruction Decode:指令译码,与前面的流水线模式大同小异
  3. Reservation stations:需要操作数输入的指令,如果输入就绪,就进入 Functoinal Unit (FU) 处理,如果没有没有就绪就监听 Bypass network,数据就绪发回信号到 Reservation stations,让指令进图 FU 处理。
  4. Functional Unit:处理指令
  5. Reorder Buffer:会将指令按照原有程序的顺序保存,这些指令会在被 dispatched 后添加到列表的一端,而当他们完成执行后,从列表的另一端移除。通过这种方式,指令会按他们 dispatch 的顺序完成。

这样的结构设计下,可以保证写入 Store Buffer 的顺序,与原始的指令顺序一样。但是加载数据,以及计算,是并行执行的。前面我们已经知道了在我们的简易 CPU 架构里面,有着多 CPU 缓存 MESI, Store Buffer 以及 Invalidate Queue 导致读取不到最新的值,这里的乱序并行加载以及处理更加剧了这一点。并且,结构设计下,仅能保证检测出同一个线程下的指令之间的互相依赖,保证这样的互相依赖之间的指令执行顺序是对的,但是多线程程序之间的指令依赖,CPU 批量取指令以及分支预测是无法感知的。所以还是会有乱序。这种乱序,同样可以通过前面的内存屏障避免


5.4. 实际的 CPU


实际的 CPU 多种多样,有着不同的 CPU 结构设计以及不同的 CPU 缓存一致性协议,就会有不同种类的乱序,如果每种单独来看,就太复杂了。所以,大家通过一种标准来抽象描述不同的 CPU 的乱序现象(即第一个操作为 M,第二个操作为 N,这两个操作是否会乱序,是不是很像 Doug Lea 对于 JMM 的描述,其实 Java 内存模型也是参考这个设计的),参考下面这个表格:


微信图片_20220625201945.jpg


我们先来说一下每一列的意思:

  1. Loads Reordered After Loads:第一个操作是读取,第二个也是读取,是否会乱序。
  2. Loads Reordered After Stores:第一个操作是读取,第二个是写入,是否会乱序。
  3. Stores Reordered After Stores:第一个操作是写入,第二个也是写入,是否会乱序。
  4. Stores Reordered After Loads:第一个操作是写入,第二个是读取,是否会乱序。
  5. Atomic Instructions Reordered With Loads:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与读取,这两个互相是否会乱序。
  6. Atomic Instructions Reordered With Stores:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与写入,这两个互相是否会乱序。
  7. Dependent Loads Reordered:如果一个读取依赖另一个读取的结果,是否会乱序。
  8. Incoherent Instruction Cache/Pipeline:是否会有指令乱序执行。

举一个例子来看即我们自己的 PC 上面常用的 x86 结构,在这种结构下,仅仅会发生 Stores Reordered After Loads 以及 Incoherent Instruction Cache/Pipeline。其实后面要提到的 LoadLoad,LoadStore,StoreLoad,StoreStore 这四个 Java 中的内存屏障,为啥在 x86 的环境下其实只需要实现 StoreLoad,其实就是这个原因。


5.5. 编译器乱序


除了 CPU 乱序以外,在软件层面还有编译器优化重排序导致的,其实编译器优化的一些思路与上面说的 CPU 的指令流水线优化其实有些类似。比如编译器也会分析你的代码,对相互不依赖的语句进行优化。对于相互没有依赖的语句,就可以随意的进行重排了。但是同样的,编译器也是只能从单线程的角度去考虑以及分析,并不知道你程序在多线程环境下的依赖以及联系。再举一个简单的例子,假设没有任何 CPU 乱序的环境下,有两个变量 x = 0,y = 0,线程 1 执行:


image.png


线程 2 执行:



image.png


那么线程 2 是可能 assert 失败的,因为编译器可能会让 x = 1y = 1 之间乱序。

编译器乱序,可以通过增加不同操作系统上的编译器屏障语句进行避免。例如线程一执行:


image.png


这样就不会出现 x = 1y = 1 之间乱序的情况。

同时,我们在实际使用的时候,一般内存屏障指的是硬件内存屏障,即通过硬件 CPU 指令实现的内存屏障,这种硬件内存屏障一般也会隐式地带上编译器屏障。编译器屏障一般被称为软件内存屏障,仅仅是控制编译器软件层面的屏障,举一个例子即 C++ 中的 volaile,它与 Java 中的 volatile 不一样, C++ 中的 volatile 仅仅是禁止编译器重排即有编译器屏障,但是无法避免 CPU 乱序。

以上,我们就基本搞清楚了乱序的来源,以及内存屏障的作用。接下来,我们即将步入正题,开始我们的 Java 9+ 内存模型之旅。在这之前,再说一件需要注意的事情:为什么最好不要自己写代码验证 JMM 的一些结论,而是使用专业的框架去测试


6. 为什么最好不要自己写代码验证 JMM 的一些结论


通过前面的一系列分析我们知道,程序乱序的问题错综复杂,假设一段代码,没有任何限制所有可能的输出结果是如下图所示这个全集:


image.png


在 Java 内存模型的限制下,可能的结果被限制到了所有乱序结果中的一个子集:


image.png


在 Java 内存模型的限制下,在不同的 CPU 架构上,CPU 乱序情况不同,有的场景有的 CPU 会乱序,有的则不会,但是都在 JMM 的范围内所以是合理的,这样所有可能的结果集又被限制到 JMM 的一个个不同子集:


image.png


在 Java 内存模型的限制下,在不同的操作系统的编译器编译出来的 JVM 的代码执行顺序不同,底层系统调用定义不同,在不同操作系统执行的 Java 代码又有可能会有些微小的差异,但是由于都在 JMM 的限制范围内,所以也是合理的:


image.png


最后呢,在不同的执行方式以及 JIT 编译下,底层执行的代码还是有差异的,进一步导致了结果集的分化:


image.png


所以,如果你自己编写代码在自己的唯一一台电脑唯一一种操作系统上面去试,那么你所能试出来的结果集只是 JMM 的一个子集,很可能有些乱序结果你是看不到的。并且,有些乱序执行次数少或者没走到 JIT 优化,还看不到,所以,真的不建议你自己写代码去实验。


那么应该怎么做呢?使用较为官方的用来测试并发可见性的框架 - jcstress,这个框架虽然不能模拟不同的 CPU 架构和不同操作系统,但是能让你排除不同执行(解释执行,C1执行,C2执行)以及测试压力不足次数少的原因,后面的所有讲解都会附上对应的 jcstress 代码实例供大家使用。


7. 层层递进可见性与 Java 9+ 内存模型的对应 API


这里主要参考了 Aleksey 大神的思路,去总结出不同层次,层层递进的 Java 中的一些内存可见性限制性质以及对应的 API。Java 9+ 中,将原来的普通变量(非 volatile,final 变量)的普通访问,定义为了 Plain。普通访问,没有对这个访问的地址做任何屏障(不同 GC 的那些屏障,比如分代 GC 需要的指针屏障,不是这里要考虑的,那些屏障只是 GC 层面的,对于这里的可见性没啥影响),会有前面提到的各种乱序。那么 Java 9+ 内存模型中究竟提出了那些限制以及对应这些限制的 API 是啥,我们接下层层递进讲述。


7.1. Coherence(相干性,连贯性)与 Opaque


image.png


这里的标题我不太清楚究竟应该翻译成什么,因为我看网上很多地方把 CPU Cache Coherence Protocol 翻译成了 CPU 缓存一致性协议,即 Coherence 在那种语境下代表一致性,但是我们这里的 Coherence 如果翻译成一致性就不太合适。所以,之后的一些名词我也直接沿用 Doug Lea 大神的以及 Aleksey 大神的定义。

那么这里什么是 coherence 呢?举一个简单的例子:假设某个对象字段 int x 初始为 0,一个线程执行:


image.png


另一个线程执行(r1, r2 为本地变量):


image.png


那么在 Java 内存模型下,可能的结果是包括:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

其中第三个结果很有意思,从程序上理解即我们先看到了 x = 1,之后又看到了 x 变成了 0.当然,通过前面的分析,我们知道实际上是因为编译器乱序。如果我们不想看到这个第三种结果,我们所需要的特性即 coherence。

coherence 的定义,我引用下原文:

The writes to the single memory location appear to be in a total order consistent with program order.

即对单个内存位置的写看上去是按照与程序顺序一致的总顺序进行的。看上去有点难以理解,结合上面的例子,可以这样理解:在全局,x 由 0 变成了 1,那么每个线程中看到的 x 只能从 0 变成 1,而不会可能看到从 1 变成 0.

正如前面所说,Java 内存模型定义中的 Plain 读写,是不能保证 coherence 的。但是如果大家跑一下针对上面的测试代码,会发现跑不出来第三种结果。这是因为 Hotspot 虚拟机中的语义分析会认为这两个对于 x 的读取(load)是互相依赖的,进而限制了这种乱序:


image.png


这就是我在前面一章中提到的,为什么最好不要自己写代码验证 JMM 的一些结论。虽然在 Java 内存模型的限制中,是允许第三种结果 1, 0 的,但是这里通过这个例子是试不出来的。

我们这里通过一个别扭的例子来骗过 Java 编译器造成这种乱序


微信图片_20220625202333.jpg


我们不用太深究其原理,直接看结果:



image.png


发现出现了乱序的结果,并且,如果你自己跑一下这个例子,会发现这个乱序是发生在执行 JIT C2 编译后的 actor2 方法才会出现。

那么如何避免这种乱序呢?使用 volatile 肯定是可以避免的,但是这里我们并不用劳烦 volatile 这种重操作出马,就用 Opaque 访问即可Opaque 其实就是禁止 Java 编译器优化,但是没有涉及任何的内存屏障,和 C++ 中的 volatile 非常类似。测试下:


微信图片_20220625202356.jpg


运行下,可以发现,这个就没有乱序了(命令行如果没有 ACCEPTABLE_INTERESTING,FORBIDDEN,UNKNOWN 的 结果就不会输出了,只能最后看输出的 html):


image.png


7.2. Causality(因果性)与 Acquire/Release


image.png


在 Coherence 的基础上,我们一般在某些场景还会需要 Causality

一般到这里,大家会接触到两个很常见的词,即 happens-before 以及 synchronized-with order,我们这里先不从这两个比较晦涩的概念开始介绍(具体概念介绍不会在这一章节解释),而是通过一个例子,即假设某个对象字段 int x 初始为 0,int y 也初始为 0,这两个字段不在同一个缓存行中后面的 jcstress 框架会自动帮我们进行缓存行填充),一个线程执行:


image.png


另一个线程执行(r1, r2 为本地变量):



image.png


这个例子与我们前面的 CPU 缓存那里的乱序分析举得例子很像,在 Java 内存模型中,可能的结果有:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

同样的,第三个结果也是很有趣的,第二个线程先看到 y 更新,但是没有看到 x 的更新。这个在前面的 CPU 缓存乱序那里我们详细分析,在前面的分析中,我们需要像这样加内存屏障才能避免第三种情况的出现,即:


image.png


以及


image.png


简单回顾下,线程 1 执行 x = 1 之后,在 y = 1 之前执行了写屏障,保证 store buffer 的更新都更新到了缓存,y = 1 之前的更新都保证了不会因为存在 store buffer 中导致不可见。线程 2 执行 int r1 = y 之后执行了读屏障,保证 invalidate queue 中的需要失效的数据全部被失效,保证当前缓存中不会有脏数据。这样,如果线程 2 看到了 y 的更新,就一定能看到 x 的更新。

我们进一步更形象的描述一下:我们把写屏障以及后面的一个 Store(即 y = 1)理解为将前面的更新打包,然后将这个包在这点发射出去,读屏障与前面一个 Load(即 int r1 = y)理解成一个接收点,如果接收到发出的包,就在这里将包打开并读取进来。所以,如下图所示:


image.png


在发射点,会将发射点之前(包括发射点本身的信息)的所有结果打包,如果在执行接收点的代码的时候接收到了这个包,那么在这个接收点之后的所有指令就能看到包里面的所有内容,即发射点之前以及发射点的内容。Causality(因果性),有的地方也叫做 Casual Consistency(因果一致性),它在不同的语境下有不同的含义,我们这里仅特指:可以定义一系列写入操作,如果读取看到了最后一个写入,那么这个读取之后的所有读取操作,都能看到这个写入以及之前的所有写入操作。这是一种 Partial Order(半顺序),而不是 Total Order(全顺序),关于这个定义将在后面的章节详细说明。

在 Java 中,Plain 访问与 Opaque 访问都不能保证 Causality,因为 Plain 没有任何的内存屏障,Opaque 只是有编译器屏障,我们可以通过如下代码测试出来:

首先是 Plain:


image.png


结果是:



image.png


然后是 Opaque:



image.png


这里我们需要注意:由于前面我们看到, x86 CPU 是天然保证一些指令不乱序的,稍后我们就能看到是哪些不乱序保证了这里的 Causality,所以 x86 的 CPU 都看不到乱序,Opaque 访问就能看到因果一致性的结果,如下图所示(AMD64 是一种 x86 的实现):



image.png


但是,如果我们换成其他稍微弱一致一些的 CPU,就能看到 Opaque 访问保证不了因果一致性,下面的结果是我在 aarch64 (是一种 arm 的实现):


image.png


并且,还有一个比较有意思的点,即乱序都是 C2 编译执行的时候发生的

那么,我们如何保证 Causality 呢?同样的,我们同样不必劳烦 volatile 这么重的操作,采用 release/acquire 模式即可。release/acquire 可以保证 Coherence + Causality。release/acquire 必须成对出现(一个 acquire 对应一个 release),可以将 release 视为前面提到的发射点,acquire 视为前面提到的接收点,那么我们就可以像下图这样实现代码:


image.png


image.png


然后,继续在刚刚的 aarch64 的机器上面执行,结果是:



image.png


可以看出,Causuality 由于使用了 Release/Acquire 保证了 Causality。注意,对于发射点和接收点的选取一定要选好,例如这里我们如果换个位置,那么就不对了:

示例一:发射点只会打包之前的所有更新,对于 x = 1 的更新在发射点之后,相当于没有打包进去,所以还是会出现 1,0 的结果。


image.png


示例二:在接收点会解包,从而让后面的读取看到包里面的结果,对于 x 的读取在接收点之前,相当于没有看到包里面的更新,所以还是会出现 1,0 的结果。


image.png


由此,我们类比下 Doug Lea 的 Java 内存屏障设计,来看看这里究竟用了哪些 Java 中设计的内存屏障。在 Doug Lea 的很早也是很经典的一篇文章中,介绍了 Java 内存模型以及其中的内存屏障设计,提出了四种屏障:


1.LoadLoad

如果有两个完全不相干的互不依赖(即可以乱序执行的)的读取(Load),可以通过 LoadLoad 屏障避免它们的乱序执行(即在 Load(x) 执行之前不会执行 Load(y)):


image.png


2.LoadStore

如果有一个读取(Load)以及一个完全不相干的(即可以乱序执行的)的写入(Store),可以通过 LoadStore 屏障避免它们的乱序执行(即在 Load(x) 执行之前不会执行 Store(y)):


image.png


3.StoreStore

如果有两个完全不相干的互不依赖(即可以乱序执行的)的写入(Store),可以通过 StoreStore 屏障避免它们的乱序执行(即在 Store(x) 执行之前不会执行 Store(y)):


image.png


4.StoreLoad

如果有一个写入(Store)以及一个完全不相干的(即可以乱序执行的)的读取(Load),可以通过 LoadStore 屏障避免它们的乱序执行(即在 Store(x) 执行之前不会执行 Load(y)):


image.png


那么如何通过这些内存屏障实现的 Release/Acquire 呢?我们可以通过前面我们的抽象推出来,首先是发射点。发射点首先是一个 Store,并且保证打包前面的所有,那么不论是 Load 还是 Store 都要打包,都不能跑到后面去,所以需要在 Release 的前面加上 LoadStore,StoreStore 两种内存屏障来实现。同理,接收点是一个 Load,并且保证后面的都能看到包里面的值,那么无论 Load 还是 Store 都不能跑到前面去,所以需要在 Acquire 的后面加上 LoadLoad,LoadStore 两种内存屏障来实现

但是呢我们可以在下一章中看到,其实目前来看这四个内存屏障的设计有些过时了(由于 CPU 的发展以及 C++ 语言的发展) ,JVM 内部用的更多的是 acquire,release,fence 这三个。这里的 acquire 以及 release 其实就是我们这里提到的 Release/Acquire。这三个与传统的四屏障的设计的关系是:


image.png


我们这里知道了 Release/Acquire 的内存屏障,x86 为何没有设置这个内存屏障就没有这种乱序呢?参考前面的 CPU 乱序图:


微信图片_20220625202747.jpg


通过这里我们知道,x86 对于 Store 与 Store,Load 与 Load,Load 与 Store 都不会乱序,所以天然就能保证 Casuality



相关文章
|
14天前
|
Java
Java中ReentrantLock释放锁代码解析
Java中ReentrantLock释放锁代码解析
25 8
|
19天前
|
缓存 安全 Java
Java并发编程进阶:深入理解Java内存模型
【4月更文挑战第6天】Java内存模型(JMM)是多线程编程的关键,定义了线程间共享变量读写的规则,确保数据一致性和可见性。主要包括原子性、可见性和有序性三大特性。Happens-Before原则规定操作顺序,内存屏障和锁则保障这些原则的实施。理解JMM和相关机制对于编写线程安全、高性能的Java并发程序至关重要。
|
8天前
|
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,进一步提升开发便捷性。
|
12天前
|
Java
Java 15 神秘登场:隐藏类解析未知领域
Java 15 神秘登场:隐藏类解析未知领域
16 0
|
12天前
|
安全 Java 编译器
接口之美,内部之妙:深入解析Java的接口与内部类
接口之美,内部之妙:深入解析Java的接口与内部类
35 0
接口之美,内部之妙:深入解析Java的接口与内部类
|
17天前
|
存储 算法 安全
深度解析JVM世界:JVM内存分配
深度解析JVM世界:JVM内存分配
|
17天前
|
存储 缓存 安全
【企业级理解】高效并发之Java内存模型
【企业级理解】高效并发之Java内存模型
|
22小时前
|
缓存 Java
【Java基础】简说多线程(上)
【Java基础】简说多线程(上)
5 0
|
1天前
|
并行计算 算法 安全
Java从入门到精通:2.1.3深入学习Java核心技术——掌握Java多线程编程
Java从入门到精通:2.1.3深入学习Java核心技术——掌握Java多线程编程
|
1天前
|
安全 Java 编译器
是时候来唠一唠synchronized关键字了,Java多线程的必问考点!
本文简要介绍了Java中的`synchronized`关键字,它是用于保证多线程环境下的同步,解决原子性、可见性和顺序性问题。从JDK1.6开始,synchronized进行了优化,性能得到提升,现在仍可在项目中使用。synchronized有三种用法:修饰实例方法、静态方法和代码块。文章还讨论了synchronized修饰代码块的锁对象、静态与非静态方法调用的互斥性,以及构造方法不能被同步修饰。此外,通过反汇编展示了`synchronized`在方法和代码块上的底层实现,涉及ObjectMonitor和monitorenter/monitorexit指令。
6 0

推荐镜像

更多