1. Java Memory Model (JMM) の概要
可視性と順序付けの問題
単一スレッドのプログラムでは話は簡単で、変数に値を書き込めばすぐに読み戻せます。マルチスレッドの現実ではそうはいきません。プロセッサは値をキャッシュし、コンパイラや JVM は命令の順序を入れ替えることがあり、あるスレッドが変更したばかりの値を、別のスレッドが「古い」まま見ることがあります。
Java Memory Model (JMM) は、スレッドがメモリを介してどのようにやり取りするか、すなわちあるスレッドの変更がいつ他のスレッドに見えるのか、また操作がどの順序で起こるのかを定義します。これを考慮しないと、見た目は正しくてもプログラムが予測不能な振る舞いをすることがあります。
JMM を理解すると、なぜスレッドが最新のデータを見られないことがあるのか、volatile、synchronized、アトミッククラスを正しく使う方法、そしてマルチスレッドのバグが本番環境だけで現れる理由が分かります。要するに、JMM はメモリのルールであり、それを無視すると、どれほど丁寧に書いたコードでもあなたに逆らうことがあります。
たとえ話
2 人(スレッド)が、黒板(メモリ)にあるメモ(変数)を書いたり読んだりしていると想像してください。片方が書いても、もう片方はまだ新しい書き込みが見えないことがあります。自分の黒板のコピー(キャッシュ)を見ているからです。JMM は、これらのメモがいつ・どのように全員に見えるようになるかを定めています。
2. happens-before: JMM の基礎
happens-before とは?
happens-before は、プログラム中の 2 つの動作の関係です。動作 A が動作 B に対して happens-before であるなら、A で行われたすべての変更は、B で確実に観測できます。
重要: happens-before は単なる「先に起きた」ではなく、「確実に見える」という保証を意味します。
happens-before の主な規則
1. 単一スレッド内
同じスレッド内で起きることは順序付けられます。変数に書き込んでから読み戻せば、自分の変更が見えます。
2. 同期化ブロック/モニタ
synchronized ブロックから出る前に起きたことは、その後でそのブロックに入るスレッドに見えるようになります。
synchronized(lock) {
sharedVar = 42; // 書き込み
}
// ...
synchronized(lock) {
System.out.println(sharedVar); // 42 を必ず観測できる
}
3. volatile の書き込み/読み取り
あるスレッドによる volatile フィールドへの書き込みは、別のスレッドによる後続のそのフィールドの読み取りに対して happens-before となります。
volatile boolean ready = false;
// スレッド 1
data = 123;
ready = true; // volatile 書き込み
// スレッド 2
if (ready) { // volatile 読み取り
System.out.println(data); // data = 123 を必ず観測できる
}
4. スレッドの開始と終了
- Thread.start() の呼び出しはスレッド開始に対して happens-before です。
- スレッドの終了は Thread.join() の復帰に対して happens-before です。
5. Executor のタスク完了
Executor にタスクを送ってその完了を待つ(Future.get())と、タスク内で行われた変更は get() 後に見えるようになります。
6. final フィールド
コンストラクタで final 修飾子の付いたフィールドを初期化することは、オブジェクト参照の公開に対して happens-before になります。これはイミュータブルなオブジェクトにとって重要です。
3. オブジェクトの安全な公開
問題: 「ステール」なオブジェクト
あるスレッドがオブジェクトを生成し、同期なしに別のスレッドへ渡すと、受け取った側はフィールドの「生」の値(未初期化や古い値)を見ることがあります。
class Holder {
int value;
Holder() { value = 42; }
}
Holder holder = null;
// スレッド 1
holder = new Holder(); // オブジェクトを生成
// スレッド 2
if (holder != null) {
System.out.println(holder.value); // 42 ではなく 0 を見る可能性がある!
}
どう公開すれば安全か?
1. final フィールド経由
オブジェクトのすべてのフィールドが final で、コンストラクタで初期化されるなら、追加の同期なしに安全に公開できます。
class SafeHolder {
final int value;
SafeHolder() { value = 42; }
}
2. volatile 参照経由
オブジェクトへの参照を volatile として宣言すれば、代入後にそのオブジェクトは他のスレッドから確実に見えるようになります。
volatile Holder holder;
// スレッド 1
holder = new Holder();
// スレッド 2
if (holder != null) {
System.out.println(holder.value); // 42 を必ず観測できる
}
3. 公開前の単一スレッド初期化
参照が他のスレッドからアクセス可能になる前に、単一スレッド内でオブジェクトを生成・初期化すれば安全です。
Holder holder = new Holder(); // 単一スレッド内のみ
// ... その後 holder は他のスレッドから利用可能になる
4. ロックを用いる
オブジェクトを同期化ブロック内で生成し、その参照を同じブロック内でのみ読み取るなら安全です。
Holder holder;
synchronized(lock) {
if (holder == null) {
holder = new Holder();
}
}
// ... 別のスレッドで
synchronized(lock) {
if (holder != null) {
// 安全
}
}
4. Double-checked locking と volatile
double-checked locking とは?
これは singleton オブジェクトを遅延初期化するためのパターンです:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第1チェック
synchronized (Singleton.class) {
if (instance == null) { // 第2チェック
instance = new Singleton();
}
}
}
return instance;
}
}
問題: instance への参照が volatile でないと、このコードは正しく動作しません。別のスレッドが未完全に初期化されたオブジェクトを見る可能性があります。
なぜ volatile がないと危険か?
JVM は命令を再順序化でき、コンストラクタの完了前にオブジェクト参照が代入されることがあります。別のスレッドは未初期化のオブジェクトを見てしまいます。
正しい書き方
instance を volatile として宣言します:
private static volatile Singleton instance;
これで double-checked locking は正しく動作します。volatile は参照の書き込みと読み取りの間に happens-before を保証します。
代替: 静的初期化
singleton を作る最も簡単で安全な方法は、静的初期化を使うことです:
class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() { return INSTANCE; }
}
ここでは JVM 自身が正しい初期化を保証します。
5. VarHandle: 現代的な低レベルアクセス
VarHandle とは?
VarHandle は(Java 9+ の)モダンな API で、変数を低レベルに扱えます。読み書き、アトミック操作、可視性や命令順序の制御ができます。
アトミッククラスがあるのに VarHandle が必要な理由は?
— VarHandle は任意のフィールドを扱えます(int/long/Reference に限らない)。
— アクセスのセマンティクスを明示的に選べます: volatile、acquire/release、opaque。
— 高性能なデータ構造の実装に使われます。
アクセスのセマンティクス
- Volatile: happens-before の完全な保証(volatile フィールドと同様)。
- Acquire/Release: より弱い保証だが高速(ロックフリー構造に使用)。
- Opaque: 最小限の可視性保証だが最大の性能。
VarHandle の使用例
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
int value;
static final VarHandle VALUE_HANDLE;
static {
try {
VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
} catch (Exception e) {
throw new Error(e);
}
}
}
Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);
VarHandle を使う場面
- 自作のロックフリーなデータ構造を実装するとき。
- 命令順序を含む最大限の性能と制御が必要なとき。
- 一般的なアプリでは多くの場合、アトミッククラスや synchronized で十分です。
6. 偽共有(false sharing)とキャッシュ整列
False sharing は、2 つのスレッドが異なる変数を扱っていても、それらが同じ CPU のキャッシュライン上にあるために干渉し合う状況です。片方の変数を変更すると、もう片方のためのキャッシュが無効化されてしまいます。
たとえ話: 2 人が同じ机(キャッシュライン)に座り、それぞれ自分の半分だけに書いています。片方が何かを変えるたびに、もう片方は紙全体を「読み直す」羽目になります。
なぜ問題か?
- 性能が急激に落ちます。CPU はキャッシュの同期に時間を費やします。
- 異なるスレッドから頻繁に更新される「ホット」な変数では特に致命的です。
どう避けるか?
「ホット」なフィールドを別オブジェクトに分けるか、整列のための特別なアノテーション/構造を使います(例: @Contended)。現代の JVM ではオプション -XX:-RestrictContended を有効にし、フィールド整列のために @sun.misc.Contended(Java 8+)を使えます。
例:
@sun.misc.Contended
public volatile long value1;
@sun.misc.Contended
public volatile long value2;
注意: @Contended は標準 API ではありませんが、JDK のアトミッククラス最適化で使われています。
7. 実践: singleton を修正し、JMH でミニベンチ
動かない singleton を修正
悪い例(volatile なし):
class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton();
}
}
}
return instance;
}
}
良い例(volatile あり):
class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
ベストは静的初期化:
class StaticSingleton {
private static final StaticSingleton INSTANCE = new StaticSingleton();
public static StaticSingleton getInstance() { return INSTANCE; }
}
JMH ミニベンチ: 可視性とアトミック性
注意: JMH は Java のマイクロベンチマーク専用フレームワークです。JMH なしに性能評価を行わないでください。結果は最適化やキャッシュの影響で簡単に誤解を招きます。
例: volatile の可視性を確認
public class VolatileVisibility {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// true が見えるまでループ
}
// 変更を観測した
}
}
例: volatile はアトミックではない
public class VolatileNotAtomic {
volatile int counter = 0;
public void increment() {
counter++; // アトミックではない!
}
}
volatile であっても、複数スレッドから同時にインクリメントすると最終値は期待より小さくなります(操作 counter++ は読み取り・演算・書き込みに分解されるため)。
8. JMM・volatile・公開に関する典型的なミス
エラー No.1: volatile にアトミック性を期待する。
volatile が保証するのは変更の可視性だけで、操作のアトミック性ではありません。counter++ は、変数が volatile でもアトミックにはなりません。
エラー No.2: 同期なしでオブジェクトを公開する。
あるスレッドで生成したオブジェクトを、volatile、synchronized、または final フィールドなしに他スレッドへ渡すと、受け手は「生」の値を見る可能性があります。
エラー No.3: volatile なしの double-checked locking。
singleton の参照が volatile でないと、他スレッドで未初期化のオブジェクトを取得することがあります。
エラー No.4: 仮想スレッドと古いロックの併用。
いくつかの古い同期機構(native monitor など)は、JVM が仮想スレッドを効率的に管理するのを妨げることがあります。
エラー No.5: 偽共有を無視する。
「ホット」な変数がメモリ上で近接していると、キャッシュラインのためにスレッド同士が干渉します。
エラー No.6: JMH なしで性能判断をする。
JMH を使わないマイクロベンチは、JVM の最適化や CPU キャッシュの影響で誤った結果を出しがちです。
GO TO FULL VERSION