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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 全网最硬核 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 编译执行的时候发生的


相关文章
|
10天前
|
人工智能 自然语言处理 Java
FastExcel:开源的 JAVA 解析 Excel 工具,集成 AI 通过自然语言处理 Excel 文件,完全兼容 EasyExcel
FastExcel 是一款基于 Java 的高性能 Excel 处理工具,专注于优化大规模数据处理,提供简洁易用的 API 和流式操作能力,支持从 EasyExcel 无缝迁移。
69 9
FastExcel:开源的 JAVA 解析 Excel 工具,集成 AI 通过自然语言处理 Excel 文件,完全兼容 EasyExcel
|
17天前
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
Java 并发编程——volatile 关键字解析
|
15天前
|
Java 数据库连接 Spring
反射-----浅解析(Java)
在java中,我们可以通过反射机制,知道任何一个类的成员变量(成员属性)和成员方法,也可以堆任何一个对象,调用这个对象的任何属性和方法,更进一步我们还可以修改部分信息和。
|
5天前
|
监控 Java
java异步判断线程池所有任务是否执行完
通过上述步骤,您可以在Java中实现异步判断线程池所有任务是否执行完毕。这种方法使用了 `CompletionService`来监控任务的完成情况,并通过一个独立线程异步检查所有任务的执行状态。这种设计不仅简洁高效,还能确保在大量任务处理时程序的稳定性和可维护性。希望本文能为您的开发工作提供实用的指导和帮助。
42 17
|
15天前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
1天前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
17天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
17天前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
18天前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
42 3
|
18天前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
102 2

推荐镜像

更多