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 を作るか』を意識することが、
不思議なバグを防ぐ鍵になる。」
