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 つの概念#
原子性#
原子性とは、ある操作の中で CPU が途中で一時停止して再スケジュールすることができないことを指します。すなわち、中断されることなく、完了するか、または実行されないかのどちらかです(データベーストランザクション処理の原子性を思い出してください)。
可視性#
可視性とは、複数のスレッドが同じ変数にアクセスする際に、あるスレッドがこの変数の値を変更すると、他のスレッドがその変更された値をすぐに見ることができることを指します。
順序性#
順序性とは、プログラムの実行順序がコードの先後に従って実行されることを指します。
実際、原子性の問題、可視性の問題、順序性の問題は、人々が抽象的に定義したものです。この抽象の根本的な問題は、前述のキャッシュの一貫性の問題、プロセッサの最適化の問題、命令の再配置の問題などです。キャッシュの一貫性の問題は実際には可視性の問題であり、プロセッサの最適化
は原子性の問題を引き起こす可能性があり、命令の再配置
は順序性の問題を引き起こす可能性があります。
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 はできるのか
可視性の実装#
参考:並行性の 3 つの特性 - 可視性の定義、可視性の問題と可視性の保証技術
-
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 つのスレッド内では意味的な直列性を保証する必要があります。つまり、コードの順序に従って実行される必要があります。モニターロックルール
: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 () メソッドの開始の前に発生します。