仮想スレッドと synchronized
Java 21 では高スケーラビリティを実現するためのイノベーションとして、仮想スレッドが正式に導入されました。しかし、言語機能としてのロック機構 synchronized
キーワードは、1 つ以上の仮想スレッドが動作する従来のプラットフォームスレッド (キャリアスレッド) に対して作用するため、仮想スレッドがキャリからアンマウントできずにスタックしてしまい、synchronized
を抜けるまでキャリアにピンニング (固定化) され独占されしまいます。仮想スレッドとかプラットフォームスレッドとかを気にせず、どちらでも以前の synchronized
と同じように同期化するには、代わりに ReentrantLock
クラスを使用します。
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// 共有リソースへのアクセスなど
} finally {
lock.unlock();
}
}
ただし、上記のイディオムは柔軟さと引き換えに、lock()
と try
の間に処理を書けないことを強制できず、また finally
で unlock()
とかきっちり書かないと不具合が発生するため、注意する必要があります。
この対応が必要なのは JDK (java.io や java.net など) や各種フレームワーク、ライブラリも同じです。たとえば synchronized
やブロッキング操作が残っている古い通信ライブラリや JDBC ドライバなどを使用すると、アプリケーションを仮想スレッド対応しても、スケーラビリティが損なわれる可能性があります。
ReentrantLock を安全に使う 3 つの方法
構造的に安全に記述するには、以下のような方法があります。
1. ReentrantLock を try-with-resources
unlock
を AutoCloseable
へ委譲するクラスを作成し、try-with-resources で使用します。open
というメソッド名で close と対になることを明確にしています。サブクラスに AutoCloseable
を implements
してしまうと try (var _ = lock)
みたいに lock()
せずに try
できてまうので open()
の戻り値にし、AutoCloseable
を毎回生成しないように最初に委譲フィールドに保持しています。JDK Lock 系クラスの try-with-resources 対応は 2011 年に Project Coin と JSR 334 で要望がありましたが、JDK の下位互換性を保てないなどの理由により却下されました。
public class CloseableReentrantLock extends ReentrantLock {
private final AutoCloseable autoCloseable = this::unlock;
public AutoCloseable open() {
lock();
return autoCloseable;
}
}
private final CloseableReentrantLock lock = new CloseableReentrantLock();
public void m() throws Exception {
try (var _ = lock.open()) {
// 共有リソースへのアクセスなど
}
}
コメントで新しいクラスを作らない版をいただきました。ただし、lock()
後に処理が書けてしまうことと、現在は軽微だと思いますが 毎回 Closeable
インスタンス生成と関連する GC コストがあります。この生成オーバーヘッドに関しては前出の Project Coin での検討時にも指摘されていました。
private final ReentrantLock lock = new ReentrantLock();
public void m(int i) throws Exception {
lock.lock();
try (Closeable _ = lock::unlock) {
// 共有リソースへのアクセスなど
}
}
2. ReentrantLock をラムダで使う
同期ブロックをラムダとして渡せるサブクラスを作成して使用します。2013 年に JDK への組み込み要望があったのですが、この頃はラムダの毎回生成コストが無視できないことにより、却下されています。あと、Java 標準の関数インターフェースでは例外を外側でキャッチできないので、ちょっと使いにくいかもしれません。
public class GuardableReentrantLock extends ReentrantLock {
public void guard(Runnable guarded) {
lock();
try {
guarded.run();
} finally {
unlock();
}
}
}
private final GuardableReentrantLock lock = new GuardableReentrantLock();
public void m() {
lock.guard(() -> {
// 共有リソースへのアクセスなど
});
}
3. Lombok の @Locked
で ReentrantLock
Lombok の v1.18.31 から ReentrantLock
をサポートするアノテーション @Locked
が使用できます。フィールドも自動生成されるため、synchronized
と同じ雰囲気で ReentrantLock
できます。
@Locked
public void m() {
// 共有リソースへのアクセスなど
}
最大スレッド数
仮想スレッド数は無制限、プールすると逆に効率が落ちるためプールされません。仮想スレッドが使用するプラットフォームスレッドは、デフォルトで並列実行数が論理プロセッサー数、プラットフォームスレッドがブロックされたときの代替用として使用される最大プールサイズは 256 です。Web アプリケーションでは、Tomcat や Web サーバ、ゲートウェイなどの設定でリクエスト数を制限することが考えられます。
フレームワークなどの仮想スレッド対応
Spring Boot
Spring Boot では 3.2 から application.properties の spring.threads.virtual.enabled
(デフォルト false
) を true
にするだけで、Tomcat や Jetty のリクエスト、@Async
や @Scheduled
などで仮想スレッドが使用されるようになります。
https://spring.pleiades.io/spring-boot/docs/current/reference/html/features.html#features.task-execution-and-scheduling
副作用として仮想スレッドはデーモンスレッドになります。非デーモンスレッドが存在しない場合、JVM は終了します。この動作は、たとえば @Scheduled
を使用したアプリケーションがすぐ終了してしまう問題が発生します。非デーモンスレッドがなくなった場合でもアプリケーションを存続させたい場合は spring.main.keep-alive
(デフォルト false
) を true
に設定します。
関連記事
Tomcat
Tomcat 8.5.90 以降、server.xml Connector 要素の useVirtualThreads 属性 (デフォルト false) に true を設定することで、仮想スレッドが有効になります。