共计 24547 个字符,预计需要花费 62 分钟才能阅读完成。
集体创作公约:自己申明创作的所有文章皆为本人原创,如果有参考任何文章的中央,会标注进去,如果有疏漏,欢送大家批评。如果大家发现网上有剽窃本文章的,欢送举报,并且踊跃向这个 github 仓库 提交 issue,谢谢反对~
本篇文章参考了大量文章,文档以及论文,然而这块货色真的很繁冗,我的程度无限,可能了解的也不到位,如有异议欢送留言提出。本系列会不断更新,联合大家的问题以及这里的谬误和疏漏,欢送大家留言
JMM 相干文档:
- Java Language Specification Chapter 17
- The JSR-133 Cookbook for Compiler Writers – Doug Lea’s
- Using JDK 9 Memory Order Modes – Doug Lea’s
内存屏障,CPU 与内存模型相干:
- Weak vs. Strong Memory Models
- Memory Barriers: a Hardware View for Software Hackers
- A Detailed Analysis of Contemporary ARM and x86 Architectures
- Memory Model = Instruction Reordering + Store Atomicity
- Out-of-Order Execution
x86 CPU 相干材料:
- x86 wiki
- Intel® 64 and IA-32 Architectures Software Developer Manuals
- Formal Specification of the x86 Instruction Set Architecture
ARM CPU 相干材料:
- ARM wiki
- aarch64 Cortex-A710 Specification
各种一致性的了解:
- Coherence and Consistency
Aleskey 大神的 JMM 解说:
- Aleksey Shipilëv – 不要误会 Java 内存模型(上)
- Aleksey Shipilëv – 不要误会 Java 内存模型(下)
置信很多 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,就是具体的实现了,从标准到理论的实现,其实是有肯定的差别的。首先是上面这个代码:
理论 HotSpot 最初编译并且通过 JIT 优化与 CPU 指令优化运行的代码其实是:
行将后果 3 放入寄存器并返回,这样与原始代码其实成果是统一的,省略了无用的本地变量操作,也是正当的。那么你可能会有疑难:不会呀,我打断点运行到这里的时候,能看到本地变量 x,y,result 呀。这个其实是 JVM 运行时做的工作,如果你是以 DEBUG 模式运行 JVM,那么其实 JIT 默认就不会启用,只会简略的解释执行,所以你能看到本地变量。然而理论执行中,如果这个办法是热点办法,通过 JIT 的优化,这些本地变量其实就不存在了。
还有一个例子是,Hotspot 会有锁收缩机制(这个咱们前面还会测试),即:
如果依照 JLS 的形容,那么 x = 1 与 y = 1 这两个操作是不能重排序的。然而 Hotspot 理论的实现会将下面的代码优化成:
那么这样,其实 x = 1 与 y = 1 这两个操作就能够重排序了,这个咱们前面也会验证。
不同的 JVM 实现,理论的体现都会有些差别。并且就算是同一个 JVM 实现,在不同的操作系统,硬件环境等等,体现也有可能不一样。例如上面这个例子:
失常状况下,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 章中的阐明,上面这两个操作,并不是原子性拜访的:
因为大家以后的零碎通常都是 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 中的一个例子:
咱们应用 Java 8 32bit(Java 9 之后就不再反对 32 位的机器了)的 JVM 运行这里的代码,后果是:
能够看到,后果不止 -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 数组),很显著,如果没有同步锁,并发拜访就会并发平安问题从而造成字决裂的问题:
后果是:
这里用了一个不太失当的例子来阐明什么是字决裂,Java 中是能够保障没有字决裂的,对应下面的 BitSet 的例子就是咱们尝试更新一个 boolean 数组,这样后果就只会是 true true:
这个后果只会是 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 缓存,总线与总线事件等等):
CPU 收回读取申请,在内存响应之前,CPU 须要始终期待,无奈解决其余的事件。这一段 CPU 就是 处于 Stall 状态 。如果 CPU 始终间接从内存中读取,CPU 间接拜访内存耗费工夫很长,可能须要 几百个指令周期 ,也就是每次拜访都会有几百个指令周期内 CPU 处于 Stall 状态什么也干不了,这样效率会很低。个别须要引入 若干个高速缓存(Cache)来缩小 Stall:高速缓存即与处理器紧挨着的小型存储器,位于处理器和内存之间。
咱们这里 不关怀多级高速缓存,以及是否存在多个 CPU 共用某一缓存的状况 ,咱们就简略认为是上面这个架构:
当须要读取一个地址的值时,拜访高速缓存看是否存在:存在代表 命中 (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 都共用在同一个总线上,则会有如下这些信息在总线上发送:
- Read:这个事件蕴含要读取的缓存行的物理地址。
- Read Response:蕴含后面的读取事件申请的数据,数据起源可能是 内存或者是其余高速缓存,例如,如果申请的数据在其余缓存处于 modified 状态的话,那么必须从这个缓存读取缓存行数据作为 Read Response
- Invalidate:这个事件蕴含要过期掉的缓存行的物理地址。其余的高速缓存必须移除这个缓存行并且响应 Invalidate Acknowledge 音讯。
- Invalidate Acknowledge:收到 Invalidate 音讯移除掉对应的缓存行之后,回复 Invalidate Acknowledge 音讯。
- Read Invalidate:是 Read 音讯还有 Invalidate 音讯的组合,蕴含要读取的缓存行的物理地址。既读取这个缓存行并且须要 Read Response 音讯响应,同时发给其余的高速缓存,移除这个缓存行并且响应 Invalidate Acknowledge 音讯。
- Writeback:这个音讯蕴含要更新的内存地址以及数据。同时,这个音讯也容许状态为 modified 的缓存行被剔除,以给其余数据腾出空间。
缓存行状态转移与事件 的关系:
这里只是列出这个图,咱们不会深刻去讲的,因为 MESI 是一个十分精简的协定,具体实现的时候会有很多额定的问题 MESI 无奈解决 ,如果具体的去讲,会把读者绕进去,读者会思考在某个极限状况下这个协定要怎么做能力保障正确,然而 MESI 实际上解决不了这些。在 理论的实现中,CPU 一致性协定要比 MESI 简单的多得多,然而个别都是基于 MESI 扩大的。
举一个简略的 MESI 的例子:
1.CPU A 发送 Read 从地址 a 读取数据,收到 Read Response 将数据存入他的高速缓存并将对应的缓存行置为 Exclusive
2.CPU B 发送 Read 从地址 a 读取数据,CPU A 检测到地址抵触,CPU A 响应 Read Response 返回缓存中蕴含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存
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,如下图所示:
有了 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 要执行上面的代码:
咱们必定是冀望最初 b 会等于 2 的。然而真的会如咱们所愿么?咱们来具体看下上面这个运行步骤:
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。
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:
5.2.4. 繁难 CPU 模型 – 防止 Store Buffer 带来的乱序执行 – 内存屏障
咱们上面再来看一个示例:假如有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假如 CPU A(缓存行外面蕴含 b,这个缓存行状态是 Exclusive)执行:
假如 CPU B 执行:
如果所有依照程序程序预期执行,那么咱们冀望 CPU B 执行 assert(a == 1) 是胜利的,然而咱们来看上面这种执行流程:
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 音讯。
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 避免乱序,例如:
smp_mb() 会让 CPU 将 Store Buffer 中的内容刷入缓存。退出这个内存屏障指令后,执行流程变成:
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 音讯。
3.CPU B 执行 smp_mb():
(1)CPU B 将以后 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 构造如下所示:
有了 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))执行:
CPU B(缓存行外面蕴含 a(shared))执行:
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 缓存,所以这里间接通过
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 执行的代码是:
这样,在后面的第 5 步,CPU B 退出循环,执行 assert(a == 1) 之前须要期待 Invalidate queue 解决完:
(1)解决 Invalidate 音讯,将 b 置为 Invalid
(2)持续代码,执行 assert(a == 1),这时候缓存内不存在 b,须要发 Read 音讯,这样就能看到 b 的最新值 1 了,assert 胜利。
5.2.7. 繁难 CPU 模型 – 更细粒度的内存屏障
咱们后面提到,在咱们后面提到的 CPU 模型中,smp_mb()
这个内存屏障指令,做了两件事:期待 CPU 刷完所有的 Store Buffer,期待 CPU 的 Invalidate Queue 全副解决完。然而,对于咱们这里 CPU A 与 CPU B 执行的代码中的内存屏障,并不是每次都要这两个操作同时存在:
所以,个别 CPU 还会形象出更细粒度的内存屏障指令,咱们这里管期待 CPU 刷完所有的 Store Buffer 的指令叫做 写内存屏障 (Write Memory Buffer),期待 CPU 的 Invalidate Queue 全副解决完的指令叫做 读内存屏障(Read Memory Buffer)。
5.2.8. 繁难 CPU 模型 – 总结
咱们这里通过一个简略的 CPU 架构登程,层层递进,讲述了一些繁难的 CPU 构造以及为何会须要内存屏障,能够总结为上面这个简略思路流程图:
- CPU 每次间接拜访内存太慢,会让 CPU 始终处于 Stall 期待。为了缩小 CPU Stall,退出了 CPU 缓存。
- CPU 缓存带来了多 CPU 间的缓存不一致性,所以 通过 MESI 这种繁难的 CPU 缓存一致性协定协调不同 CPU 之间的缓存一致性
对于 MESI 协定中的一些机制进行优化,进一步缩小 CPU Stall:
- 通过将更新放入 Store Buffer,让更新收回的 Invalidate 音讯不必 CPU Stall 期待 Invalidate Response。
- Store Buffer 带来了指令 (代码) 乱序,须要内存屏障指令,强制以后 CPU Stall 期待刷完所有 Store Buffer 中的内容。这个内存屏障指令个别称为写屏障。
- 为了放慢 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)
5.3.2. 进一步升高 CPU Stall – CPU 乱序流水线(Out of order execution Pipeline)
因为指令的数据是否就绪也是不确定的,比方上面这个例子:
假使数据 a 没有就绪,还没有载入到寄存器,那么咱们其实没必要 Stall 期待加载 a,能够先执行 c = 1; 由此,咱们能够将程序中,能够并行的指令提取进去同时安顿执行,CPU 乱序流水线(Out of order execution Pipeline)就是基于这种思路:
如图所示,CPU 的执行阶段分为:
- Instructions Fetch:批量拉取一批指令,进行指令剖析,剖析其中的循环以及依赖,分支预测等等
- Instruction Decode:指令译码,与后面的流水线模式大同小异
- Reservation stations:须要操作数输出的指令,如果输出就绪,就进入 Functoinal Unit (FU) 解决,如果没有没有就绪就监听 Bypass network,数据就绪发回信号到 Reservation stations,让指令进图 FU 解决。
- Functional Unit:解决指令
- 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 内存模型也是参考这个设计的),参考上面这个表格:
咱们先来说一下每一列的意思:
- Loads Reordered After Loads:第一个操作是读取,第二个也是读取,是否会乱序。
- Loads Reordered After Stores:第一个操作是读取,第二个是写入,是否会乱序。
- Stores Reordered After Stores:第一个操作是写入,第二个也是写入,是否会乱序。
- Stores Reordered After Loads:第一个操作是写入,第二个是读取,是否会乱序。
- Atomic Instructions Reordered With Loads:两个操作是原子操作(一组操作,同时产生,例如同时批改两个字这种指令)与读取,这两个相互是否会乱序。
- Atomic Instructions Reordered With Stores:两个操作是原子操作(一组操作,同时产生,例如同时批改两个字这种指令)与写入,这两个相互是否会乱序。
- Dependent Loads Reordered:如果一个读取依赖另一个读取的后果,是否会乱序。
- 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 执行:
线程 2 执行:
那么线程 2 是可能 assert 失败的,因为编译器可能会让 x = 1
与 y = 1
之间乱序。
编译器乱序,能够通过减少不同操作系统上的编译器屏障语句进行防止。例如线程一执行:
这样就不会呈现 x = 1
与 y = 1
之间乱序的状况。
同时,咱们在理论应用的时候,个别内存屏障指的是硬件内存屏障,即通过硬件 CPU 指令实现的内存屏障,这种硬件内存屏障个别也会隐式地带上编译器屏障。编译器屏障个别被称为软件内存屏障,仅仅是管制编译器软件层面的屏障,举一个例子即 C++ 中的 volaile,它与 Java 中的 volatile 不一样,C++ 中的 volatile 仅仅是禁止编译器重排即有编译器屏障,然而无奈防止 CPU 乱序。
以上,咱们就根本搞清楚了乱序的起源,以及内存屏障的作用。接下来,咱们行将步入正题,开始咱们的 Java 9+ 内存模型之旅。在这之前,再说一件须要留神的事件:为什么最好不要本人写代码验证 JMM 的一些论断,而是应用业余的框架去测试
6. 为什么最好不要本人写代码验证 JMM 的一些论断
通过后面的一系列剖析咱们晓得,程序乱序的问题盘根错节,假如一段代码,没有任何限度所有可能的输入后果是如下图所示这个选集:
在 Java 内存模型的限度下,可能的后果被限度到了所有乱序后果中的一个子集:
在 Java 内存模型的限度下,在不同的 CPU 架构上,CPU 乱序状况不同,有的场景有的 CPU 会乱序,有的则不会,然而都在 JMM 的范畴内所以是正当的,这样所有可能的后果集又被限度到 JMM 的一个个不同子集:
在 Java 内存模型的限度下,在不同的操作系统的编译器编译进去的 JVM 的代码执行程序不同,底层零碎调用定义不同,在不同操作系统执行的 Java 代码又有可能会有些渺小的差别,然而因为都在 JMM 的限度范畴内,所以也是正当的:
最初呢,在不同的执行形式以及 JIT 编译下,底层执行的代码还是有差别的,进一步导致了后果集的分化:
所以,如果你本人编写代码在本人的惟一一台电脑惟一一种操作系统下面去试,那么你所能试出来的后果集只是 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
这里的题目我不太分明到底应该翻译成什么,因为我看网上很多中央把 CPU Cache Coherence Protocol 翻译成了 CPU 缓存一致性协定,即 Coherence 在那种语境下代表一致性,然而咱们这里的 Coherence 如果翻译成一致性就不太适合。所以,之后的一些名词我也间接沿用 Doug Lea 大神的以及 Aleksey 大神的定义。
那么这里什么是 coherence 呢?举一个简略的例子:假如某个对象字段 int x 初始为 0,一个线程执行:
另一个线程执行(r1, r2 为本地变量):
那么在 Java 内存模型下,可能的后果是包含:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
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)是相互依赖的,进而限度了这种乱序:
这就是我在后面一章中提到的,为什么最好不要本人写代码验证 JMM 的一些论断。尽管在 Java 内存模型的限度中,是容许第三种后果 1, 0 的,然而这里通过这个例子是试不进去的。
咱们这里通过一个顺当的例子来骗过 Java 编译器造成这种乱序:
咱们不必太深究其原理,间接看后果:
发现呈现了乱序的后果,并且,如果你本人跑一下这个例子,会发现这个乱序是产生在执行 JIT C2 编译后的 actor2 办法才会呈现。
那么如何防止这种乱序呢?应用 volatile 必定是能够防止的,然而这里咱们并不必劳烦 volatile 这种重操作出马,就用 Opaque 拜访即可。Opaque 其实就是禁止 Java 编译器优化,然而没有波及任何的内存屏障,和 C++ 中的 volatile 十分相似。测试下:
运行下,能够发现,这个就没有乱序了(命令行如果没有 ACCEPTABLE_INTERESTING,FORBIDDEN,UNKNOWN 的 后果就不会输入了,只能最初看输入的 html):
7.2. Causality(因果性)与 Acquire/Release
在 Coherence 的根底上,咱们个别在某些场景还会须要 Causality
个别到这里,大家会接触到两个很常见的词,即 happens-before 以及 synchronized-with order,咱们这里先不从这两个比拟艰涩的概念开始介绍(具体概念介绍不会在这一章节解释),而是通过一个例子,即假如某个对象字段 int x 初始为 0,int y 也初始为 0,这两个字段不在同一个缓存行中 ( 前面的 jcstress 框架会主动帮咱们进行缓存行填充),一个线程执行:
另一个线程执行(r1, r2 为本地变量):
这个例子与咱们后面的 CPU 缓存那里的乱序剖析举得例子很像,在 Java 内存模型中,可能的后果有:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
r1 = 0, r2 = 0
同样的,第三个后果也是很乏味的,第二个线程先看到 y 更新,然而没有看到 x 的更新。这个在后面的 CPU 缓存乱序那里咱们详细分析,在后面的剖析中,咱们须要像这样加内存屏障能力防止第三种状况的呈现,即:
以及
简略回顾下,线程 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)了解成一个接收点,如果接管到收回的包,就在这里将包关上并读取进来。所以,如下图所示:
在发射点,会将发射点之前(包含发射点自身的信息)的所有后果打包,如果 在执行接收点的代码的时候接管到了这个包,那么在这个接收点之后的所有指令就能看到包外面的所有内容,即发射点之前以及发射点的内容。Causality(因果性),有的中央也叫做 Casual Consistency(因果一致性),它在不同的语境下有不同的含意,咱们这里仅特指:能够定义一系列写入操作,如果读取看到了最初一个写入,那么这个读取之后的所有读取操作,都能看到这个写入以及之前的所有写入操作。这是一种 Partial Order(半程序),而不是 Total Order(全程序),对于这个定义将在前面的章节具体阐明。
在 Java 中,Plain 拜访与 Opaque 拜访都不能保障 Causality,因为 Plain 没有任何的内存屏障,Opaque 只是有编译器屏障,咱们能够通过如下代码测试进去:
首先是 Plain:
后果是:
而后是 Opaque:
这里咱们须要留神:因为后面咱们看到,x86 CPU 是人造保障一些指令不乱序的,稍后咱们就能看到是哪些不乱序保障了这里的 Causality,所以 x86 的 CPU 都看不到乱序,Opaque 拜访就能看到因果一致性的后果,如下图所示(AMD64 是一种 x86 的实现):
然而,如果咱们换成其余稍强劲统一一些的 CPU,就能看到 Opaque 拜访保障不了因果一致性,上面的后果是我在 aarch64(是一种 arm 的实现):
并且,还有一个比拟有意思的点,即乱序都是 C2 编译执行的时候产生的。
那么,咱们如何保障 Causality 呢?同样的,咱们同样不用劳烦 volatile 这么重的操作,采纳 release/acquire 模式即可。release/acquire 能够保障 Coherence + Causality。release/acquire 必须成对呈现(一个 acquire 对应一个 release),能够将 release 视为后面提到的发射点,acquire 视为后面提到的接收点,那么咱们就能够像下图这样实现代码:
而后,持续在刚刚的 aarch64 的机器下面执行,后果是:
能够看出,Causuality 因为应用了 Release/Acquire 保障了 Causality。留神,对于发射点和接收点的选取肯定要选好,例如这里咱们如果换个地位,那么就不对了:
示例一:发射点只会打包之前的所有更新,对于 x = 1 的更新在发射点之后,相当于没有打包进去,所以还是会呈现 1,0 的后果。
示例二:在接收点会解包,从而让前面的读取看到包外面的后果,对于 x 的读取在接收点之前,相当于没有看到包外面的更新,所以还是会呈现 1,0 的后果。
由此,咱们类比下 Doug Lea 的 Java 内存屏障设计,来看看这里到底用了哪些 Java 中设计的内存屏障。在 Doug Lea 的很早也是很经典的一篇文章中,介绍了 Java 内存模型以及其中的内存屏障设计,提出了四种屏障:
1.LoadLoad
如果有两个齐全不相干的互不依赖(即能够乱序执行的)的读取(Load),能够通过 LoadLoad 屏障防止它们的乱序执行(即在 Load(x) 执行之前不会执行 Load(y)):
2.LoadStore
如果有一个读取(Load)以及一个齐全不相干的(即能够乱序执行的)的写入(Store),能够通过 LoadStore 屏障防止它们的乱序执行(即在 Load(x) 执行之前不会执行 Store(y)):
3.StoreStore
如果有两个齐全不相干的互不依赖(即能够乱序执行的)的写入(Store),能够通过 StoreStore 屏障防止它们的乱序执行(即在 Store(x) 执行之前不会执行 Store(y)):
4.StoreLoad
如果有一个写入(Store)以及一个齐全不相干的(即能够乱序执行的)的读取(Load),能够通过 LoadStore 屏障防止它们的乱序执行(即在 Store(x) 执行之前不会执行 Load(y)):
那么如何通过这些内存屏障实现的 Release/Acquire 呢?咱们能够通过后面咱们的形象推出来,首先是发射点。发射点首先是一个 Store,并且保障打包后面的所有,那么不论是 Load 还是 Store 都要打包,都不能跑到前面去,所以 须要在 Release 的后面加上 LoadStore,StoreStore 两种内存屏障来实现 。同理,接收点是一个 Load,并且保障前面的都能看到包外面的值,那么无论 Load 还是 Store 都不能跑到后面去,所以 须要在 Acquire 的前面加上 LoadLoad,LoadStore 两种内存屏障来实现。
然而呢咱们能够在下一章中看到,其实目前来看这 四个内存屏障的设计有些过期了(因为 CPU 的倒退以及 C++ 语言的倒退),JVM 外部用的更多的是 acquire,release,fence 这三个。这里的 acquire 以及 release 其实就是咱们这里提到的 Release/Acquire。这三个与传统的四屏障的设计的关系是:
咱们这里晓得了 Release/Acquire 的内存屏障,x86 为何没有设置这个内存屏障就没有这种乱序呢?参考后面的 CPU 乱序图:
通过这里咱们晓得,x86 对于 Store 与 Store,Load 与 Load,Load 与 Store 都不会乱序,所以人造就能保障 Casuality
7.3. Consensus(共识性)与 Volatile
最初终于来到咱们所相熟的 Volatile 了,Volatile 其实就是在 Release/Acquire 的根底上,进一步保障了 Consensus;Consensus 即所有线程看到的内存更新程序是统一的,即所有线程看到的内存程序全局统一,举个例子:假如某个对象字段 int x 初始为 0,int y 也初始为 0,这两个字段不在同一个缓存行中 ( 前面的 jcstress 框架会主动帮咱们进行缓存行填充),一个线程执行:
另一个执行:
在 Java 内存模型下,同样可能有 4 种后果:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
r1 = 0, r2 = 0
第四个后果比拟有意思,他是不合乎 Consensus 的,因为两个线程看到的更新程序不一样(第一个线程看到 0 代表他认为 x 的更新是在 y 的更新之前执行的,第二个线程看到 0 代表他认为 y 的更新是在 x 的更新之前执行的)。如果没有乱序,那么必定不会看到 x, y 都是 0,因为线程 1 和线程 2 都是先更新后读取的。然而也正如后面所有的讲述一样,各种乱序造成了咱们能够看大第三个这样的后果。那么 Release/Acquire 是否保障不会呈现这样的后果呢?咱们来简略剖析下,如果对于 x,y 的拜访都是 Release/Acquire 模式的,那么线程 1 理论执行的就是:
这里咱们就可以看进去,x = 1 与 int r1 = y 之间没有任何内存屏障,所以理论可能执行的是:
同理,线程 2 可能执行的是:
或者:
这样,就会造成咱们可能看到第四种后果。咱们通过代码测试下:
测试后果是:
如果要保障 Consensus,咱们只有保障线程 1 的代码与线程 2 的代码不乱序即可,即在本来的内存屏障的根底上,增加 StoreLoad 内存屏障,即线程 1 执行:
线程 2 执行:
这样就能保障不会乱序,这其实就是 volatile 拜访了。Volatile 拜访即在 Release/Acquire 的根底上减少 StoreLoad 屏障,咱们来测试下:
后果是:
那么引出另一个问题,这个 StoreLoad 屏障是 Volatile Store 之后增加,还是 Volatile Load 之前增加呢?咱们来做下这个试验:
首先保留 Volatile Store,将 Volatile Load 改成 Plain Load,即:
测试后果:
从后果中能够看出,依然放弃了 Consensus。再来看保留 Volatile Load,将 Volatile Store 改成 Plain Store:
测试后果:
发现又乱序了。
所以,能够得出结论,这个 StoreLoad 是加在 Volatile 写之后的,在前面的 JVM 底层源码剖析咱们也能看进去。
7.4 Final 的作用
Java 中,创建对象通过调用类的构造函数实现,咱们还可能在构造函数中放一些初始化一些字段的值,例如:
咱们能够这样调用结构器创立一个对象:
咱们合并这些步骤,用伪代码示意底层理论执行的是:
他们之间,没有任何内存屏障,同时依据语义剖析,1 和 5 之间有依赖关系,所以 1 和 5 的前后程序不能变。1,2,3,4 之间有依赖,所以 1,2,3,4 的前后程序也不能变。2,3,4 与 5 之间,没有任何关系,他们之间的执行程序是可能乱序的。如果 5 在 2,3,4 中的任一一步之前执行,那么就会造成咱们可能看到结构器还未执行完,x,y,z 还是初始值的状况。测试下:
在 x86 平台的测试后果,你只会看到两个后果,即 -1, -1, -1(代表没看到对象初始化)和 1, 2, 3(看到对象初始化,并且没有乱序),后果如下图所示(AMD64 是一种 x86 的实现):
这是因为,前文咱们也提到过相似的,x86 CPU 是比拟强一致性的 CPU,这里不会乱序。至于因为 x86 哪种不乱序性质这里才不乱序,咱们前面会看到。
还是和前文一样,咱们换到不那么强一致性的 CPU(ARM)上执行,这里看到的后果就比拟冷落了,如下图所示(aarch64 是一种 ARM 实现):
那咱们如何保障看到结构器执行完的后果呢?
用后面的内存屏障设计,咱们能够把伪代码的第五步改成 setRelease,即:
后面咱们提到过 setRelease 会在后面加上 LoadStore 和 StoreStore 屏障,StoreStore 屏障会避免 2,3,4 与 5 乱序,所以能够防止这个问题,咱们来试试看:
再到后面的 aarch64 机器上试一下,后果是:
从后果能够看出,只能看到要么没初始化,要么残缺的结构器执行后的后果了。
咱们再进一步,其实咱们这里只须要 StoreStore 屏障就够了,由此引出了 Java 的 final 关键字:final 其实就是在更新前面紧接着退出 StoreStore 屏障,这样也相当于在结构器完结之前退出 StoreStore 屏障,保障了只有咱们能看到对象,对象的结构器肯定是执行完了的。测试代码:
咱们再进一步,因为伪代码中 2,3,4 是相互依赖的,所以这里咱们只有保障 4 先于 5 执行,那么 2,3,肯定先于 5 执行,也就是咱们只须要对 z 设置为 final,从而加 StoreStore 内存屏障,而不是每个都申明为 final,从而多加内存屏障:
而后,咱们持续用 aarch64 测试,测试后果仍然是对的:
最初咱们须要留神,final 仅仅是在更新前面加上 StoreStore 屏障,如果你在结构器过程中,将 this 裸露了进来,那么还是会看到 final 的值没有初始化,咱们测试下:
这次咱们在 x86 的机器上就能看到 final 没有初始化:
最初,为何这里的示例中 x86 不须要内存屏障就能实现,参考后面的 CPU 图:
x86 自身 Store 与 Store 之间就不会乱序,人造就有保障。
最初给上表格:
8. 底层 JVM 实现剖析
8.1. JVM 中的 OrderAccess 定义
JVM 中有各种用到内存屏障的中央:
- 实现 Java 的各种语法元素(volatile,final,synchronized,等等)
- 实现 JDK 的各种 API(VarHandle,Unsafe,Thread,等等)
GC 须要的内存屏障:因为要思考 GC 多线程与利用线程(在 GC 算法中叫做 Mutator)的工作形式,到底是进行世界(Stop-the-world,STW)的形式,还是并发的形式
- 对象援用屏障:例如分代 GC,复制算法,年老代 GC 的时候咱们个别是从一个 S 区复制存活对象到另一个 S 区,如果 复制的过程,咱们不想进行世界(Stop-the-world,STW),而是和利用线程同时进行,那么咱们就须要内存屏障,例如;
- 保护屏障:例如分区 GC 算法,咱们须要保护每个区的跨区援用表以及应用情况表,例如 Card Table。这个如果咱们想要利用线程与 GC 线程并发批改拜访,而不是进行世界,那么也须要内存屏障。
- JIT 也须要内存屏障:同样地,利用线程到底是解释执行代码还是执行 JIT 优化后的代码,这里也是须要内存屏障的。
这些内存屏障,不同的 CPU,不同的操作系统,底层须要不同的代码实现,对立的接口设计是:
源代码地址:orderAccess.hpp
不同的 CPU,不同的操作系统实现是不一样的,联合后面 CPU 乱序表格:
咱们来看下 linux + x86 的实现:
源代码地址:orderAccess_linux_x86.hpp
对于 x86,因为 Load 与 Load,Load 与 Store,Store 与 Store 原本有一致性保障,所以只有没有编译器乱序,那么就天生有 StoreStore,LoadLoad,LoadStore 屏障,所以这里咱们看到 StoreStore,LoadLoad,LoadStore 屏障的实现都只是加了编译器屏障。同时,前文中咱们剖析过,acquire 其实就是相当于在 Load 前面加上 LoadLoad,LoadStore 屏障,对于 x86 还是须要编译器屏障就够了。release 咱们前文中也剖析过,其实相当于在 Store 后面加上 LoadStore 和 StoreStore,对于 x86 还是须要编译器屏障就够了。于是,咱们有如下表格:
咱们再看下后面咱们常常应用的 Linux aarch64 下的实现:
源代码地址:orderAccess_linux_aarch64.hpp
如后面表格外面说,ARM 的 CPU Load 与 Load,Load 与 Store,Store 与 Store,Store 与 Load 都会乱序。JVM 针对 aarch64 没有间接应用 CPU 指令,而是应用了 C++ 封装好的内存屏障实现。C++ 封装好的很像咱们后面讲的繁难 CPU 模型的内存屏障,即读内存屏障(__atomic_thread_fence(__ATOMIC_ACQUIRE)
),写内存屏障(__atomic_thread_fence(__ATOMIC_RELEASE)
),读写内存屏障(全内存屏障,__sync_synchronize()
)。acquire 的作用是作为接收点解包让前面的都看到包外面的内容,类比繁难 CPU 模型,其实就是阻塞期待 invalidate queue 齐全解决完保障 CPU 缓存没有脏数据。release 的作用是作为发射点将后面的更新打包收回去,类比繁难 CPU 模型,其实就是阻塞期待 store buffer 齐全刷入 CPU 缓存。所以,acquire,release 别离应用读内存屏障和写内存屏障实现。
LoadLoad 保障第一个 Load 先于第二个,那么其实就是在第一个 Load 前面退出读内存屏障,阻塞期待 invalidate queue 齐全解决完;LoadStore 同理,保障第一个 Load 先于第二个 Store,只有 invalidate queue 解决完,那么以后 CPU 中就没有对应的脏数据了,就不须要期待以后的 CPU 的 store buffer 也清空。
StoreStore 保障第一个 Store 先于第二个,那么其实就是在第一个写入前面放读内存屏障,阻塞期待 store buffer 齐全刷入 CPU 缓存;对于 StoreLoad,比拟非凡,因为第二个 Load 须要看到 Store 的最新值,也就是更新不能只到 store buffer,同时过期不能存在于 invalidate queue 未解决,所以须要读写内存屏障,即全屏障。
8.2. volatile 与 final 的内存屏障源码
咱们接下来看一下 volatile 的内存屏障插入的相干代码,以 arm 为例子. 咱们其实通过跟踪 iload 这个字节码就可以看进去如果 load 的是 volatile 关键字或者 final 关键字润饰的字段会怎么样,以及 istore 就可以看进去如果 store 的是 volatile 关键字或者 final 关键字润饰的字段会怎么样
对于字段拜访,JVM 中也有疾速门路和慢速门路,咱们这里只看疾速门路的代码:
对应源码:
源代码地址:templateTable_arm.cpp
9. 一些 QA
9.1. 为什么看到某些中央在办法本地变量应用 final
对于 本地变量中的 final(和后面提到的修饰字段的 final 不是一回事),这个单纯从语义上讲,其实并没有什么性能方面的思考,仅仅是作为一种标记。即:你可能在办法本地申明很多变量,然而为了语义清晰,就将必定不会改的申明为 final。
JDK 的开发者个别用 final 本地变量来做这样一件事,假如有如下代码:
假如编译器不会做任何优化,那么 1,2,4 咱们都各做了一次对于字段的拜访。如果有编译器优化参加进来,那么是有可能优化成上面的代码的:
这样,只会读取 1 次 x 字段。这样造成的问题是,代码在被解释器执行,不同的 JIT 优化执行的时候,如果 x 有并发的更新,那么看到的可能的后果集是不一样的。为了防止这种歧义,如果咱们确定咱们这里的函数只想读取一次 x,那么就间接写成:
为了标记 lx 是不会变的(同时也为了表白咱们只想读一次 x),加上 final,就变成:
微信搜寻“我的编程喵”关注公众号,加作者微信,每日一刷,轻松晋升技术,斩获各种 offer:
我会常常发一些很好的各种框架的官网社区的新闻视频材料并加上集体翻译字幕到如下地址(也包含下面的公众号),欢送关注:
- 知乎:https://www.zhihu.com/people/…
- B 站:https://space.bilibili.com/31…