メモリモデル (Memory Model)
メモリモデルは、プログラム内の変数(インスタンスフィールド、静的フィールド、配列要素)間の関係性を定義し、実際のコンピュータシステムにおけるメモリへの変数の保存およびメモリからの読み出しといった低レベルの詳細を説明します。
異なるプラットフォーム間のプロセッサアーキテクチャは、メモリモデルの構造に直接影響を与えます。
CやC++では、特定の操作プラットフォームのメモリモデルを利用して並行プログラムを記述できます。しかし、これは開発者にとって高い学習コストを伴います。これに対し、Javaは仮想マシンの利点を活かし、メモリモデルを具体的なプロセッサアーキテクチャに縛られないようにすることで、真のクロスプラットフォームを実現しました。(HotSpot JVM、JRockitなどの異なるJVMでは、メモリモデルも異なる場合があります)
メモリモデルの特徴:
- 可視性 (Visibility): マルチコア、マルチスレッド環境におけるデータの共有
- 順序性 (Ordering): メモリに対する操作が順序正しく行われること
Javaメモリモデル (Java Memory Model)
Java言語仕様によると、JVMシステム内にはメインメモリ(Main MemoryまたはJava Heap Memory)が存在し、Javaのすべての変数はこのメインメモリに格納され、すべてのスレッドで共有されます。
各スレッドは独自のワーキングメモリ(Working Memory)を持ち、ここにはメインメモリ内の一部の変数のコピーが保存されます。スレッドはすべての変数に対する操作をワーキングメモリ内で行い、スレッド間で直接変数にアクセスすることはできません。変数の受け渡しはすべてメインメモリを介して行われます。
ここで、ワーキングメモリ内の変数は、マルチコアプロセッサ環境下では主にプロセッサのキャッシュに保存されます。このキャッシュはメモリを経由せずにアクセスされるため、他のスレッドからは見えないことがあります。
JMMは可視性(Visibility)をどのように保証するか?
JMMにおいて、複数のスレッドが変数の値を同時に変更する場合、スレッドが変数をメインメモリに同期(書き戻し)するまで、他のスレッドはその更新された値を参照できません。
JMMは順序性(Ordering)をどのように保証するか?
Javaが提供する同期機構やvolatileキーワードを利用することで、メモリへのアクセス順序が保証されます。
キャッシュ一貫性(Cache Coherency)
キャッシュ一貫性とは何か?
これは、マルチプロセッサシステムのキャッシュメモリを管理する仕組みであり、データがキャッシュメモリからメインメモリへの転送の過程で、データが失われたり重複したりしないことを保証します。(Wikipediaより)
例で理解する:
あるプロセッサのキャッシュに更新された変数値があるが、まだメインメモリに書き込まれていないとします。この場合、他のプロセッサはこの更新された値を見ることができません。
キャッシュ一貫性の解決方法?
- 順序一貫性モデル:
プロセッサが変数値を変更した場合、その値をすべてのプロセッサが受け取るまで、他の命令の実行を待たせます。 - リリース一貫性モデル:(JMMのキャッシュ一貫性に類似)
プロセッサが変数値を変更した場合、ロックを解放するタイミングまでその変更を遅延させることができます。
JMMのキャッシュ一貫性モデル – 「先行発生順序 (Happens-Before Ordering)」
一般的な状況を示すサンプルプログラム:
int a = 0;
int b = 0;
int c = 0;
int d = 0;
// スレッドA
b = 1;
a = 1;
// スレッドB
c = a;
d = b;
上記のプログラムで、スレッドAとBが同期なしで実行された場合、cとdの値はどのようになりますか?
答えは、不定です。(00, 01, 10, 11のいずれの組み合わせも起こり得ます)ここではJavaの同期機構が使用されていないため、JMMの順序性と可視性は保証されません。
「先行発生順序」はどのようにこの状況を防ぐか?
この原則は以下の通りです:
- プログラムの実行順序において、スレッド内の各操作は、それに続くすべての操作の前に発生します。
- オブジェクトのロックを解放する操作は、そのロックを待っているスレッドのロック取得操作の前に発生します。
- volatileキーワードで修飾された変数への書き込み操作は、その変数の読み込み操作の前に発生します。
- あるスレッドのThread.start()呼び出しは、開始されたスレッド内のすべての操作の前に発生します。
- あるスレッドのThread.join()が正常に戻ることは、そのスレッドのすべての操作が完了したことを意味します。
この「先行発生順序」を実現するために、JavaとJDKが提供するツール:
- synchronizedキーワード
- volatileキーワード
- final変数
- java.util.concurrent.locksパッケージ(JDK 1.5以降)
- java.util.concurrent.atomicパッケージ(JDK 1.5以降)
「先行発生順序」を使用した例:
- オブジェクトのロックを取得します。
- ワーキングメモリのデータをクリアし、メインメモリから変数を現在のワーキングメモリにコピーします(読み込みとロード)。
- コードを実行し、共有変数の値を変更します(使用と代入)。
- ワーキングメモリのデータをメインメモリに書き戻します(ストアと書き込み)。
- オブジェクトのロックを解放します。
注: 4番目と5番目のステップは同時に実行されます。
ここで最も重要なのは2番目のステップです。これはメインメモリを同期させるため、前のスレッドによる変数の変更結果が、現在のスレッドで知ることができるのです!(「先行発生順序」の原則を利用)
ダブルチェックロッキングの問題
ダブルチェックロッキング(Double-Checked Locking)の問題は、JMMが避けられない欠陥の一つです。DCLの問題を理解することで、JMMの動作原理を深く理解できます。
DCLの問題を示すために、まず重要な概念である遅延初期化(Lazy Loading)を理解する必要があります。
遅延初期化の例
スレッドセーフではない遅延初期化の例:
class Bar {
private Resource instance = null;
public Resource getInstance() {
// 通常の遅延初期化
if (instance == null) {
instance = new Resource();
}
return instance;
}
}
スレッドセーフな遅延初期化の例:
class Bar {
private Resource instance = null;
public synchronized Resource getInstance() {
// インスタンス取得操作を同期方式で行うため、パフォーマンスが低い
if (instance == null) {
instance = new Resource();
}
return instance;
}
}
ダブルチェックロッキングによるスレッドセーフな遅延初期化の例:
class Bar {
private volatile Resource instance = null; // volatileを追加
public Resource getInstance() {
if (instance == null) {
// 初回初期化時のみ同期方式を使用
synchronized (this) {
if (instance == null) {
instance = new Resource();
}
}
}
return instance;
}
}
ダブルチェックロッキングは非常に理想的に見えます。しかし残念なことに、Javaの言語仕様によると、上記のコードは信頼できません。
この問題が発生する主な理由は2つです:
- コンパイラがプログラム命令を最適化し、CPU処理速度を向上させます。
- マルチコアCPUが並列演算能力を向上させるために命令の順序を動的に調整します。
問題が発生する順序:
- スレッドAが、オブジェクトが初期化されていないことを発見し、初期化を開始しようとします。
- コンパイラの最適化により、コンストラクタが完全に呼び出される前に、共有変数の参照が部分的に初期化されたオブジェクトを指すことが許可されます。オブジェクトは完全に初期化されていませんが、nullではなくなっています。
- スレッドBが、部分的に初期化されたオブジェクトがnullでないことを発見し、そのオブジェクトを直接返します。
ただし、JiveやLenyaなどの有名なオープンソースフレームワークもDCLパターンを使用しており、極端な例外は報告されていません。これは、DCLの問題が発生する確率は比較的低いことを示唆しています。次に、パフォーマンスと安定性の間での選択となります。
DCLの代替案: 初期化時ロード (Initialize-On-Demand)
public class Bar {
// プライベートな静的内部クラス。参照がある場合にのみ、このクラスがロードされる
private static class LazyBar {
public static final Bar singleton = new Bar();
}
private Bar() {} // コンストラクタをプライベートにする
public static Bar getInstance() {
return LazyBar.singleton;
}
}
ダブルチェックロッキングの問題を解決する最も確実な方法は、初期化時ロードパターンを使用することです。この方法は、JVMがクラスをロードする際にインスタンスを生成するため、スレッドセーフであり、同期のオーバーヘッドもありません。