JDK24からVirtualThreadでsynchronizedしてもPlatformThreadに固定されなくなった

本稿は[ラクス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/rakus)の二日目です。

先日、JDK24（2025年3月リリース予定）の改善として、[JEP 491: Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491)が追加されました。
すでにアーリーアクセス版にも実装済みなので、どのような動きになるか試してみたいと思います。

# 背景

## そもそもVirtualThreadとは

従来のスレッドはOSスレッドとして実装されており、ブロッキングI/Oでプロセッサを占有してしまうという性質があります。
ブロッキング中のプロセッサ自体はほとんど遊んでいる状態で、この間に他の処理を実行することができれば効率的です。
JDK20まではこれの実現のためにリアクティブといった手法が用いられてきましたが、従来のプログラミングスタイルとは異なる非同期的な考え方が求められることから、実装のハードルが高いものとなっていました。
そこで登場したのがVirtualThreadです。

VirtualThreadはJDK21で追加された、従来とは異なる仕組みのスレッドです。
その特徴はおおむね以下の通りです。

- JavaのスレッドをOSスレッドと1:1のPlatformThread、PlatformThreadに対して1:NのVirtualThreadに分ける
- VirtualThreadはタスクを実行する際にPlatformThreadにマウントされ、タスクの完了、もしくはブロッキングの待機中にアンマウントされる
- 空いたPlatformThreadには別のVirtualThreadがマウントされ、あるVirtualThreadのブロッキング中にも別のタスクを実行することができる
- 従来の同期的なプログラミングスタイルで上記を実現できる

これにより、リアクティブのような難易度の高い手法を用いることなく、I/Oバウンドなアプリケーションで従来よりもスケーリングを向上させることが可能になりました。

ですがこのVirtualThreadには、一つ大きな落とし穴がありました。

## synchronizedでPlatformThreadに固定される問題

Javaではあるオブジェクトやメソッドに対してアクセス可能なスレッドを一つに制限する手法として、synchronizedブロックもしくはメソッドが用いられてきました。
JDK23までのVirtualThreadでこのsynchronizedを含むプログラムを実行すると、通常であればPlatformThreadからアンマウントできる状態であるにもかかわらず、アンマウントできなくなります。

簡単な例を用いて確認してみましょう。

```java
package org.example;

import java.util.concurrent.CountDownLatch;

public class Main {

    public static void main(String[] args) {
        int threadCount = 20;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 1; i <= threadCount; i++) {
            final int threadId = i;
            Thread.ofVirtual().start(() -> {
                try {
                    Object lock = new Object();
                    synchronized (lock) {
                        System.out.println("Thread" + threadId + ":start");

                        // ダミーAPI呼出し（10秒待機）
                        DummyApiCaller dummyApiCaller = new DummyApiCaller();
                        dummyApiCaller.call();
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        try {
            // すべてのスレッドが終了するまで待機
            latch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
```

このプログラムはVirtualThreadを20個生成し、各スレッド内でダミーのWeb APIを呼び出しています。
またAPI呼び出し部分はsynchronizedブロック内に実装されています。
ダミーのAPIはレスポンス返却に10秒かかるよう実装されているため、各スレッドはAPI呼び出しで10秒間ブロッキングされることになります。

本来であればブロッキング中にPlatformThreadからアンマウントされるため、APIのレスポンス待機中も別のスレッドが立ち上がるはずです。
しかもプログラムを見ればわかる通り、ロックオブジェクトはスレッドローカルなインスタンスであり、synchronizedブロックは実質的に何も排他しない状態です。
にもかかわらず、このプログラムをJDK23で実行すると、以下のような挙動になります。

※プロセッサ数＝16
![画面録画-2024-11-30-173612.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3291514/ab55dae5-21dd-2964-57de-d53657fc370c.gif)

番号が順不同なので少々わかり辛いですが、最初に16個のスレッドが立ち上がって、おおよそ10秒経過後に残り4つのスレッドが立ち上がっています。
この挙動は、16個のVirtualThreadがsynchronizedブロック内でブロッキング状態となり、PlatformThreadに固定されてしまったことによって引き起こされています。

これが、VirtualThreadが固定化されてしまう現象です。
この現象を回避するにはsynchronizedブロックまたはメソッドを使用しない以外に方法がないため、基本的にVirtualThreadで排他制御する際は、ReentrantLockのような再入可能なロックへの転換が推奨されてきました。


# JEP 491による改善

これに対し、synchronized内のブロッキングでもVirtualThreadをアンマウントできるよう改善したのが、「JEP 491: Synchronize Virtual Threads without Pinning」です。

早速[OpenJDK24のアーリーアクセス版](https://jdk.java.net/24/)（本稿執筆時点ではbuild25）で、先ほどのプログラムを実行してみましょう。

![画面録画-2024-11-30-180836.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3291514/27650db1-44a4-5c8a-e42c-421995d46b6e.gif)

先ほどとは異なり、最初に20個のスレッドがすべて立ち上がり、おおよそ10秒経過後に処理が完了しました。
これにより、synchronizedブロック内のブロッキング処理でも、VirtualThreadがアンマウントできるようになっていることがわかるかと思います。

# まとめ

特にオチはないですが、JDK24で追加されるVirtualThreadの改善に関するご紹介でした。
synchronizedの影響でVirtualThreadの導入を見送られた方も少なくないかと思いますが、この改善によりそういった方々にも使いやすいものになったのではないかと思います。