全网最硬核 Java 新内存模型解析与实验 - 4. Java 新内存访问方式与实验(上)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 全网最硬核 Java 新内存模型解析与实验 - 4. Java 新内存访问方式与实验(上)
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议欢迎留言提出。 本系列会不断更新,结合大家的问题以及这里的错误和疏漏,欢迎大家留言如果你喜欢单篇版,请访问: 全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)如果你喜欢这个拆分的版本,这里是目录:


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 抽象的理解有所帮助。但是,还是强调一点,内存模型的设计,出发点是让大家可以不用关心底层而抽象出来的一些设计,涉及的东西很多,我的水平有限,可能理解的也不到位,我会尽量把每一个论点的论据以及参考都摆出来,请大家不要完全相信这里的所有观点,如果有任何异议欢迎带着具体的实例反驳并留言


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 编译器造成这种乱序



微信图片_20220625205717.jpg


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



image.png


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

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


微信图片_20220625205741.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 编译执行的时候发生的


相关文章
|
3月前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
56 6
|
3月前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
2月前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
4月前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。
|
4月前
|
算法 搜索推荐 Java
java 后端 使用 Graphics2D 制作海报,画echarts图,带工具类,各种细节:如头像切割成圆形,文字换行算法(完美实验success),解决画上文字、图片后不清晰问题
这篇文章介绍了如何使用Java后端技术,结合Graphics2D和Echarts等工具,生成包含个性化信息和图表的海报,并提供了详细的代码实现和GitHub项目链接。
210 0
java 后端 使用 Graphics2D 制作海报,画echarts图,带工具类,各种细节:如头像切割成圆形,文字换行算法(完美实验success),解决画上文字、图片后不清晰问题
|
4月前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
69 1
|
7月前
|
存储 安全 Java
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
128 13
|
7月前
|
Java 程序员 编译器
Java面试题:解释Java内存模型(JMM)是什么,它为何重要?
Java面试题:解释Java内存模型(JMM)是什么,它为何重要?
89 2
|
7月前
|
设计模式 安全 Java
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
102 1
|
7月前
|
安全 Java
Java面试题:解释synchronized关键字在Java内存模型中的语义
Java面试题:解释synchronized关键字在Java内存模型中的语义
59 1

推荐镜像

更多