乐趣区

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

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

本文基于 OpenJDK 11 以上的版本

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

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

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

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

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

上面咱们就这两个问题进行进一步剖析并优化。

依据 Java 内存模型的定义,不思考理论 JVM 的实现,getValue 有可能返回 null 的起因

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

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

那么这个实际上是两次对于字段的读取(对应字节码 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),别离位于:

因为 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 对于上面的程序是没有编译器乱序的


然而如果你换成上面这种写法,就骗过了编译器:

咱们不必太深究其原理,间接看其中一个后果:

对于 DCL 这种写法,咱们也是能够骗过编译器的,然而个别咱们不会这么写,这里就不赘述了

可能读取到没有初始化实现的 Value 的字段值

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

咱们从 Value 类的结构器动手:


对于 value = new Value(10); 这一步,将代码合成为更具体易于了解的伪代码则是:

这两头没有任何内存屏障,依据语义剖析,1 与 5 之间有依赖关系,因为 5 依赖于 1 的后果,必须先执行 1 再执行 5。2 与 3 之间也是有依赖关系的,因为 3 依赖 2 的后果。然而,2 和 3,与 4,以及 5 这三个之间没有依赖关系,是能够乱序的。咱们应用应用代码测试下这个乱序:

尽管在正文中写出了这么编写代码的起因,然而这里还是想强调下这么写的起因:

  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 用来辨别

我别离在 x86arm CPU 上测试了这个程序,后果别离是:

x86 – AMD64

arm – aarch64:

咱们能够看到,在比拟强一致性的 CPU 如 x86 中,是没有看到未初始化的字段值的,然而在 arm 这种弱一致性的 CPU 下面,咱们就看到了未初始化的值。在我的另一个系列 – 全网最硬核 Java 新内存模型解析与试验中,咱们也屡次提到了这个 CPU 乱序表格:

在这里,咱们须要的内存屏障是 StoreStore(同时咱们也从下面的表格看出,x86 天生不须要 StoreStore,只有没有编译器乱序的话,CPU 层面是不会乱序的,而 arm 须要内存屏障保障 Store 与 Store 不会乱序),只有这个内存屏障 保障咱们后面伪代码中第 2,3 步在第 5 步前,第 4 步在第 5 步之前即可,那么咱们能够怎么做呢?参考我的那篇全网最硬核 Java 新内存模型解析与试验中各种内存屏障对应关系,咱们能够有如下做法,每种做法咱们都会比照其内存屏障耗费:

1. 应用 final

final 是在赋值语句开端增加 StoreStore 内存屏障,所以咱们只须要在第 2,3 步以及第 4 步开端增加 StoreStore 内存屏障 即把 a2 和 b 设置成 final 即可,如下所示:

对应伪代码:

咱们测试下:

这次在 arm 上的后果是:

如你所见,这次 arm CPU 上也没有看到未初始化的值了。

这里 a1 不须要设置成 final,因为后面咱们说过,2 与 3 之间是有依赖的,能够把他们看成一个整体,只须要整体前面增加好内存屏障即可。然而 这个并不牢靠!!!!因为在某些 JDK 中可能会把这个代码:

优化成这样:

这样 a1, a2 之间就没有依赖了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!所以最好还是所有的变量都设置为 final

然而,这在咱们不能将字段设置为 final 的时候,就不好使了。

2. 应用 volatile,这是大家罕用以及官网举荐的做法

将 value 设置为 volatile 的,在我的另一系列文章 全网最硬核 Java 新内存模型解析与试验中,咱们晓得对于 volatile 写入,咱们通过在写入之前退出 LoadStore + StoreStore 内存屏障,在写入之后退出 StoreLoad 内存屏障实现的,如果把 value 设置为 volatile 的,那么后面的伪代码就变成了:

咱们通过上面的代码测试下:

仍旧在 arm 机器下面测试,后果是:

没有看到未初始化值了

3. 对于 Java 9+ 能够应用 Varhandle 的 acquire/release

后面剖析,咱们其实只须要保障在伪代码第五步之前保障有 StoreStore 内存屏障即可,所以 volatile 其实有点重,咱们能够通过应用 Varhandle 的 acquire/release 这一级别的可见性 api 实现,这样伪代码就变成了:

咱们的测试代码变成了:

测试后果是:

也是没有看到未初始化值了。这种形式是用内存屏障起码,同时不必限度指标类型外面不用应用 final 字段的形式。

4. 一种乏味然而没啥用的思路 – 如果是静态方法,能够通过类加载器机制实现很简便的写法

如果咱们,ValueHolder 外面的办法以及字段能够是 static 的,例如:


将 ValueHolder 作为一个独自的类,或者一个外部类,这样也是能保障 Value 外面字段的可见性的,这是通过类加载器机制实现的,在加载同一个类的时候 (类加载的过程中会初始化 static 字段并且运行 static 块代码),是通过 synchronized 关键字同步块爱护的,参考其中类加载器(ClassLoader.java) 的源码:

ClassLoader.java

对于 syncrhonized 底层对应的 monitorenter 和 monitorexit,monitorenter 与 volatile 读有一样的内存屏障,即在操作之后退出 LoadLoad 和 LoadStore,monitorexit 与 volatile 写有一样的内存屏障,在操作之前退出 LoadStore + StoreStore 内存屏障,在操作之后退出 StoreLoad 内存屏障。所以,也是能保障可见性的。然而这样尽管写起来貌似很简便,效率上更加低(低了很多,类加载须要更多事件)并且不够灵便,只是作为一种扩大常识晓得就好。

总结

  1. DCL 是一种常见的编程模式,对于锁爱护的字段 value 会有两种字段可见性问题:
  2. 如果依据 Java 内存模型的定义,不思考理论 JVM 的实现,那么 getValue 是有可能返回 null 的。然而这个个别都被当初 JVM 设计防止了,这一点咱们在理论编程的时候能够不思考。
  3. 可能读取到没有初始化实现的 Value 的字段值,这个能够通过在结构器实现与赋值给变量之间增加 StoreStore 内存屏障解决。能够通过将 Value 的字段设置为 final 解决,然而不够灵便。
  4. 最简略的形式是将 value 字段设置为 volatile 的,这也是 JDK 中应用的形式,官网也举荐这种
  5. 效率最高的形式是应用 VarHandle 的 release 模式,这个模式只会引入 StoreStore 与 LoadStore 内存屏障,绝对于 volatile 写的内存屏障要少很多(少了 StoreLoad,对于 x86 相当于没有内存屏障,因为 x86 人造有 LoadLoad,LoadStore,StoreStore,x86 仅仅不能人造保障 StoreLoad)

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

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

  • 知乎:https://www.zhihu.com/people/…
  • B 站:https://space.bilibili.com/31…
退出移动版