話す前に、記憶モデルがどうなるかについて話しますか?

皆さんこんにちは、イーアンです!

今日は、Java メモリー・モデルにおけるハプニング・ビフォアについてお話しします。

Java 言語は、最新のプロセッサーの計算能力を最大限に活用するために、その設計の初期にスレッドの概念を導入しました。マルチスレッド メカニズムは、強力で柔軟な利点をもたらすだけでなく、スレッド セーフなどの紛らわしい問題ももたらします。この場合、Java メモリ モデル (JMM) は、混乱の中で合意に達するためのガイドラインを提供してくれます。事前発生を理解する前に、それに関連するいくつかの前菜を見てみましょう

Java メモリ モデルとは何ですか?

Java メモリ モデルは、Java プログラムで共有メモリへのマルチスレッド アクセスの動作を規制するために使用される仕様です。Java 仮想マシン (JVM) がメモリを管理および操作する方法と、マルチスレッド環境でスレッドの安全性を保証する方法について説明します。

なぜ Java メモリ モデルが必要なのですか?

マルチスレッド プログラミングでは、複数のスレッドが同時に共有メモリにアクセスする可能性があるため、予期しない結果が生じる可能性があります。これらの結果には、データ競合、メモリの可視性の問題などが含まれる場合があります。Java メモリー・モデルは、スレッド・セーフなプログラムを作成するのに役立ついくつかの規則と制約を提供します。

Java メモリ モデルのルールと制約

Java メモリ モデルには、主に次の規則と制約が含まれます。

  • アトミック性: Java メモリ モデルは、基本的なデータ型と参照の読み取りおよび書き込み操作がアトミックであることを保証します。つまり、読み取りおよび書き込み操作中にデータの不整合や中断は発生しません。ただし、64 ビットの long 型と double 型の場合、読み取り操作と書き込み操作はアトミックではなく、同期キーワードまたは AtomicLong などのアトミック クラスを使用してスレッド セーフを確保する必要がある場合があります。

  • 可視性: Java メモリ モデルにより、1 つのスレッドが共有変数の値を変更した後、別のスレッドが変更された値をすぐに確認できることが保証されます。可視性を実現するために、Java メモリ モデルは「前発生」関係を使用します。簡単に言えば、操作 A が操作 B の前に発生した場合、操作 B は操作 A の結果を見ることができます。先行発生の関係がない場合、操作 A と操作 B の実行順序は未定義です。

  • 順序性: Java メモリ モデルは、プログラムの実行順序がコードを記述した順序で実行されることを保証します。ただし、マルチスレッド環境では、JVM は操作の順序を変更して、プログラムの実行効率を最適化できます。順序を確実にするために、synchronized キーワードまたは volatile キーワードを使用して、JVM による操作の並べ替えを禁止できます。Java メモリ モデルを簡単に理解した後、事前発生の原則について話しましょう。

簡単な答え

Happen-before 関係は、Java メモリ モデルにおけるマルチスレッド操作の可視性を確保するためのメカニズムであり、初期の言語仕様における可視性のあいまいな概念の正確な定義でもあります。

その特定の兆候には、同期、揮発性、ロック操作シーケンスなどの直感が含まれますが、それ以上のものがあります。以下は、いくつかの具体的な兆候です。

  • スレッドで実行されるすべての操作は、プログラムを作成する際の開発者の基本的な合意である基本的なプログラム シーケンス ルールを保証するハプニング ビフォア後の操作を保証します。

  • 揮発性変数の場合、変数への書き込み操作は、変数の後続の読み取り操作の前に発生することが保証されています。

  • ロックのロック解除操作の場合、ロック操作は事前発生が保証されます。

  • オブジェクトの構築が完了し、ハプニング ビフォアによってファイナライザーのアクションが開始されることが保証されます。

  • スレッドなどの内部操作の完了でさえ、他の Thread.join() スレッドなどの前に発生することを保証します。

これらの先行発生関係は推移的です。a 先行発生 b および b 先行発生 c が満たされる場合、先行発生 c も確立されます。

実行時間を保証するだけでなく、メモリの読み取りおよび書き込み操作の順序も保証するため、私は常に前と後を単に言う代わりに発生前を使用してきました。クロックのシーケンスだけでは、スレッドの相互作用の可視性は保証されません。

問題の解体

今日の質問は、Javaのメモリモデルの基本的な考え方を調べるためのよくある質問ですが、先ほどの回答では、日常の開発に関わるルールをできるだけ選んでいます。

JMM はインタビューのホットスポットであり、Java 並行プログラミング、コンパイラー、および JVM 内部メカニズムを深く理解するための必要条件と見なすことができますが、初心者を混乱させやすいトピックでもあります。JMM を学習するために、いくつかの個人的な提案があります。

  • 明确目的,克制住技术的诱惑。除非你是编译器或者JVM工程师,否则我建议不要一头扎进各种CPU体系结构,纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷,但其复杂性是超乎想象的,很可能会无谓增加学习难度,也未必有实践价值。

  • 克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。

此文,我会结合例子梳理下面两点:

  • 为什么需要JMM,它试图解决什么问题?

  • JMM是如何解决可见性等各种问题的?类似volatile,体现在具体用例中有什么效果?

注意,文中Java内存模型就是特指JSR-133中重新定义的JMM规范。在特定的上下文里,也许会与JVM(Java)内存结构等混淆,并不存在绝对的对错,但一定要清楚面试官的本意,有的面试官也会特意考察是否清楚这两种概念的区别。

引申

为什么需要JMM,它试图解决什么问题?

Java是最早尝试提供内存模型的语言,这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似C、C++等语言,并不存在内存模型的概念(C++ 11中也引入了标准内存模型),其行为依赖于处理器本身的 内存一致性模型,但不同的处理器可能差异很大,所以一段C++程序在处理器A上运行正常,并不能保证其在处理器B上也是一致的。

即使如此,最初的Java语言规范仍然是存在着缺陷的,当时的目标是,希望Java程序可以充分利用现代硬件的计算能力,同时保持“书写一次,到处执行”的能力。

但是,显然问题的复杂度被低估了,随着Java被运行在越来越多的平台上,人们发现,过于泛泛的内存模型定义,存在很多模棱两可之处,对synchronized或volatile等,类似指令重排序时的行为,并没有提供清晰规范。这里说的指令重排序,既可以是 编译器优化行为,也可能是源自于现代处理器的 乱序执行 等。

换句话说:

  • 既不能保证一些多线程程序的正确性,例如最著名的就是双检锁(Double-Checked Locking,DCL)的失效问题,具体可以参考我在 第14讲 对单例模式的说明,双检锁可能导致未完整初始化的对象被访问,理论上这叫并发编程中的安全发布(Safe Publication)失败。

  • 也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。

所以,Java迫切需要一个完善的JMM,能够让普通Java开发者和编译器、JVM工程师,能够 清晰地 达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。

所以:

  • 对于编译器、JVM开发者,关注点可能是如何使用类似 内存屏障(Memory-Barrier)之类技术,保证执行结果符合JMM的推断。

  • 对于Java应用开发者,则可能更加关注volatile、synchronized等语义,如何利用类似happen-before的规则,写出可靠的多线程应用,而不是利用一些“秘籍”去糊弄编译器、JVM。

我画了一个简单的角色层次图,不同工程师分工合作,其实所处的层面是有区别的。JMM为Java工程师隔离了不同处理器内存排序的区别,这也是为什么我通常不建议过早深入处理器体系结构,某种意义上来说,这样本就违背了JMM的初衷。

alt

JMM是怎么解决可见性等问题的呢?

JVM内部的运行时数据区,真正程序执行,实际是要跑在具体的处理器内核上。你可以简单理解为,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。你可以从下面示意图,看这两种模型的对应。

alt

看上去很美好,但是当多线程共享变量时,情况就复杂了。试想,如果处理器对某个共享变量进行了修改,可能只是体现在该内核的缓存里,这是个本地状态,而运行在其他内核上的线程,可能还是加载的旧状态,这很可能导致一致性的问题。从理论上来说,多线程共享引入了复杂的数据依赖性,不管编译器、处理器怎么做重排序,都必须尊重数据依赖性的要求,否则就打破了正确性!这就是JMM所要解决的问题。

JMM内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种happen-before规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。

我以volatile为例,看看如何利用内存屏障实现JMM定义的可见性?

对于一个volatile变量:

  • 对该变量的写操作 之后,编译器会插入一个 写屏障

  • 对该变量的读操作 之前,编译器会插入一个 读屏障

内存屏障能够在类似变量读、写操作之后,保证其他线程对volatile变量的修改对当前线程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。

内存屏障(Memory Barrier)是一种机制,用于防止处理器和编译器对内存访问的重排序。内存屏障可以分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。

读屏障保证了在该屏障之前的读操作要先于该屏障之后的读操作执行,从而保证了读操作的顺序性。写屏障保证了在该屏障之前的写操作要先于该屏障之后的写操作执行,从而保证了写操作的顺序性。这些屏障可以用来实现内存可见性和防止数据竞争。

内存屏障有以下三种类型:

  • LoadLoad屏障(LL):它保证了在该屏障之前的所有读操作要先于该屏障之后的所有读操作执行。这样可以保证读操作的顺序性和一致性。

  • StoreStore屏障(SS):它保证了在该屏障之前的所有写操作要先于该屏障之后的所有写操作执行。这样可以保证写操作的顺序性和一致性。

  • LoadStore屏障(LS)和StoreLoad屏障(SL):它们是最重要的内存屏障类型。LS屏障保证了在该屏障之前的所有读操作要先于该屏障之后的所有写操作执行。SL屏障保证了在该屏障之前的所有写操作要先于该屏障之后的所有读操作执行。这样可以确保数据的一致性和可见性。

内存屏障的实现是由处理器和编译器共同完成的。处理器可以使用缓存一致性协议和总线锁等机制来实现内存屏障,而编译器则可以在生成汇编代码时插入内存屏障指令来保证内存访问的顺序性和一致性。

如果你对更多内存屏障的细节感兴趣,或者想了解不同体系结构的处理器模型,建议参考JSR-133 相关文档,我个人认为这些都是和特定硬件相关的,内存屏障之类只是实现JMM规范的技术手段,并不是规范的要求。

从应用开发者的角度,JMM提供的可见性,体现在类似volatile上,具体行为是什么样呢?

我举两个例子

请看下面的代码片段,希望达到的效果是,当condition被赋值为false时,线程A能够从循环中退出。

// Thread A
while (condition) {
}

// Thread B
condition = false;

这里就需要condition被定义为volatile变量,不然其数值变化,往往并不能被线程A感知,进而无法退出。当然,也可以在while中,添加能够直接或间接起到类似效果的代码。

Brian Goetz提供的一个经典用例,使用volatile作为守卫对象,实现某种程度上轻量级的同步:

Map configOptions;
char[] configText;
volatile boolean initialized = false;

// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// Thread B
while (!initialized)
  sleep();
// use configOptions

JSR-133重新定义的JMM模型,能够保证线程B获取的configOptions是更新后的数值。

也就是说volatile变量的可见性发生了增强,能够起到守护其上下文的作用。线程A对volatile变量的赋值,会强制将该变量自己和当时其他变量的状态都刷出缓存,为线程B提供可见性。当然,这也是以一定的性能开销作为代价的,但毕竟带来了更加简单的多线程行为。

我们经常会说volatile比synchronized之类更加轻量,但轻量也仅仅是相对的,volatile的读、写仍然要比普通的读写要开销更大,所以如果你是在性能高度敏感的场景,除非你确定需要它的语义,不然慎用。

总结

今天,我从happen-before关系开始,帮你理解了什么是Java内存模型。为了更方便理解,我作了简化,从不同工程师的角色划分等角度,阐述了问题的由来,以及JMM是如何通过类似内存屏障等技术实现的。最后,我以volatile为例,分析了可见性在多线程场景中的典型用例 如果本文对你有帮助的话,欢迎点赞分享,这对我继续分享&创作优质文章非常重要。感谢 !

本文由 mdnice 多平台发布

おすすめ

転載: blog.csdn.net/qq_35030548/article/details/130164940