Java仮想スレッドではsynchronizedの代わりにReentrantLockで同期化する

:::note warn
2025/03/24 追記
Java 24 以降では、仮想スレッドでも、従来の synchronized と同じような動作をするようになったため、この記事に記載のような、ピンニング回避のために ReentrantLock などを使用する必要はありません。

JEP 491: Synchronize Virtual Threads without Pinning
https://openjdk.org/jeps/491
:::

# 仮想スレッドと synchronized

Java 21 では高スケーラビリティを実現するためのイノベーションとして、仮想スレッドが正式に導入されました。しかし、言語機能としてのロック機構 `synchronized` キーワードは、1 つ以上の仮想スレッドが動作する従来のプラットフォームスレッド (キャリアスレッド) に対して作用するため、仮想スレッドがキャリからアンマウントできずにスタックしてしまい、`synchronized` を抜けるまでキャリアにピンニング (固定化) され独占されしまいます。仮想スレッドとかプラットフォームスレッドとかを気にせず、どちらでも以前の `synchronized` と同じように同期化するには、代わりに `ReentrantLock` クラスを使用します。

```java:標準の ReentrantLock を使用したイディオム
    private final ReentrantLock lock = new ReentrantLock();

    public void m() {
        lock.lock();
        try {
            // 共有リソースへのアクセスなど
        } finally {
            lock.unlock();
        }
    }
```

ただし、上記のイディオムは柔軟さと引き換えに、`lock()` と `try` の間に処理を書けないことを強制できず、また `finally` で `unlock()` とかきっちり書かないと不具合が発生するため、注意する必要があります。

:::note info
この対応が必要なのは 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 の下位互換性を保てないなどの理由により却下されました。

```java: AutoCloseable 対応した ReentrantLock のサブクラス
public class CloseableReentrantLock extends ReentrantLock {

    private final AutoCloseable autoCloseable = this::unlock;

	public AutoCloseable open() {
		lock();
		return autoCloseable;
	}
}
```

```java: 使用例: try-with-resources で自動アンロック
    private final CloseableReentrantLock lock = new CloseableReentrantLock();

    public void m() throws Exception {
        try (var _ = lock.open()) {
            // 共有リソースへのアクセスなど
        }
    }
```

コメントで新しいクラスを作らない版をいただきました。ただし、`lock()` 後に処理が書けてしまうことと、現在は軽微だと思いますが 毎回 `Closeable` インスタンス生成と関連する GC コストがあります。この生成オーバーヘッドに関しては前出の Project Coin での検討時にも指摘されていました。

```java: 新しいクラスを作らない版
	private final ReentrantLock lock = new ReentrantLock();

	public void m(int i) throws Exception {
	    lock.lock();
	    try (Closeable _ = lock::unlock) {
            // 共有リソースへのアクセスなど
	    }
	}
```

## 2. ReentrantLock をラムダで使う

同期ブロックをラムダとして渡せるサブクラスを作成して使用します。2013 年に JDK への組み込み要望があったのですが、この頃はラムダの毎回生成コストが無視できないことにより、却下されています。あと、Java 標準の関数インターフェースでは例外を外側でキャッチできないので、ちょっと使いにくいかもしれません。

```java: ラムダで使えるようにした ReentrantLock のサブクラス
public class GuardableReentrantLock extends ReentrantLock {

	public void guard(Runnable guarded) {
		lock();
		try {
			guarded.run();
		} finally {
			unlock();
		}
	}
}
```

```java: 使用例: ラムダで自動アンロック
    private final GuardableReentrantLock lock = new GuardableReentrantLock();

    public void m() {
        lock.guard(() -> {
            // 共有リソースへのアクセスなど
        });
    }
```

## 3. Lombok の `@Locked` で ReentrantLock

Lombok の v1.18.31 から `ReentrantLock` をサポートするアノテーション `@Locked` が使用できます。フィールドも自動生成されるため、`synchronized` と同じ雰囲気で `ReentrantLock` できます。

```java: Lombok で 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](https://spring.pleiades.io/spring-boot/docs/current/reference/html/features.html#features.task-execution-and-scheduling)

:::note warn
副作用として仮想スレッドはデーモンスレッドになります。非デーモンスレッドが存在しない場合、JVM は終了します。この動作は、たとえば `@Scheduled` を使用したアプリケーションがすぐ終了してしまう問題が発生します。非デーモンスレッドがなくなった場合でもアプリケーションを存続させたい場合は `spring.main.keep-alive` (デフォルト `false`) を `true` に設定します。
:::
関連記事

https://qiita.com/cypher256/items/347f86ba10075debe6e6

## Tomcat

Tomcat 8.5.90 以降、server.xml Connector 要素の useVirtualThreads 属性 (デフォルト false) に true を設定することで、仮想スレッドが有効になります。

https://qiita.com/cypher256