インターネット上のJavaDCLに関するいくつかの誤解と、プログラムの検証と最適化の例を通じてJavaメモリモデルを理解する必要がある理由について話します。

個人作成規約:私が作成するすべての記事は私自身のものであることを宣言します。記事への参照がある場合は、マークが付けられます。欠落がある場合は、批判を歓迎します。この記事の盗用をオンラインで見つけた場合は、それを報告し、このgithubリポジトリに問題を積極的に送信してください。ご支援いただきありがとうございます〜

この記事はOpenJDK11以降に基づいています

この一連の記事は最近爆発的に増えましたネットワーク全体で最もハードコアなJava新しいメモリモデルの分析と実験基盤となるハードウェアから、Javaメモリモデルの設計が包括的に分析され、各結論に関連するリファレンスペーパーが提供されます。検証プログラムでは、Javaメモリモデルについて長年にわたって多くの誤解があることがわかりました。また、多くの人がそのような誤解を持っていることがわかりました。そのため、今回は、従来のDCL(Double Check Locking)プログラムの例を継続的に最適化して支援します。誰もがこの誤解を排除します。

まず、そのようなプログラムがあります。これは、最初に呼び出されたときにのみ初期化されるシングルトン値を実装する必要があり、複数のスレッドがこのシングルトン値にアクセスします。

画像

getValueの実装は、従来のDCL書き込みメソッドです。

Javaメモリモデルの制約の下で、このValueHolderには2つの潜在的な問題があります。

  1. Javaメモリモデルの定義によれば、実際のJVM実装に関係なく、getValueがnullを返す可能性があります。
  2. 初期化されていないValueのフィールド値を読み取ることができます。

以下では、これら2つの問題をさらに分析して最適化します。

Javaメモリモデルの定義によると、実際のJVM実装に関係なく、getValueがnullを返す場合がある理由があります。

記事7.1。コヒーレンス(コヒーレンス、コヒーレンス)と不透明記事7.1で言及しました。コヒーレンス(コヒーレンス、コヒーレンス)と不透明:オブジェクトフィールドint xが最初は0であると仮定すると、1つのスレッドが実行されます:別のスレッドの実行(r1 、r2はローカル変数です):画像画像

次に、これは実際にはフィールドの2回の読み取りです(バイトコードgetfieldに対応します)。Javaメモリモデルでは、考えられる結果は次のとおりです。

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

3番目の結果は非常に興味深いものです。プログラムから、最初にx = 1が表示され、次にxが0になることがわかります。実際、これはコンパイラが故障しているためです。この3番目の結果を見たくない場合、必要なプロパティはコヒーレンスです。通常のフィールドでprivate Value valueあるため、Javaメモリモデルによるとコヒーレンスは保証されません

プログラムに戻ると、次の場所に3つのフィールド読み取り(バイトコードgetfieldに対応)があります。画像

1と2の間には明らかな分岐関係があるため(1の結果に応じて2が実行されるか、実行されない)、コンパイラが何を見ても、最初に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

おすすめ

転載: juejin.im/post/7087027183469723661