背景
JJUG CCC 2023 Spring にて、櫻庭祐一さんによる Virtual Threads - 導入の背景と、効果的な使い方 というセッションがあった。
このセッションの中で、 Virtual Thread を使う場合はなるべく synchronized
を使わないようにすべきであること、またどうしてもロックが必要であれば ReentrantLock
などのロッククラスを使うようにすることが説明されていた。
それを聞いていて、「え? ReentrantLock
って synchronized
を使わずに同期化を実現しているってこと? どうやって?」と疑問に感じたので、仕組みを調べてみた。
あと、 ReentrantLock
と synchronized
の使い分けとかもちょっとだけ調べた。
環境
> gradle --version
------------------------------------------------------------
Gradle 8.1.1
------------------------------------------------------------
Build time: 2023-04-21 12:31:26 UTC
Revision: 1cf537a851c635c364a4214885f8b9798051175b
Kotlin: 1.8.10
Groovy: 3.0.15
Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM: 17.0.5 (Eclipse Adoptium 17.0.5+8)
OS: Windows 10 10.0 amd64
ReentrantLock とは
ReentrantLock (Java SE 17 & JDK 17)
synchronizedメソッドおよび文を使用してアクセスする暗黙の監視ロックと同じ基本動作およびセマンティックスを使用し、かつ拡張機能を持つ、再入可能な相互排他Lockです。
Java 1.5 で追加されたロックを制御するためのクラス。
synchronized
と同じことができるうえに、拡張機能もあるらしい。
以下のような感じで使用する。
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
-
lock()
でロックを取得し、unlock()
でロックを解放する - 解放漏れを防ぐため、
finally
句でunlock()
しておいたほうがいい
ReentrantLock は synchronized を使用していないのか
lock() メソッドのソースコード を見てみると、確かに synchronized
は使われていないことが分かる(sync.lock() や、その先にも synchronized
は存在しない)。
public void lock() {
sync.lock();
}
synchronized の役割
Java の synchronized
には、大きく以下の役割がある。
- 排他制御
- メモリの可視性の保証
排他制御
要するに、あるスレッドが処理している間は別のスレッドは同じ処理が出来ないように止めておくという話。
これは散々いろいろなところで説明されている話だと思うので、省略する。
メモリの可視性の保証
synchronized
のもう1つの重要な役割として、メモリの可視性の保証がある。
Java では様々な理由により、あるスレッドで変数(メモリ)に書き込まれた値が別のスレッドからそのままの形で見れるとは限らない。
以下に、変数に書き込まれたはずの値が見れない(メモリの可視性が保証されない)現象の例を記載する。
スレッドごとのキャッシュ
以下のようなコードがあったとする。
※Javaのvolatile修飾子の使い方を現役エンジニアが解説【初心者向け】 | TechAcademyマガジン のコードを参考にした。
package sandbox.async;
public class ThreadCacheSample {
private static int a;
public static void main(String[] args) throws Exception {
final Thread t1 = new Thread(() -> {
for (int i=0; i<5; i++) {
a = i+1;
System.out.println("[t1] a=" + a);
try {
Thread.sleep(1000);
} catch (InterruptedException e) { /* ignore */ }
}
});
final Thread t2 = new Thread(() -> {
int previousAValue = a;
while (a < 5) {
if (previousAValue != a) {
System.out.println("[t2] a=" + a);
previousAValue = a;
}
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
}
- スレッド1(
t1
)では、変数a
に対して1秒ごとに1加算した値を代入しつつ、加算後のa
の値を出力している - スレッド2(
t2
)では、ループをしつつ変数a
の値が変わったかどうかをチェックしていて、値が変わったことを検知したらその値を出力している-
a
の値が5
以上になったらループを終える
-
これを実行すると、以下のようになる。
[t1] a=1
[t1] a=2
[t1] a=3
[t1] a=4
[t1] a=5
この処理は、この表示のまま止まってプログラムは終了しなくなる。
t2
の出力が全くされていないことから、 t2
では変数 a
の値が変わったことが検知できていないことが分かる(t2
からは、変数 a
の値はずっと 0
のままに見えている)。
これは、スレッドごとに変数 a
の値がキャッシュされることで発生している。
synchronized による可視性の保証
Java でマルチスレッドのプログラムを書いた場合、上記のようにメモリの可視性が保証されないケースが起こりえる。
synchronized
には、このメモリの可視性を保証してくれる役割がある。
synchronized
は、ロックを取得するときにロックオブジェクトを指定する。
あるスレッドαがロックオブジェクトAでロックを取得して、そのスレッド内でいくつかのメモリに値を書き込んだとする。
スレッドαがロックを解放し、次に別のスレッドβが同じロックオブジェクトAでロックを取得したとする。
このとき、スレッドβからはスレッドαで書き出されたメモリの状態(ロックオブジェクトAによるロックが解放されるまでにメモリに書き出された値)が見えることが保証されるようになっている。
試しに、先ほどのコードに synchronized
を導入してみる。
package sandbox.async;
public class ThreadCacheSample {
private static int a;
public static void main(String[] args) throws Exception {
final Thread t1 = new Thread(() -> {
for (int i=0; i<5; i++) {
synchronized (ThreadCacheSample.class) { // ★
a = i+1;
System.out.println("[t1] a=" + a);
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) { /* ignore */ }
}
});
final Thread t2 = new Thread(() -> {
int previousAValue = a;
while (a < 5) {
synchronized (ThreadCacheSample.class) { // ★
if (previousAValue != a) {
System.out.println("[t2] a=" + a);
previousAValue = a;
}
}
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
}
- スレッド1(
t1
)で変数a
に値を書き込んでいるところと、スレッド2(t2
)で変数a
の値を読み込んでいるところを、それぞれsynchronized
で囲った - ロックオブジェクトとしては、いずれも
ThreadCacheSample.class
を指定した
これを動かすと、以下のようになる。
[t1] a=1
[t1] a=2
[t2] a=2
[t1] a=3
[t2] a=3
[t1] a=4
[t2] a=4
[t1] a=5
[t2] a=5
t2
で変数 a
の値が変わっていることが検知できるようになり、処理は終了した。
揮発性変数
synchronized
以外に Java に用意されている同期化に関する仕組みとして、揮発性変数(volatile variable)というものがある(volatile は、カタカナ英語で「ボラタイル」)。
揮発性変数は、クラスのフィールドに volatile
修飾子を付けることで宣言できる。
public class Hoge {
private volatile int n; // n は揮発性変数になる
}
変数を揮発性変数にすると、その値はスレッドにキャッシュされなくなる。
これにより、揮発性変数から読み取った値は、常に最後にその揮発性変数に書き出された値であることが保証されるようになる(つまり、あるスレッドが揮発性変数に書き出した値は、他のスレッドからも確実に見ることができる)。
試しに、前節のコードの変数 a
を揮発性変数に書き換えてみる。
package sandbox.async;
public class ThreadCacheSample {
private static volatile int a; // ★ a を揮発性変数に変更
public static void main(String[] args) throws Exception {
final Thread t1 = new Thread(() -> {
for (int i=0; i<5; i++) {
a = i+1;
System.out.println("[t1] a=" + a);
try {
Thread.sleep(1000);
} catch (InterruptedException e) { /* ignore */ }
}
});
final Thread t2 = new Thread(() -> {
int previousAValue = a;
while (a < 5) {
if (previousAValue != a) {
System.out.println("[t2] a=" + a);
previousAValue = a;
}
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
}
[t1] a=1
[t1] a=2
[t2] a=2
[t1] a=3
[t2] a=3
[t2] a=4
[t1] a=4
[t2] a=5
[t1] a=5
a
の値がスレッドにキャッシュされなくなり t2
スレッドからは常に最新の a
の値が見れるようになったため、問題なく処理が完了した。
改めて ReentrantLock はどうやって同期化を実現しているのか
synchronized
には、以下の2つの役割がある。
- 排他制御
- メモリの可視性の保証
ReentrantLock
は synchronized
を使わずに、これらの役割を実現していることになる。
一体どうやって実現しているのか?
排他制御の実現
まずは排他制御をどうやっているのか見てみる。
以下のようなコードを書いて動かしてみる。
package sandbox.async;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Hoge {
public static void main(String[] args) throws Exception {
Lock lock = new ReentrantLock();
final Thread t1 = new Thread(() -> {
lock.lock();
try {
TimeUnit.MINUTES.sleep(60);
} catch (Exception e) {
// ignore
} finally {
lock.unlock();
}
});
t1.setName("t1");
final Thread t2 = new Thread(() -> {
try {
lock.lock();
} finally {
lock.unlock();
}
});
t2.setName("t2");
t1.start(); t2.start();
t1.join(); t2.join();
}
}
- 片方のスレッド(
t1
)でロックをとった状態で止めておき、もう片方のスレッド(t2
)でロック解放待ちを発生させようとしている
これを動かして t2
スレッドがロック解放待ちになったのを確認したら、 jconsole で接続して t2
スレッドのスタックトレースを確認してみる。
どうやら、 LockSupport.park()
というメソッドでスレッドを待機状態にしているっぽい(Unsafe
が使われているので、これ以上先は首を突っ込まないことにする)。
park()
を呼び出している元の AbstractQueuedSynchronizer
の acquire
メソッドを確認してみる。
private transient volatile Node head;
...
abstract static class Node {
volatile Node prev; // initially attached via casTail
volatile Node next; // visibly nonnull when signallable
Thread waiter; // visibly nonnull when enqueued
volatile int status; // written by owner, atomic bit ops by others
...
final int acquire(Node node, int arg, boolean shared,
boolean interruptible, boolean timed, long time) {
Thread current = Thread.currentThread();
byte spins = 0, postSpins = 0; // retries upon unpark of first thread
boolean interrupted = false, first = false;
Node pred = null; // predecessor of node when enqueued
for (;;) {
if (!first && (pred = (node == null) ? null : node.prev) != null &&
...
}
if (first || pred == null) {
...
}
if (node == null) { // allocate; retry before enqueue
...
} else if (pred == null) { // try to enqueue
...
} else if (first && spins != 0) {
--spins; // reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // enable signal and recheck
} else {
...
if (!timed)
LockSupport.park(this);
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos);
else
break;
...
}
}
return cancelAcquire(node, interrupted, interruptible);
}
細かい判定の条件とかは置いておくとして、多分要するに for(;;)
で無限ループしつつ適宜状態を見て LockSupport.park()
でスレッドを待機させたりしているのかなと思う(適当)。
ポイントは、条件判定に使っている変数たち(Node
や、そのインスタンス変数など)が揮発性変数で宣言されている点かなと。
これにより、マルチスレッドで動いても問題なく条件判定が働いているのだと思う。
メモリの可視性の保証の実現
ここからが本題。
冒頭の「え? ReentrantLock
って synchronized
を使わずに同期化を実現しているってこと? どうやって?」と思った要因はここで、メモリの可視性の保証を synchronized
を使わずにどうやって実現しているのかが分からなかった。
当初の自分の揮発性変数に対する理解は、その揮発性変数だけの可視性が保証される、というものだった。
このため、揮発性変数以外の変数の可視性の保証がどうやって実現されているのかが分からなかった。
synchronized
の代わりになるためには、以下の図のように、直前のロックの最中にメモリに書き出された状態は次のロック開始後は全て可視になる必要がある(揮発性変数以外も)。
この仕組みを理解するためには、事前発生(happens-before)を理解する必要がある。
事前発生(happens-before)
スレッドの処理の中に登場する個々の処理(変数のリード・ライト、モニタのロック・アンロック、etc...)は、アクションと呼ばれる。
この、個々のアクションの順番を定めるルールの1つとして 事前発生(happens-before) というものが存在する。
あるアクションAとBがあり、AがBより事前発生している(A happens-before B)場合、Aのアクションで書き出された値はBから可視になる。
17.4.5. Happens-before Order
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.(訳)2つのアクションは happens-before 関係によって順序付けることができる。
もし、あるアクション(one action)が他のアクション(other action)より事前発生する場合、最初のアクション(one action)は2つ目のアクション(other action)から可視であり、2つ目のアクションの前に順序付けられる。
なんかややこしいことを言っているようだが、要するに A happens-before B の関係が成り立つなら、 A で書き出された値は B から見える、ということを言っている。
例えば以下のようなコードがあったとする。
int a = 0; // A
int b = a; // B
アクションAは、変数 a
に 0
を代入している。
アクションBは、変数 b
に a
の値を代入している。
A と B の間には A happens-before B の関係が成り立つことが言語仕様で決められている。
このため、アクション A で変数 a
に書き出された値はアクション B から見えることになる(当たり前の話)。
事前発生の関係は推移的
A happens-before B かつ B happens-before C が成り立つとき、 A happens-before C も成り立つ。
もうちょっと分かりやすくするため、A happens-before B を以下のように表現したとする。
すると、 A happens-before B かつ B happens-before C は次のように表現できる。
このとき、 C からは B だけでなく A も可視となる。
つまり、矢印を辿ってさかのぼれる分は、すべて可視となる。
事前発生が適用されるケース
事前発生が適用されるケースは、Javaの言語仕様でいくつか定められている。
プログラム順序のルール
同じスレッド内のアクションは、プログラムで書かれているそのままの順序で事前発生が適用される。
どういうことかというと、
public void method() {
int a = 0; // A
int b = a; // B
}
とプログラム上に書かれていたら、その順番のまま事前発生が適用される。
したがって、 A happens-before B となる(当たり前の話)。
言語仕様には以下のように書かれている。
17.4.5. Happens-before Order
If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.
- If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
(訳)
2つのアクション x, y に関して hb(x, y) と表現したとき、これは x happens-before y を表している。
- もし x と y が同じスレッド内のアクションであり、かつ x が y よりもプログラム順序で前にある場合、 hb(x, y) である
スレッドが異なれば事前発生は適用されない
前述の言語仕様に「同じスレッド内のアクション」とあるように、スレッドが異なる場合はそのままでは事前発生が適用されない。
int a, b;
int x, y;
public void method1() {
a = 1;
x = b;
}
public void method2() {
b = 1;
y = a;
}
method1
と method2
が別々のスレッドで実行される場合、事前発生の関係は以下のようになる。
それぞれのメソッドの中のアクションは同じスレッドで実行されるので事前発生の関係がある(矢印で結ばれている)。
しかし、お互いのメソッド間には事前発生の関係がないので矢印は結ばれない。
ここでは、 a = 1;
と y = a;
の間には事前発生の関係はないことになる。
したがって、仮に時系列上は a = 1;
のアクションが y = a;
のアクションより先に実行されていたとしても、 y = a;
の時点で a
の値が 1
で見れる保証はない。
スレッド間でメモリの可視性を保証するためには、このスレッド間に事前発生の関係を結んであげる必要がある。
モニタロックのルール
同じロックオブジェクトを使ったロックとアンロック(モニタロック)には、「アンロック happens-before その後のロック」という事前発生の関係が適用される。
ここで言っているモニタロックとは、 synchronized
のことを指している(ReentrantLock
のことではない)。
17.4.5. Happens-before Order
- An unlock on a monitor happens-before every subsequent lock on that monitor.
(訳)
- あるモニタのアンロックは、その後のそのモニタのロックより事前発生する
このモニタロックのルールを用いることで、スレッド間に事前発生の関係を持たせることができるようになる。
それぞれのスレッドで同じロックオブジェクトを使ったモニタロック(synchronized
)を行うようにすると、アンロックとロックの間で事前発生の関係が結ばれるようになる。
これにより、スレッド間のアクションが事前発生で一本につながるようになる。
この結果、直近アンロックするまでにメモリに書き出した情報は、次にロックを取得したスレッドからは全て可視になることが保証されるようになる。
揮発性変数のルール
揮発性変数への書き込みは、その後に行われる同じ揮発性変数からの読み込みよりも事前発生する(揮発性変数への書き込み happens-before その後の同じ揮発性変数からの読み込み)。
17.4.5. Happens-before Order
- A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.
(訳)
- volatile フィールドへの書き込みは、その後の同じフィールドの読み込みより事前発生する
図にすると以下のような感じ。
揮発性変数vがあったとして、スレッド1でそこに値を書き込んだとする。
次にスレッド2で同じ揮発性変数vから値を読み込む。
すると、2つのアクションの間には事前発生の関係が結ばれるようになる。
これにより、モニタロックのときと同じようにスレッド間のアクションが一本の事前発生の関係で結ばれるようになる。
同期化の相乗り
揮発性変数は、自身の変数の可視性を保証するのが本来の役割となっている。
しかし、上述のように事前発生のルールを利用することで他の変数に関する可視性を保証することができるようになる。
このように他の同期化の仕組みを利用して別の部分で同期化を実現するテクニックを、同期化の相乗りと呼ぶ。
この方法は synchronized
を使わないので、より低いコストでメモリの可視性の保証ができるようになる(※Java 1.6 で synchronized
の内部の仕組みが改善されており、 synchronized
でもそんなに速度は遅くならないらしい)。
しかし、相乗りのコードを正確に記述しないと正しく同期化ができないので、実際に使う場合は細心の注意が必要となる(というか危険なので自前で実装すべきではない)。
Java の標準ライブラリには、この同期化の相乗りを安全に利用するためのクラスが用意されている。
それが java.util.concurrent パッケージ 配下で提供されているロックに関するクラスたちであり、 ReentrantLock もその1つとなる。
ReentrantLock の同期化の相乗り
ようやく疑問の答えにたどり着いた。
ReentrantLock
は同期化の相乗りを利用することで synchronized
を使わずに synchronized
と同等のメモリの可視性の保証を実現している。
ReentrantLock
は、次のような構成になっている。
ReentrantLock
の内部に Sync
というクラスがあり、その親クラス(AbstractQueuedSynchronizer
)に state
と呼ばれる揮発性変数が定義されている。
そして、 lock()
と unlock()
を呼ぶと、おおざっぱに内部で以下のようなことが行われている。
lock()
時は揮発性変数の state
から値を読み込み、 unlock()
のときに値が書き込まれる。
これにより、同期化の相乗りが起こりメモリの可視性が保証されるようになる。
以上で、 ReentrantLock
は synchronized
と同等の同期化ができるようになる。
ReentrantLock と synchronized の違い
最後に、 ReentrantLock
と synchronized
の違いを軽くまとめる。
ReentrantLock(Lock) にだけできることとかメリットとか
- ロックの待機にタイムアウトを設定できる
- tryLock(long, TimeUnit) を使えば、ロックの解放待ちにタイムアウトを設定できる
-
synchronized
の場合は永遠に待ち続けるので実装順序をミスるとデッドロックになる
- ロックの待機中にインタラプト(割り込み)ができる
-
synchronized
によるロック待ちはインタラプト(割り込み)ができない -
tryLock(long, TimeUnit)
でロックを待機していれば、ロック待ちに対してもインタラプトができる
-
- 公平なロックができる
- 公平なロックというのは、ロックの解放を待っているスレッドが複数存在したときに、先に待機を開始したスレッドの方が優先的に次のロックを取得できることを意味する
- 不公平なロックの場合は、次にロックを取得できるのはロックが解放された後で最初にロックの取得を試みたスレッドになるので、後から待機し始めていたスレッドがロックを取れる可能性がある
-
synchronized
によるロックは不公平なロックのみだが、ReentrantLock
の場合はいずれかを選択できる - 一見すると不公平よりは公平なほうがよさそうに思えるが、公平なロックを実現するためには待機スレッドのキュー管理が必要になるため、その分処理のオーバーヘッドが発生する
- 実際のところ、多くのケースは不公平なロックで問題無く、またスループットも出るため、
ReentrantLock
もデフォルトは不公平なロックとなっている - 公平なロックでないと困るアルゴリズムなときだけ、公平なロックを使うようにしたほうがいい
ReentrantLock(Lock) のデメリットとか
-
unlock()
を明示的に行う必要がある-
synchronized
ならロックの解放漏れとかはないが、ReentrantLock
(Lock
) を使う場合はロックの解放(unlock()
) を明示的に行わなければならない -
unlock()
を忘れると永遠にロックが解放されないことになるので注意が必要 - FindBugs とかで解放漏れがないか静的チェックができるらしい
-
性能差
-
ReentrantLock
とsynchronized
では、そんなに性能差はでないらしい-
ReentrantLock
が出た当時の Java 1.5 の頃は、synchronized
と比較してReentrantLock
の方が実行性能が何倍も高い状態だった - しかし、 Java 1.6 で
synchronized
の内部の仕組みが改善されたことにより、ReentrantLock
とsynchronized
の間にそこまで大きな性能差はでなくなったらしい(Java 並行処理プログラミングの「13-2 実行性能への配慮」より)
-