Java | Java 詳細・モダン文法:並行・非同期 – happens-before

Java Java
スポンサーリンク

happens-before を一言でいうと

happens-before は、
この処理が終わってから、あの処理が“必ずその結果を見える形で”起きる
という“時間とメモリの順序”を表す言葉です。

もっと砕くと、

「A が B の前に“ちゃんと起きたことを、Java が保証してくれる関係”」

だと思ってください。
マルチスレッドでは、この「順序の保証」がないと、
「書いたはずの値が見えない」「順番がおかしく見える」という不思議なバグが起きます。


なぜ happens-before が必要になるのか

単一スレッドだけなら、コードの上から順に実行されます。

x = 1;      // ①
y = x + 1;  // ②
Java

このとき、「① が終わってから ② が実行される」は直感通りです。
y は必ず 2 になります。

ところが、スレッドが増えると話が変わります。

一つのスレッドが変数に書き込んでも、
別のスレッドが「いつその値を見られるか」は、
何もしないと保証されません。

CPU やコンパイラが最適化のために命令の順番を入れ替えたり、
スレッドごとのキャッシュに値を持ったままにしたりするからです。

そこで Java は、

「この操作とこの操作の間には、
“happens-before” という順序の約束がある」

と定義しておき、
その約束があるところでは「書いたものが必ず見える」ようにしています。


単一スレッド内の happens-before

まず、いちばん基本のルールがあります。

同じスレッドの中では、プログラムの順序通りに happens-before が成り立つ

つまり、同じスレッド内では、

a = 1;   // ①
b = 2;   // ②
c = a;   // ③
Java

というコードがあったら、

① は ② に happens-before
② は ③ に happens-before

という関係になります。

この「単一スレッド内の順序」は、
マルチスレッドを考えるときの土台になります。


スレッド間の主な happens-before ルール

ここからが本題です。
「別スレッド同士で、どんなときに happens-before が成り立つか」です。

synchronized と happens-before

synchronized ブロックやメソッドには、
ロックの「取得」と「解放」があります。

Java は次のように約束しています。

あるロックに対して、
あるスレッドが unlock(ロック解放)する操作は、
同じロックを別のスレッドが lock(ロック取得)する操作に happens-before する

コードでイメージするとこうです。

class Shared {
    int value = 0;
    final Object lock = new Object();
}

Shared shared = new Shared();

// スレッドA
synchronized (shared.lock) {
    shared.value = 42;   // ①
}                        // ② unlock

// スレッドB
synchronized (shared.lock) {  // ③ lock
    System.out.println(shared.value); // ④
}
Java

ここでは、

② の unlock は ③ の lock に happens-before します。
その結果、

① で書き込まれた shared.value = 42 は、
④ で必ず 42 として見えます。

「同じロックを使っている限り、
ロックの解放前に行った書き込みは、
次にそのロックを取ったスレッドから必ず見える」

これが、synchronized が提供する happens-before の本質です。

volatile と happens-before

volatile 変数にも、はっきりしたルールがあります。

あるスレッドの volatile 変数への書き込みは、
その後に別のスレッドが同じ変数を読み込む操作に happens-before する

例を見ます。

class Config {
    static int value;
    static volatile boolean initialized = false;
}

// スレッドA
Config.value = 42;          // ①
Config.initialized = true;  // ②(volatile 書き込み)

// スレッドB
if (Config.initialized) {   // ③(volatile 読み込み)
    System.out.println(Config.value); // ④
}
Java

ここでは、

② の initialized = true(volatile 書き込み)は、
③ の initialized 読み込みに happens-before します。

その結果、

① の value = 42 も、
④ から必ず見えるようになります。

「volatile への書き込み前に行った処理は、
その volatile を読んだスレッドから必ず見える」

これが、volatile が提供する happens-before です。

スレッドの start / join と happens-before

スレッドの開始と終了にも、順序の約束があります。

Thread.start() については、

スレッド A がスレッド B に対して start() を呼ぶ前に行った処理は、
B の run() の中から必ず見える

という happens-before が成り立ちます。

int x = 0;

Thread t = new Thread(() -> {
    System.out.println(x); // ②
});

x = 42;   // ①
t.start();
Java

ここでは、

① は ② に happens-before します。
なので、run の中からは x == 42 が必ず見えます。

Thread.join() については、

スレッド B の run() の中で行った処理は、
それに対して join() したスレッド A から必ず見える

という happens-before が成り立ちます。

int x = 0;

Thread t = new Thread(() -> {
    x = 42;  // ①
});

t.start();
t.join();    // ②

System.out.println(x); // ③
Java

ここでは、

① は ② に happens-before
② は ③ に happens-before

なので、③ では必ず x == 42 が見えます。


happens-before を「設計の言葉」として使う

ここまでの話を、実務でどう使うか。
ポイントは、「自分のコードに対して、順序の前提を言葉にしてみる」ことです。

例えば、さきほどの Config の例なら、

initialized が true なら、value は必ず初期化済みである」

という前提でコードを書いています。
これはそのまま、

initialized の volatile 書き込みと読み込みの間に happens-before がある」

と言い換えられます。

synchronized を使うときも同じです。

「この共有データに対する書き込みは、
必ずこのロックを持っているときにだけ行う」

と決めることで、

「ロック解放前に行った書き込みは、
次にロックを取ったスレッドから必ず見える」

という happens-before を得ています。

つまり、

「この処理とこの処理の間には happens-before がある」

と自分で言えるようになると、
「どこで何が見えるか」を論理的に説明できるようになります。


まとめ:happens-before を自分の言葉で説明するなら

あなたの言葉で happens-before を説明すると、こうなります。

happens-before は、
『この操作が終わってから、あの操作が“その結果を見える形で”起きる』
という順序の約束を表す言葉。

同じスレッド内ではプログラムの順序がそのまま happens-before になり、
スレッド間では、
synchronized のロック解放→取得、
volatile の書き込み→読み込み、
Thread.start()Thread.join() などが
happens-before を作る。

この関係があるところでは、
『書いた値が別スレッドから必ず見える』と安心して言えるし、
逆に happens-before がないところでは、
見え方が保証されない。

マルチスレッド設計では、
『どの操作とどの操作の間に happens-before を作るか』を意識することが、
不思議なバグを防ぐ鍵になる。」

タイトルとURLをコピーしました