Java メモリモデル(JMM)のわかりやすい解説

1. JMM とは

JMM の正式名称は Java Memory Model で、中国語では Java メモリモデル と呼ばれます。

これは、特定の物理ハードウェアメモリについて述べているわけでも、CPUのL1、L2、L3キャッシュについて直接述べているわけでもありません。

これはJavaのマルチスレッド仕様の一種であり、以下を規定するものです:

1. あるスレッドが変数を変更した後、他のスレッドはいつその変更を認識できるか?
2. コンパイラやCPUはコードの実行順序を調整できるか?
3. どの操作が本質的に安全で、どの操作にvolatile、ロック、またはAtomicクラスが必要か?

一言で言えば:

JMMは、Javaにおけるマルチスレッド環境での共有変数の読み書きに関する一連の抽象的なルールです。


2. なぜ JMM が必要なのか

Java プログラムの実行時には、多くの最適化が行われます:

  • コンパイラが命令の順序を再配置する場合があります;
  • JIT が変数をレジスタにキャッシュする場合があります;
  • CPU は L1、L2、L3 キャッシュを使用します;
  • CPUは命令を乱順実行する場合もあります;
  • あるスレッドが変数を変更しても、別のスレッドがすぐにその変更を認識できるとは限りません。

例:

class Demo {
    private boolean running = true;
    
public void loop() {
        while (running) {
            // 何か処理
        }
    }

    public void stop() {
        running = false;
    }
}

あるスレッドが stop() を呼び出した後、別のスレッド内の loop() が終了すると思うかもしれません。

しかし、runningvolatileでない場合、ループスレッドは古い値であるtrueを読み続け、ループが停止しなくなる可能性があります。

これが可視性の問題です。


3. JMMの2つの抽象概念

JMMは2つの概念を抽象化しています:

メインメモリ Main Memory
ワーキングメモリ Working Memory

次のように図示できます:

 メインメモリ
        すべてのスレッドが変数を共有する抽象的な記憶領域
              / \\
             / \\
       ワーキングメモリ A ワーキングメモリ B
       スレッド A スレッド B

スレッドが共有変数を操作する際の抽象的な流れは以下の通りです:

1. メインメモリから変数を読み込む
2. 自身のワーキングメモリにコピーする
3. スレッド内で使用・変更する
4. 再びメインメモリに書き戻す

注意:

メインメモリとワーキングメモリはどちらもJMMの抽象概念であり、ハードウェア内の特定のメモリ領域を指すものではありません。


4. メインメモリは物理メモリ(RAM)と同じものですか?

完全に同一というわけではありません。

大まかに次のように理解できます:

メインメモリ ≈ Java において、すべてのスレッドが共有する変数の最終的な格納場所

しかし、JMM は変数が実際に RAM、CPU キャッシュ、あるいはレジスタのどこにあるかについては関知しません。

JMM が関心を寄せるのは、

スレッド A が変数を書き込んだとき、スレッド B はいつその変更を確認できるか?

5. ワーキングメモリは L1、L2、L3 キャッシュのことか?

いいえ。

ワーキングメモリは Java レベルの抽象概念であり、特定のレベルの CPU キャッシュとは同一ではありません。

その下位層では、以下に対応している可能性があります:

CPUレジスタ
L1キャッシュ
L2キャッシュ
L3キャッシュ
Store Buffer(書き込みバッファ)
Load Buffer(読み取りバッファ)
JITによって生成された一時的なコピー
スレッドスタック上のローカルコピー

したがって、次のように言うことはできません:

ワーキングメモリ = L1
ワーキングメモリ = L2
ワーキングメモリ = L3

より正確に言えば:

ワーキングメモリは、スレッドが保持する可能性のある共有変数のコピーを表しており、その基盤となる部分はレジスタ、CPUキャッシュ、書き込みバッファなどの場所に配置される可能性があります。


6. CPUキャッシュとは

ハードウェア上には通常、以下のものがある:

レジスタ:最速、容量が最小
L1キャッシュ:通常、各CPUコアが独自に持つ
L2キャッシュ:通常、各CPUコアが独自に持つ。容量はL1より大きい
L3キャッシュ:通常、複数のコアで共有
RAM:物理メモリ、最遅

大まかなアクセス階層:

レジスタ -> L1 -> L2 -> L3 -> RAM

ただし、JMMはどのレベルのキャッシュを使用するかを直接規定していません。


7. volatileとは何か

volatile は Java のキーワードであり、主に以下の 2 つのことを保証する:

1. 可視性
2. 特定の再順序付けの禁止

例:

class Demo {
    private volatile boolean running = true;
    
public void loop() {
        while (running) {
            // 何か処理
        }
    }

    public void stop() {
        running = false;
    }
}

volatile を指定した後、あるスレッドが次のように実行します:

running = false;

別のスレッドはこの変更を認識できます。


8. volatile はスレッドがキャッシュを使わないようにするのでしょうか?

いいえ。

この説明は正確ではありません:

volatile によってスレッドは自身のキャッシュを使わず、直接メインメモリから読み込む

より正確な表現は:

volatile は、メモリバリアと CPU キャッシュの一貫性メカニズムを通じて、変数の読み書きの可視性と順序性を保証しますが、実際に CPU キャッシュを無効にするわけではありません。

CPUは依然としてL1、L2、L3キャッシュを使用します。


9. volatileの可視性

private volatile int flag = 0;

スレッドA:

flag = 1;

スレッド B:

int x = flag;

JMM の保証:

スレッド B は、スレッド A による flag への書き込みを認識できる。

より専門的に言えば:

volatile 変数への書き込みは、その後の任意のスレッドによる同じ volatile 変数への読み取りに対して happens-before となる。


10. volatile の順序性

例:

class Demo {
    private int data = 0;
    
private volatile boolean ready = false;

    public void writer() {
        data = 100;
        ready = true;
    }

    public void reader() {
        if (ready) {
            System.out.println(data);
        }
    }
}

もし reader スレッドが以下を確認した場合:

ready == true

その場合、以下も確認できます:

data == 100

その理由は:

data = 100 は ready = true の後に再順序付けされることはない
ready = true は volatile 書き込みである
reader が ready = true を読み取った後、volatile 書き込み以前の通常の書き込みを確認できる

11. volatile は原子性を保証しない

volatile は複合操作の原子性を保証しない。

volatile int count = 0;

count++;

count++ は実際には3つのステップからなる:

1. countを読み取る
2. 1を加算する
3. countに書き戻す

複数のスレッドが同時に実行されると、データが失われる可能性がある。

正しい方法:

AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

あるいは:

synchronized

12. happens-before は JMM の核心

JMM の最も重要な概念は:

happens-before

操作 A が操作 B の happens-before である場合、以下のことが成り立ちます:

1. A の結果は B から可視である
2. A の実行順序は B より前である

13. 一般的な happens-before ルール

13.1 プログラム順序のルール

同一スレッド内では、先行する操作は後続の操作に対して happens-before となる。

int a = 1;
int b = 2;

現在のスレッド内では:

a = 1 は b = 2 より happens-before である

13.2 volatile ルール

volatile 変数への書き込みは、その変数に対する後続の読み取りより happens-before である。

13.3 ロックの規則

あるスレッドがロックを解放すると、別のスレッドが同じロックを取得する操作に対して「happens-before」となる。

synchronized (lock) {
    value = 10;
}

13.4 スレッド起動のルール

Thread.start() を呼び出す前の操作は、新しいスレッドから見える。

13.5 スレッドの join のルール

スレッド内の操作は、他のスレッドが join() から戻るよりも前に発生する(happens-before)。


14. synchronized と volatile の違い

機能 volatile synchronized
可視性 あり あり
順序性 あり あり
原子性 複合操作は保証されない 同期ブロックの排他性を保証
ブロッキングの有無 ブロッキングなし ブロッキングの可能性あり
適したシナリオ 状態フラグ、単一の更新 複数変数の整合性、クリティカルセクション

15. AtomicInteger と volatile の違い

volatile int count;

読み書きの可視性のみを保証し、以下は保証されない:

count++

スレッドセーフ性。

AtomicInteger count = new AtomicInteger();

以下が保証されます:

count.incrementAndGet();

これはアトミック操作です。


16. ダブルチェックロックでなぜ volatile が必要なのか

典型的なシングルトン:

class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton ();
                }
            }
        }
        return instance;
    }
}

なぜ instancevolatile を付ける必要があるのか?

理由は:

instance = new Singleton();

これは次のように分解される可能性があります:

1. メモリの割り当て
2. オブジェクトの初期化
3. オブジェクトのアドレスを instance に代入

再順序化が発生した場合、次のような状態になる可能性があります:

1. メモリの割り当て
2. オブジェクトのアドレスを instance に代入
3. オブジェクトの初期化

別のスレッドが instance != null と認識しますが、オブジェクトの初期化はまだ完了していません。

volatile を使用することで、このような危険な再順序付けを防ぐことができます。


17. よくある誤解

誤解 1:volatile は CPU キャッシュを無効にする

間違いです。

volatile は CPU キャッシュを無効にしません。

これは、メモリバリアとキャッシュ一貫性プロトコルを通じて可視性を保証するものです。

誤解 2:ワーキングメモリとは L2 または L3 のこと

誤りです。

JMM のワーキングメモリは抽象的な概念であり、レジスタ、L1、L2、L3、書き込みバッファ、JIT 一時コピーなどに対応する可能性があります。

誤解 3:volatile は synchronized の代わりになる

必ずしもそうとは限りません。

volatileは、複合操作の原子性を保証できず、複数の変数の整合性も保護できません。

誤解 4:volatile でコレクションを修飾すればスレッドセーフになる

間違いです。

volatile List<String> list = new ArrayList<>();

ここでは、listという参照の可視性のみが保証されており、ArrayList内部の操作がスレッドセーフであることは保証されません。


18. クイックリファレンス表

シナリオ 推奨方法
あるスレッドが別のスレッドに停止を通知 volatile boolean
単純な状態の公開 volatile
インクリメントカウンタ AtomicInteger / LongAdder
複数変数の同時更新 synchronized / Lock
クリティカルセクションの保護 synchronized / Lock
高並行性カウント LongAdder
シングルトンの二重チェック volatile + synchronized

19. まとめ

JMM は、Java のマルチスレッドにおけるメモリ可視性のルールです。

JMM は以下を抽象化しています:

メインメモリ
ワーキングメモリ

しかし、これら2つの概念は具体的なハードウェアとは同一ではありません:

メインメモリ ≠ 単なる物理RAM
ワーキングメモリ ≠ 固定のL1/L2/L3

volatileの役割は:

1. 可視性の保証
2. 特定の再順序付けの禁止

しかし、以下を保証することはできません:

複合操作の原子性
集合のスレッドセーフ性
複数の変数の整合性

一言で言えば:

volatile は、スレッドがキャッシュを使用しないようにするものではなく、JVM と CPU が JMM のルールに従って変数の読み書きの可視性と順序性を保証するためのものです。