通过实例程序验证与优化谈谈网上很多对于Java DCL的一些误解以及为何要理解Java内存模型(上)

简介: 通过实例程序验证与优化谈谈网上很多对于Java DCL的一些误解以及为何要理解Java内存模型(上)
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~本文基于 OpenJDK 11 以上的版本

最近爆肝了这系列文章 全网最硬核 Java 新内存模型解析与实验,从底层硬件,往上全面解析了 Java 内存模型设计,并给每个结论都配有了相关的参考的论文以及验证程序,我发现多年来对于 Java 内存模型有很多误解,并且我发现很多很多人都存在这样的误解,所以这次通过不断优化一个经典的 DCL (Double Check Locking)程序实例来帮助大家消除这个误解。

首先有这样一个程序, 我们想实现一个单例值,只有第一次调用的时候初始化,并且有多线程会访问这个单例值,那么我们会有:



微信图片_20220625211409.jpg


getValue 的实现就是经典的 DCL 写法。

在 Java 内存模型的限制下,这个 ValueHolder 有两个潜在的问题:

  1. 如果根据 Java 内存模型的定义,不考虑实际 JVM 的实现,那么 getValue 是有可能返回 null 的。
  2. 可能读取到没有初始化完成的 Value 的字段值。

下面我们就这两个问题进行进一步分析并优化。


根据 Java 内存模型的定义,不考虑实际 JVM 的实现,getValue 有可能返回 null 的原因


全网最硬核 Java 新内存模型解析与实验 文章的7.1. Coherence(相干性,连贯性)与 Opaque中我们提到过:假设某个对象字段 int x 初始为 0,一个线程执行:


image.png


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



image.png


那么这个实际上是两次对于字段的读取(对应字节码 getfield),在 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。这里由于private Value value是普通的字段,所以根据 Java 内存模型来看并不保证 coherence

回到我们的程序,我们有三次对字段读取(对应字节码 getfield),分别位于:


image.png


由于 1,2 之间有明显的分支关系(2 根据 1 的结果而执行或者不执行),所以无论在什么编译器看来,都要先执行 1 然后执行 2。但是对于 1 和 3,他们之间并没有这种依赖关系,在一些简单的编译器看来,他们是可以乱序执行的。在 Java 内存模型下,也没有限制 1 与 3 之间是否必须不能乱序。所以,可能你的程序先执行 3 的读取,然后执行 1 的读取以及其他逻辑,最后方法返回 3 读取的结果

但是,在 OpenJDK Hotspot 的相关编译器环境下,这个是被避免了的。OpenJDK Hotspot 编译器是比较严谨的编译器,它产生的 1 和 3 的两次读取(针对同一个字段的两次读取)也是两次互相依赖的读取,在编译器维度是不会有乱序的(注意这里说的是编译器维度哈,不是说这里会有内存屏障连可能的 CPU 乱序也避免了,不过这里针对同一个字段读取,前面已经说了仅和编译器乱序有关,和 CPU 乱序无关)

不过,这个仅仅是针对一般程序的写法,我们可以通过一些奇怪的写法骗过编译器,让他任务两次读取没有关系,例如在全网最硬核 Java 新内存模型解析与实验 文章的7.1. Coherence(相干性,连贯性)与 Opaque中的实验环节,OpenJDK Hotspot 对于下面的程序是没有编译器乱序的


image.png


但是如果你换成下面这种写法,就骗过了编译器:


微信图片_20220625211510.jpg


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


image.png


对于 DCL 这种写法,我们也是可以骗过编译器的,但是一般我们不会这么写,这里就不赘述了


可能读取到没有初始化完成的 Value 的字段值


这个就不只是编译器乱序了,还涉及了 CPU 指令乱序以及 CPU 缓存乱序,需要内存屏障解决可见性问题。

我们从 Value 类的构造器入手:


image.png


对于 value = new Value(10); 这一步,将代码分解为更详细易于理解的伪代码则是:


image.png


这中间没有任何内存屏障,根据语义分析,1 与 5 之间有依赖关系,因为 5 依赖于 1 的结果,必须先执行 1 再执行 5。 2 与 3 之间也是有依赖关系的,因为 3 依赖 2 的结果。但是,2和3,与 4,以及 5 这三个之间没有依赖关系,是可以乱序的。我们使用使用代码测试下这个乱序:


微信图片_20220625211602.jpg


虽然在注释中写出了这么编写代码的原因,但是这里还是想强调下这么写的原因:

  1. jcstress 的 @Actor 是使用一个线程执行这个方法中的代码,在测试中,每次会用不同的 JVM 启动参数让这段代码解释执行,C1编译执行,C2编译执行,同时对于 JIT 编译还会修改编译参数让它的编译代码效果不一样。这样我们就可以看到在不同的执行方式下是否会有不同的编译器乱序效果
  2. jcstress 的 @Actor 是使用一个线程执行这个方法中的代码,在每次使用不同的 JVM 测试启动时,会将这个 @Actor 绑定到一个 CPU 执行,这样保证在测试的过程中,这个方法只会在这个 CPU 上执行, CPU 缓存由这个方法的代码独占,这样才能更容易的测试出 CPU 缓存不一致导致的乱序所以,我们的 @Actor 注解方法的数量需要小于 CPU 个数
  3. 我们测试机这里只有两个 CPU,那么只能有两个线程,如果都执行原始代码的话,那么很可能都执行到 synchronized 同步块等待,synchronized 本身有内存屏障的作用(后面会提到)。为了更容易测试出没有走 synchronized 同步块的情况,我们第二个 @Actor 注解的方法直接去掉同步块逻辑,并且如果 value 为 null,我们就设置结果都是 -1 用来区分
相关文章
|
6天前
|
存储 Java 编译器
Java内存区域详解
Java内存区域详解
18 0
Java内存区域详解
|
14天前
|
存储 缓存 算法
优化 Java 后台代码的关键要点
【4月更文挑战第5天】本文探讨了优化 Java 后台代码的关键点,包括选用合适的数据结构与算法、减少不必要的对象创建、利用 Java 8 新特性、并发与多线程处理、数据库和缓存优化、代码分析与性能调优、避免阻塞调用、JVM 调优以及精简第三方库。通过这些方法,开发者可以提高系统性能、降低资源消耗,提升用户体验并减少运营成本。
|
10天前
|
存储 编译器 C语言
深入探索C语言动态内存分配:释放你的程序潜力
深入探索C语言动态内存分配:释放你的程序潜力
26 0
|
7天前
|
Java Maven
【Java报错】显示错误“Error:java: 程序包org.springframework.boot不存在“
【Java报错】显示错误“Error:java: 程序包org.springframework.boot不存在“
30 3
|
13天前
|
缓存 安全 Java
Java并发编程进阶:深入理解Java内存模型
【4月更文挑战第6天】Java内存模型(JMM)是多线程编程的关键,定义了线程间共享变量读写的规则,确保数据一致性和可见性。主要包括原子性、可见性和有序性三大特性。Happens-Before原则规定操作顺序,内存屏障和锁则保障这些原则的实施。理解JMM和相关机制对于编写线程安全、高性能的Java并发程序至关重要。
|
3天前
|
SQL 缓存 Java
Java数据库连接池:优化数据库访问性能
【4月更文挑战第16天】本文探讨了Java数据库连接池的重要性和优势,它能减少延迟、提高效率并增强系统的可伸缩性和稳定性。通过选择如Apache DBCP、C3P0或HikariCP等连接池技术,并进行正确配置和集成,开发者可以优化数据库访问性能。此外,批处理、缓存、索引优化和SQL调整也是提升性能的有效手段。掌握数据库连接池的使用是优化Java企业级应用的关键。
|
3天前
|
存储 缓存 监控
Java内存管理:垃圾回收与内存泄漏
【4月更文挑战第16天】本文探讨了Java的内存管理机制,重点在于垃圾回收和内存泄漏。垃圾回收通过标记-清除过程回收无用对象,Java提供了多种GC类型,如Serial、Parallel、CMS和G1。内存泄漏导致内存无法释放,常见原因包括静态集合、监听器、内部类、未关闭资源和缓存。内存泄漏影响性能,可能导致应用崩溃。避免内存泄漏的策略包括代码审查、使用分析工具、合理设计和及时释放资源。理解这些原理对开发高性能Java应用至关重要。
|
5天前
|
Java 程序员 编译器
Java中的线程同步与锁优化策略
【4月更文挑战第14天】在多线程编程中,线程同步是确保数据一致性和程序正确性的关键。Java提供了多种机制来实现线程同步,其中最常用的是synchronized关键字和Lock接口。本文将深入探讨Java中的线程同步问题,并分析如何通过锁优化策略提高程序性能。我们将首先介绍线程同步的基本概念,然后详细讨论synchronized和Lock的使用及优缺点,最后探讨一些锁优化技巧,如锁粗化、锁消除和读写锁等。
|
5天前
|
Java 编译器
Java并发编程中的锁优化策略
【4月更文挑战第13天】 在Java并发编程中,锁是一种常见的同步机制,用于保证多个线程之间的数据一致性。然而,不当的锁使用可能导致性能下降,甚至死锁。本文将探讨Java并发编程中的锁优化策略,包括锁粗化、锁消除、锁降级等方法,以提高程序的执行效率。
12 4
|
11天前
|
设计模式 缓存 安全
分析设计模式对Java应用性能的影响,并提供优化策略
【4月更文挑战第7天】本文分析了7种常见设计模式对Java应用性能的影响及优化策略:单例模式可采用双重检查锁定、枚举实现或对象池优化;工厂方法和抽象工厂模式可通过对象池和缓存减少对象创建开销;建造者模式应减少构建步骤,简化复杂对象;原型模式优化克隆方法或使用序列化提高复制效率;适配器模式尽量减少使用,或合并多个适配器;观察者模式限制观察者数量并使用异步通知。设计模式需根据应用场景谨慎选用,兼顾代码质量和性能。