Javaメモリモデルの基礎と揮発性変数

ハードウェアの効率と一貫性

マルチスレッド処理と計算効率向上には密接な関係があるが、両者の間には根本的なギャップが存在する。計算処理は演算操作だけでなくメモリI/Oも含まれ、これらの処理速度には数桁の差がある。この差を埋めるため、現代コンピュータシステムは多段階の高速キャッシュを採用している。しかしこれにより、メモリ一貫性という新たな課題が生じる。

各プロセッサは独自の高速キャッシュを持ちながら、同じメインメモリを共有する。さらにプロセッサは内部ユニットを最大限活用するため、入力コードをアウトオブオーダー実行し、結果を再構成して順次実行と同じ結果を保証する。このため、他タスクの中間結果に依存する計算では、コード順序だけで正確な実行順序を保証できない。

Javaメモリモデルの構造

メモリ間相互作用

Javaメモリモデル(JMM)では、全ての変数はメインメモリに格納され、各スレッドは固有の作業メモリを持つ。作業メモリには使用変数のメインメモリコピーが保存され、変数操作は全て作業メモリで実行される。異なるスレッドは互いの作業メモリに直接アクセスできず、変数値の受け渡しはメインメモリ経由で行われる。

主なメモリ操作:

  • lock: メインメモリ変数を対象に、スレッド排他状態を設定
  • unlock: ロック状態の変数を解放
  • read: メインメモリから作業メモリへ変数値転送
  • load: readで取得した値を作業メモリ変数に格納
  • use: 作業メモリの値を実行エンジンに渡す
  • assign: 実行エンジンから受けた値を作業メモリ変数に設定
  • store: 作業メモリの値をメインメモリへ転送
  • write: storeで取得した値をメインメモリ変数に格納

現代の実装ではread/write/lock/unlockの4操作に簡略化されているが、基本設計は変わらない。

volatile変数の特別な規則

volatileはJavaで最も軽量な同期メカニズムであり、2つの特性を持つ:

  1. 全スレッドに対する可視性の保証(変数変更が即時反映)
  2. 命令の再並べ替え防止

通常の変数では、スレッドAの変更がスレッドBに反映されるには、メインメモリへの書き込みと読み取りが必要となる。

public class ConcurrencyDemo {
    private static volatile AtomicInteger counter = new AtomicInteger(0);
    
    public static void increment() {
        counter.incrementAndGet();
    }
    
    public static void main(String[] args) throws InterruptedException {
        Thread[] workers = new Thread[20];
        for (int i = 0; i < 20; i++) {
            workers[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increment();
                }
            });
            workers[i].start();
        }
        
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println(counter.get());
    }
}

元のint型変数では非原子性のため不整合が発生する。以下はcount++の疑似バイトコード:

0: getstatic #14   // フィールドcountの値を取得
3: iconst_1         // 定数1をスタックにプッシュ
4: iadd             // 加算実行
5: putstatic #14   // 結果をcountに保存

この処理中に他のスレッドが値を更新すると、競合状態が発生する。AtomicIntegerはこの問題を解決する。

タグ: Java JMM volatile マルチスレッド メモリ一貫性

7月27日 23:02 投稿