Java の並行性の基本概念#
ハードウェアレベル#
CPU が命令を実行する速度は非常に速いですが、メモリへのアクセス速度は遅く、桁違いの差があります。そのため、メモリがコンピュータプログラムの処理のボトルネックにならないように、CPU とメモリの間にキャッシュを追加することで解決します。
キャッシュの一貫性#
CPU と主記憶の間にキャッシュを追加すると、マルチスレッドのシナリオではキャッシュの一貫性の問題が発生する可能性があります。つまり、マルチコア CPU では、各コアのキャッシュに同じデータのキャッシュ内容が一致しない可能性があります。
キャッシュの一貫性を解決するための 2 つの方法
- バスに LOCK# ロックを追加する方法(現代のコンピュータはすべてマルチコア CPU であり、バスのロックは他の CPU もメモリにアクセスできなくなるため、効率が悪い)
- キャッシュ一貫性プロトコル(Cache Coherence Protocol)
MESI キャッシュ一貫性プロトコル#
キャッシュ一貫性プロトコル(Cache Coherence Protocol)で最も有名なのは Intel の MESI プロトコルです。MESI プロトコルは、各キャッシュで使用される共有変数のコピーが一致していることを保証します。
MESI の核心的な考え方は、CPU がデータを書き込むとき、操作している変数が共有変数であることがわかると、他の CPU にその変数のキャッシュ行を無効状態にするように信号を送ることです。したがって、他の CPU がこの変数を読み取る必要があるとき、キャッシュ内のその変数のキャッシュ行が無効であることがわかると、メモリから再度読み取ります。
MESI プロトコルでは、各キャッシュには 4 つの状態がある可能性があります。それらは次のとおりです:
M(Modified)
:このデータ行は有効で、データが変更されており、メモリ内のデータと一致しません。データは本キャッシュ内にのみ存在します。E(Exclusive)
:このデータ行は有効で、データはメモリ内のデータと一致しており、データは本キャッシュ内にのみ存在します。S(Shared)
:このデータ行は有効で、データはメモリ内のデータと一致しており、データは多くのキャッシュに存在します。I(Invalid)
:このデータ行は無効です。
MESI プロトコルはキャッシュの一貫性を保証できますが、リアルタイム性を保証することはできません。
プロセッサの最適化と命令の再配置#
プロセッサの最適化
:プロセッサ内部の演算ユニットをできるだけ活用するために、プロセッサは入力コードを乱序実行することがあります。命令の再配置
:現在、多くの一般的なプロセッサがコードを最適化して乱序処理を行うだけでなく、多くのプログラミング言語のコンパイラも同様の最適化を行います。たとえば、Java 仮想マシンの JIT コンパイラも命令の再配置を行います。- 連想記憶:
- Spark では依存関係のないタスクが並行して実行される最適化計算
- リソース競合のないプログラムが並行して実行される。たとえば、あるプログラムが IO リソースを奪う場合、他の IO リソースを奪わないプログラムを先に実行して待機時間を省くことができます。
並行プログラミングの 3 つの概念#
原子性#
原子性とは、1 つの操作の中で CPU が途中で停止して再スケジュールされることができないことを指します。中断されることなく、完了するか、実行されないかのいずれかです(データベーストランザクション処理の原子性を連想)。
可視性#
可視性とは、複数のスレッドが同じ変数にアクセスする際に、1 つのスレッドがこの変数の値を変更した場合、他のスレッドがその変更された値を即座に見ることができることを指します。
順序性#
順序性とは、プログラムの実行順序がコードの先後に従って実行されることを指します。
実際、原子性の問題、可視性の問題、順序性の問題は、人々が抽象的に定義したものです。そして、この抽象の根底にある問題は、前述のキャッシュの一貫性の問題、プロセッサの最適化の問題、命令の再配置の問題などです。キャッシュの一貫性の問題は実際には可視性の問題であり、プロセッサの最適化
は原子性の問題を引き起こす可能性があり、命令の再配置
は順序性の問題を引き起こす可能性があります。
Java のメモリモデル#
基本概念#
-
Java の並行性は「共有メモリ」モデルを採用しており、スレッド間はメモリの共有状態を読み書きすることで通信します。複数のスレッドは直接データを渡して相互作用することはできず、相互作用は共有変数を通じてのみ実現されます。
-
JMM 自体は抽象的な概念であり、実際には存在しません。これはすべての変数が主記憶に存在することを規定する一連のルールまたは規範を説明しています。これは
通常のメモリ
に似ており、各スレッドは自分の作業メモリを持ち、キャッシュ
に類似しています。したがって、スレッドの操作は主に作業メモリに基づいており、彼らは自分の作業メモリにしかアクセスできず、作業の前後で値を主メモリに同期させる必要があります。
ps: これはキャッシュ + DB のシステムアーキテクチャに似ており、すべての変数は主記憶に存在し、DB
に似ており、各スレッドは自分の作業メモリを持ち、キャッシュ
に類似しています。
Java メモリモデルの実装#
-
Java メモリモデル(JMM)は、すべての変数が主メモリに保存されていることを規定しており、各スレッドには自分の作業メモリがあります:
- スレッドの作業メモリには、そのスレッドが使用する変数のコピー(主メモリからコピーされたもの)が保存されており、スレッドが変数に対して行うすべての操作は作業メモリ内で実行され、主メモリ内の変数に直接アクセスすることはできません。
- 異なるスレッドは互いの作業メモリの変数に直接アクセスできず、スレッド間の変数値の伝達は主メモリを通じて行われます。
-
Java スレッド間の通信は、メモリモデル JMM(Java Memory Model)によって制御されます:
- JMM は、あるスレッドが変数に書き込んだときに、別のスレッドにその変更がいつ見えるかを決定します。
- スレッド間で共有される変数は主メモリに保存されます。
- 各スレッドには、共有変数の読み取り / 書き込みのコピーを保存するプライベートなローカルメモリがあります。
- JMM は、各スレッドのローカルメモリ間の相互作用を制御することで、プログラマーにメモリの可視性の保証を提供します。
-
メモリ間の相互作用操作:
- lock(ロック):主メモリの変数に作用し、変数を 1 つのスレッドが独占する状態として識別します。
- unlock(アンロック):主メモリの変数に作用し、ロック状態にある変数を解放し、解放された変数は他のスレッドによってロックされることができます。
- read(読み取り):主メモリの変数に作用し、主メモリの変数を作業メモリに読み取ります。
- load(ロード):作業メモリに作用し、read 操作で作業メモリに読み取られた変数を作業メモリの変数のコピーにロードします。
- use(使用):作業メモリの変数に作用し、作業メモリ内の変数の値を実行エンジンに渡します。
- assign(代入):作業メモリの変数に作用し、実行エンジンが受け取った値を作業メモリの変数に代入します。
- store(保存):作業メモリの変数の値を主メモリに渡します。
- write(書き込み):store 操作の値を主メモリの変数に書き込みます。
注意:主メモリと作業メモリは、JVM メモリ構造の Java ヒープ、スタック、メソッド領域などとは異なるレベルのメモリ区分ではありません。
Java における並行性の実装#
原子性の実装#
-
Java では、原子性を保証するために、monitorenter と monitorexit という 2 つの高レベルのバイトコード命令が提供されており、対応するキーワードは synchronized です。
-
Atomic クラスも原子性を実現できます。
CAS 原理に基づいています。参考:なぜ volatile は原子性を保証できず、Atomic はできるのか
可視性の実装#
参考:並行性の三つの特性 - 可視性の定義、可視性の問題と可視性の保証技術
-
volatile キーワードを使用してメモリバリアをマークし、可視性を保証します。
-
synchronized キーワードを使用して同期コードブロックまたは同期メソッドを定義し、可視性を保証します。
-
Lock インターフェースを使用して可視性を保証します。
-
Atomic 型を使用して可視性を保証します。
-
final キーワードを使用して実現します。
final 修飾されたフィールドは、一度初期化が完了すると(静的変数またはコンストラクタ内で初期化)、コンストラクタが「this」の参照を外部に渡さない限り(this 参照の逃避は非常に危険なことであり、他のスレッドがこの参照を通じて「初期化の途中」のオブジェクトにアクセスできる可能性があります)、他のスレッドで final フィールドの値を見ることができます。
順序性の実装#
Java では、synchronized と volatile を使用してマルチスレッド間の操作の順序性を保証できます。
- volatile キーワードは命令の再配置を禁止します。
- synchronized キーワードは同時に 1 つのスレッドのみが操作を許可します。
happens-before原則
#
JMM は、何の手段も使わずに保証できる先天的な順序性を持っています。通常、これを happens-before 原則と呼びます。《JSR-133:Java メモリモデルとスレッド仕様》では、以下の happens-before ルールが定義されています:
プログラム順序規則
:1 つのスレッド内では意味的な直列性を保証する必要があります。つまり、コードの順序に従って実行される必要があります。モニターロック規則
:あるスレッドのロック解除は、そのスレッドのロック取得に happens-before します。volatile変数規則
:volatile ドメインへの書き込みは、その後の volatile ドメインへの読み取りに happens-before します。伝播性
:もし A が B に happens-before し、B が C に happens-before するなら、A は C に happens-before します。start()規則
:スレッドの start () メソッドは、そのすべてのアクションの前に実行されます。つまり、スレッド A がスレッド B の start メソッドを実行する前に共有変数の値を変更した場合、スレッド B が start メソッドを実行するとき、スレッド A の共有変数の変更はスレッド B に見えます。join()スレッド終了原則
:スレッドのすべての操作はスレッドの終了の前に行われます。もし A が ThreadB.join () を実行し、成功して戻るなら、スレッド B 内の任意の操作はスレッド A が ThreadB.join () 操作から成功して戻る前に happens-before します。interrupt()スレッド中断原則
:スレッドの interrupt () メソッドの呼び出しは、中断されたスレッドのコードが中断イベントの発生を検出する前に発生します。これは Thread.interrupted () メソッドを使用して中断が発生したかどうかを検出できます。finalize()オブジェクト終了原則
:オブジェクトの初期化が完了することは、その finalize () メソッドの開始よりも先に発生します。