最近はP5Rに夢中なので進捗は思わしくありませんが、Gao Juan Xing YYDSと言わざるを得ません。早速、今日のトピックであるJMM と Happens-Beforeを始めましょう。
それらに関する質問はあまりなく、基本的には次の 2 つだけです。
- JMMとは何ですか? JMMについて詳しく説明します。
- JMM についてのあなたの理解について教えてください。なぜこのように設計されているのですか?
ヒント: この記事は JMM 理論に焦点を当てています。
JMMとは何ですか?
JMM は Java メモリ モデル、Java メモリ モデルです。JSR-133 FAQのメモリ モデルの説明は次のとおりです。
プロセッサ レベルでは、メモリ モデルは、他のプロセッサによるメモリへの書き込みが現在のプロセッサに認識され、現在のプロセッサによる書き込みが他のプロセッサに認識されることを知るための必要十分条件を定義します。
プロセッサ レベルでは、メモリ モデルは、プロセッサ コアが互いのメモリ書き込み操作を認識できるようにするための必要十分条件を定義します。同様に:
さらに、メモリへの書き込みをプログラムの早い段階に移動することができます。この場合、プログラム内で実際に書き込みが「発生」する前に、他のスレッドが書き込みを認識する可能性があります。この柔軟性はすべて設計によるものです。コンパイラー、ランタイム、またはハードウェアに、メモリ モデルの範囲内で最適な順序で操作を実行する柔軟性を与えることで、より高いパフォーマンスを実現できます。
コンパイラ、ランタイム、またはハードウェアが、メモリ モデルの制限内でパフォーマンスを向上させる最適な順序で命令を実行できるようにします。最適な順序は、命令のリオーダリングによって得られる命令の実行順序です。
プロセッサレベルのメモリモデルを要約してみましょう。
- コア間の書き込み操作の可視性を定義します。
- 命令の並べ替えには制限があります。
次に、JMM の説明を見てください。
Java メモリ モデルは、マルチスレッド コードでどのような動作が正当であるか、およびスレッドがメモリを介してどのように対話できるかを説明します。これは、プログラム内の変数間の関係と、実際のメモリまたはレジスタへの変数の格納と取得の低レベルの詳細を記述します。これは、さまざまなハードウェアとさまざまなコンパイラの最適化を使用して正しく実装できる方法で行われます。
この一節から重要な情報を抽出します。
- JMM は、マルチスレッドでの動作の合法性と、スレッドがメモリを介してどのように対話するかを説明します。
- ハードウェアとコンパイラ間の実装の違いは、一貫したメモリ アクセス効果を実現するためにシールドされています。
メモリ モデルを見てみましょう。JMM とは正確には何ですか?
- JVM の観点から見ると、JMM はさまざまなハードウェア/プラットフォームの根本的な違いを保護して、一貫したメモリ アクセス効果を実現します。
- Java 開発者の観点から見ると、JMM はスレッド間の書き込み操作の可視性を定義し、命令の並べ替えを制約します。
では、なぜメモリモデルがあるのでしょうか?
「奇妙な」同時実行の問題
スレッドについて知っておくべき 8 つの質問 (上記) は、同時プログラミングの 3 つの要素と、それらを正しく実装できないことによって引き起こされる問題を示しています。次に、根本的な理由を探ってみましょう。
ヒント: Linux でスレッド スケジュール関連のコンテンツを少し追加します。
Linux のスレッド スケジューリングは、タイム スライスに基づくプリエンプティブ スケジューリングです。単純に理解すると、スレッドはまだ実行を終了していませんが、タイム スライスが使い果たされ、スレッドは一時停止されます。Linux は、待機キュー内で最も優先度の高いスレッドを選択して割り当てます。タイムスライスなので優先度が高く、スレッドは常に実行されます。
コンテキストの切り替えによって引き起こされるアトミック性の問題
一般的な自動インクリメント操作 count++ を例として考えてみましょう。直感的には、自己インクリメント操作は一時停止せずに一度に実行されると考えられます。しかし、実際には 3 つの命令が生成されます。
- 命令 1: カウントをキャッシュに読み取ります。
- 命令 2: 自己インクリメント操作を実行します。
- 命令 3: 自己インクリメントされたカウントをメモリに書き込みます。
ここで疑問が生じます。2 つのスレッド t1 と t2 がカウントの自動インクリメント操作を同時に実行し、t1 が命令 1 を実行した後にスレッドの切り替えが発生した場合、この時点で何が起こるでしょうか?
結果は 2 になると予想していましたが、実際には 1 でした。これは、スレッドの切り替えによって引き起こされる原子性の問題です。では、スレッド切り替えを禁止すればアトミック性の問題は解決しないのでしょうか?
それでも、スレッド切り替えを禁止する代償はあまりにも大きくなります。CPU の計算速度は「速い」一方で、I/O 操作は「遅い」ことがわかっています。想像してみてください。steam を使って P5R をダウンロードしているのに、コンピューターがフリーズしてしまうと、ダウンロード後に喜んでバグを書くことしかできなくなります。怒っていますか?
したがって、オペレーティング システムのスレッドが I/O 操作を実行すると、CPU タイム スライスを放棄して他のスレッドに与え、CPU の使用率を向上させます。
P5Rは世界最高だ!!!
キャッシュによって引き起こされる可視性の問題
上記の例では、スレッド t1 と t2 が同じカウントを操作していないと思われるかもしれません。
これは同じカウントのように見えますが、実際には別のキャッシュにあるメモリ内のカウントのコピーです。なぜなら、I/OとCPUの間には大きな速度差があるだけでなく、メモリとCPUの差も小さくないため、その差を補うためにメモリとCPUの間にCPUキャッシュが追加されます。
CPUコアがメモリデータを操作する場合、まずデータをキャッシュにコピーし、次にキャッシュ内のデータコピーをそれぞれ操作します。
まず MESI の影響を無視しましょう。スレッドによるキャッシュ内の変数の変更は、他のスレッドにはすぐには表示されないことがわかります。
ヒント: MESI プロトコルの基本的な内容は、拡張版で補足されています。
命令の再順序付けによって発生する順序の問題
実行速度を上げる上記の方法に加えて、命令の並べ替えという「蛾」もあります。スレッドについて知っておくべき 8 つの質問 (上記)の例を変更してみましょう。
public static class Singleton {
private Singleton instance;
public Singleton getInstance() {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
}
Java の New Singleton() は 3 つの手順を実行する必要があります。
- メモリを割り当てます。
- シングルトン オブジェクトを初期化します。
- インスタンスをこのメモリにポイントします。
これら 3 つのステップ間の依存関係を分析します。メモリ割り当てを最初に実行する必要があります。そうでない場合、2 と 3 は実行できません。2 と 3 に関しては、誰が最初に実行しても、シングル スレッドでのセマンティクスの正確さに影響はありません。それらの間に違いはありません。
しかし、マルチスレッドのシナリオとなると、状況は複雑になります。
このとき、スレッドt2で取得したインスタンスは初期化されていないインスタンスオブジェクトとなり、リオーダリングによる順序問題が発生します。
ヒント: 拡張での補足命令の並べ替え。
JMMは何をしましたか?
JMM を正式に説明する前に、他の 2 つのメモリ モデルが JSR-133 で言及されました。
- 逐次整合性メモリモデル
- 発生前メモリモデル
逐次一貫性のあるメモリ モデルにより、コンパイラとプロセッサの最適化が禁止され、メモリの可視性が強力に保証されます。それには以下が必要です:
- 実行中、すべての読み取り/書き込み操作には完全な順序関係があります。
- スレッド内の操作はプログラムの順序で実行する必要があります。
- 操作はアトミックに実行され、すべてのスレッドに即座に表示される必要があります。
逐次整合性モデルは制限が多すぎるため、同時実行性をサポートするプログラミング言語のメモリ モデルとしては明らかに適していません。
前に起こること
Happens-Before は、2 つの操作の結果間の関係を記述します。操作 A は操作 B よりも前に発生します ( で示されます)。順序を変更した後でも、操作 A の結果は操作 B に表示される必要があります。
ヒント: Happens-Before は因果関係、つまり 「原因」であり、A の結果は B には「結果」として表示され、実行プロセスは私の仕事ではありません。
Happens-Before のルールについては、「The Art of Java Concurrent Programming」の翻訳を引用します。
プログラム順序規則: スレッド内の各操作は、そのスレッド内の後続の操作よりも前に発生します。
モニター・ロック・ルール: ロックのロック解除は、このロックのロックに続いて行われます。
揮発性変数のルール: 揮発性変数の書き込みは、この揮発性変数のその後の読み取りよりも前に行われます。
推移性: A が B より前に発生し、B が C より前に発生する場合、A は C より前に発生します。
start() ルール: スレッド A が操作 ThreadB.start() を実行する (スレッド B を開始する) 場合、スレッド A の ThreadB.start() 操作はスレッド B の操作よりも前に発生します。
join() ルール: スレッド A が操作 ThreadB.join() を実行して正常に戻った場合、スレッド B の操作はすべて、スレッド A が ThreadB.join() 操作から正常に戻る前に発生します。
上記の内容はJSR-133 Chapter 5 Happens-Before and Synchronizes-With Edgesに記載されていますが、原文は読みにくいです。
これらはナンセンスに思えますが、私たちはマルチスレッド環境、コンパイラ、ハードウェアの並べ替えに直面していることを忘れないでください。
もう一度、モニター ロック ルールを例に挙げると、ロックの前にロック解除が行われるとだけ記述されていますが、ロック解除後の実際の結果 (成功/失敗) はロックの前に発生します。
ヒント: Happens-Before は前に起こっていると翻訳でき、Synchronizes-With は… と同期していると翻訳できます。
さらに、JSR-133 では、不揮発性変数の規則についても言及されています。
不揮発性読み取りによって表示される値は、事前発生一貫性として知られるルールによって決定されます。
つまり、不揮発性変数に対する読み取り操作の可視性は、前発生一貫性によって決まります。
Happens-Before一貫性: 変数 V に対して書き込み操作 W と読み取り操作 R があります。WhbR が満たされる場合、操作 W の結果は操作 R に表示されます (JSR 133 の定義は科学者の厳密さを解釈しています)。
JMM はすべての Happens-Before ルール (拡張) を受け入れるわけではありませんが、Happens-Before ルール ≈ JMM ルールとみなすことができます。
では、なぜ Happens-Before を選択するのでしょうか? 実際、これはプログラミングの容易さ、制約、操作効率の間のトレードオフの結果です。
この図では、今日多かれ少なかれ言及されたメモリ モデルのみが選択されており、その中で X86/ARM はハードウェア アーキテクチャ システムを指します。
Happens-Before は JMM の中核ですが、さらに、JMM はハードウェア間の違いも保護し、Java 開発者に 3 つの同時実行プリミティブ ( synchronized 、 volatile 、および Final ) を提供します。
コンテンツを展開する
メモリ モデルと JMM に関する理論的な内容は終わりました。ここでは、記事に登場する概念の補足です。ほとんどはハードウェア レベルの内容です。興味がない場合は、直接スキップしてください。
キャッシュコヒーレンシプロトコル
キャッシュ コヒーレンス プロトコル (Cache Coherence Protocol) では、一貫性は一般的な一貫性ではありません。
Coherence と Consistency は、並行プログラミング、コンパイルの最適化、分散システム設計などで頻繁に登場します。中国語の翻訳だけで理解すると誤解しやすいです。実際、この 2 つの違いは依然として非常に大きいです。一貫性モデルを見てみましょう。ウィキペディアの説明:
一貫性は、キャッシュされたシステムまたはキャッシュのないシステムで発生する一貫性とは異なり、すべてのプロセッサに関するデータの一貫性です。Coherenceは、単一の場所または単一の変数への書き込みがすべてのプロセッサによって認識されるグローバル順序の維持を扱います。一貫性は、すべてのプロセッサに関する複数の場所への操作の順序を扱います。
明らかに、Coherence の場合は単一の変数を対象としていますが、Consistency は複数の接続を対象としています。
MESIプロトコル
MESI プロトコルは、無効化に基づく最も一般的に使用されるキャッシュ コヒーレンシ プロトコルです。MESI はキャッシュの 4 つの状態を表します。
- M (変更済み、変更済み)、キャッシュ内のデータは変更されており、メイン メモリ内のデータとは異なります。
- E (排他的、排他的)、データは現在のコアのキャッシュにのみ存在し、メイン メモリのデータと同じです。
- S(Shared、共有)、データは複数のコアに存在し、メインメモリのデータと同じです。
- I (無効、無効)、キャッシュ内のデータは無効です。
ヒント: MESI プロトコル以外にも、MSI プロトコル、MOSI プロトコル、MOESI プロトコルなどがあります。頭文字はステータスを表し、O は Owned を表します。
MESI はハードウェア レベルで行われる保証であり、複数のコア上の変数の読み取りおよび書き込み順序を保証します。
CPU アーキテクチャが異なれば、MESI の実装も異なります。たとえば、X86 ではストア バッファが導入され、ARM ではロード バッファと無効なキューが導入されます。読み取り/書き込みバッファと無効なキューにより速度は向上しますが、別の問題が生じます。
命令の並べ替え
並べ替えは 3 つのカテゴリに分類できます。
- 命令の並列並べ替え: データの依存関係がない場合、プロセッサは命令の実行順序を自ら最適化できます。
- コンパイラーによる最適化された並べ替え: コンパイラーは、シングルスレッドのセマンティクスを変更せずにステートメントの実行順序を並べ替えることができます。
- メモリ システムの並べ替え: ストア/ロード バッファを導入し、非同期で実行します。命令が「順不同」で実行されるように見えます。
最初の 2 つの並べ替えは理解するのが簡単ですが、記憶システムの並べ替えをどのように理解すればよいでしょうか?
ストアバッファ、ロードバッファ、無効キューを導入し、本来の同期対話処理を非同期対話に変更することで、同期ブロッキングは軽減されますが、「順序が狂う」可能性も生じます。
もちろん、並べ替えは「タブーではない」わけではありません。結論は 2 つあります。
- データ依存関係: 2 つの操作が同じデータに依存しており、これには書き込み操作も含まれますが、このとき 2 つの操作の間にはデータ依存関係があります。2 つの操作間にデータの依存関係がある場合、コンパイラーまたはプロセッサーが順序を変更するときに 2 つの操作の順序を変更することはできません。
- as-if-serial セマンティクス: as-if-serial セマンティクスは、シングル スレッド シナリオのように実行されることを意味するものではありませんが、どのように並べ替えても、シングル スレッド シナリオのセマンティクスを変更することはできません (または実行結果は変わりません)。
推奨読書
メモリモデルとJMMの測定値
- メモリモデルとは何ですか?
- Java 11 言語仕様第 17 章
- JSR-133 クックブック
- JSR-133(英語版)
- JSR-133 (中国語版) ディン・イー翻訳
- 分散システムにおける時間、クロック、およびイベントの順序
「分散システムにおける時間、クロック、およびイベントの順序付け」では、分散分野の問題について説明していますが、同時プログラミングの分野にも大きな影響を与えます。
さらに、「Time, Clocks, and the Ordering of Events in a Distributed System」と JSR-133 の中国語版と英語版の計 3 つの PDF ファイルを用意しましたので、[JSR133] に返信してください。
最後に、興味深いことに、大手のブログは非常に「シンプル」です。Doug Lea のブログのホームページ:
Lamport のブログのホームページ:
エピローグ
最近P5Rにハマって怠けてます~~
JMM の内容は非常に複雑で、同時実行の原理に関しては、決してプログラミング言語自体が争っているわけではなく、CPU からプログラミング言語までのあらゆるリンクが関与しているため、詳細を制御するのは困難です。内容の各部に若干の誤差がございます。