关于java:全网最硬核-Java-新内存模型解析与实验-4-Java-新内存访问方式与实验

23次阅读

共计 9224 个字符,预计需要花费 24 分钟才能阅读完成。

集体创作公约:自己申明创作的所有文章皆为本人原创,如果有参考任何文章的中央,会标注进去,如果有疏漏,欢送大家批评。如果大家发现网上有剽窃本文章的,欢送举报,并且踊跃向这个 github 仓库 提交 issue,谢谢反对~

本篇文章参考了大量文章,文档以及论文,然而这块货色真的很繁冗,我的程度无限,可能了解的也不到位,如有异议欢送留言提出。本系列会不断更新,联合大家的问题以及这里的谬误和疏漏,欢送大家留言

如果你喜爱单篇版,请拜访:全网最硬核 Java 新内存模型解析与试验单篇版(不断更新 QA 中)
如果你喜爱这个拆分的版本,这里是目录:

  • 全网最硬核 Java 新内存模型解析与试验 – 1. 什么是 Java 内存模型
  • 全网最硬核 Java 新内存模型解析与试验 – 2. 原子拜访与字决裂
  • 全网最硬核 Java 新内存模型解析与试验 – 3. 硬核了解内存屏障(CPU+ 编译器)
  • 全网最硬核 Java 新内存模型解析与试验 – 4. Java 新内存拜访形式与试验
  • 全网最硬核 Java 新内存模型解析与试验 – 5. JVM 底层内存屏障源码剖析

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 形象的了解有所帮忙。然而,还是强调一点,内存模型的设计,出发点是让大家能够不必关怀底层而形象进去的一些设计,波及的货色很多,我的程度无限,可能了解的也不到位,我会尽量把每一个论点的论据以及参考都摆出来,请大家不要齐全置信这里的所有观点,如果有任何异议欢送带着具体的实例反驳并留言

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 内存模型下,可能的后果是包含:

  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)是相互依赖的,进而限度了这种乱序:

这就是我在后面一章中提到的,为什么最好不要本人写代码验证 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 内存模型中,可能的后果有:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. 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 种后果:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. 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 之间就不会乱序,人造就有保障。

最初给上表格:

微信搜寻“我的编程喵”关注公众号,加作者微信,每日一刷,轻松晋升技术,斩获各种 offer

我会常常发一些很好的各种框架的官网社区的新闻视频材料并加上集体翻译字幕到如下地址(也包含下面的公众号),欢送关注:

  • 知乎:https://www.zhihu.com/people/…
  • B 站:https://space.bilibili.com/31…
正文完
 0