この記事はラクス Advent Calendar 2025 14日目の記事です。
来年3月リリース予定のJDK26には、Virtual Threadの改善(JDK-8369238 Allow virtual thread preemption on some common class initialization paths)が盛り込まれています。
今回は検証プログラムも交えて、どのような改善が行われているか確認していきたいと思います。
概要
Virtual Threadにはいくつかのシチュエーションでキャリアスレッドからアンマウントできなくなる(ピン留め)問題が存在します。
その中の一つが、「初期化中のクラスに対してVirtual Threadがアクセスすると、初期化完了までそのスレッドがアンマウントできなくなる」問題です。
例として、2コアCPUの環境で3つのVirtual Threadを取り扱う場合を考えてみます。
以下のシーケンス図は、VALUEというstaticフィールドを持つクラス(SlowInitClass)の初期化中にVirtual Threadがアクセスを試みた場合、どのような現象が発生するかを表しています。
vthread-1およびvthread-2がSlowInitClassにアクセスする一方、vthread-3はSlowInitClassとは無関係の処理を行います。
まずvthread-1がCarrier-1にマウントされ、SlowInitClass.VALUEにアクセスします。
ここがSlowInitClassに対する初回アクセスとなるため、クラスの初期化が発生します(①)。
次にvthread-2が起動し、空いているCarrier-2にマウントされ、SlowInitClass.VALUEへのアクセスを開始します。
SlowInitClassはこの時点で初期化中のため、初期化終了まで待機することになります。
この時、vthread-2はCarrier-2にマウントされたままとなるため、すべてのキャリアスレッドが占有された状態となります。(②)
この状態でvthread-3を起動すると、キャリアスレッドの空きがないため処理を開始することができません。(③)
結果としてSlowInitClassの初期化が完了してどちらかのキャリアスレッドが空くまで待機することになります。(④)
ここで問題となるのは②の挙動です。
Virtual Threadの利点は、スレッドが待機中になった際に他のスレッドにCPUを明け渡すことで、効率的にCPUリソースを活用し、より多くのスレッドを同時に取り扱うことができるという点です。
②の挙動は、初期化終了待機中にキャリアスレッドを占有することになるため、結果としてその利点を損なっています。
そこで、初期化中のクラスに対するアクセス待機中にVirtual Threadをアンマウントできるようにするのが、JDK-8369238 Allow virtual thread preemption on some common class initialization pathsの改善内容になります。
これを適用すると、先ほどのシーケンス図は以下のように変化します。
②でvthread-2がSlowInitClassに対する初期化待機状態になると、キャリアスレッドからアンマウントされるように挙動が変化しています。
これによりキャリアスレッドに空きが生まれ、結果としてvthread-3の処理が直ちに実行されるようになっています。(③)
検証コード
以下のプログラムでこの挙動を確認してみます。
public class Main {
private static long programStart;
void main() throws Exception {
System.out.println("Java version: " + System.getProperty("java.version"));
System.out.println("キャリアスレッド数: " +
System.getProperty("jdk.virtualThreadScheduler.parallelism", "default"));
System.out.println();
programStart = System.currentTimeMillis();
// vthread-1: クラス初期化を実行(carrier-1を占有)
Thread vt1 = Thread.ofVirtual().name("vthread-1").unstarted(() -> {
String name = Thread.currentThread().getName();
System.out.println("[" + elapsedMs() + "ms] " + name + ": SlowInitClass初期化開始");
int value = SlowInitClass.VALUE;
System.out.println("[" + elapsedMs() + "ms] " + name + ": 初期化完了 (VALUE=" + value + ")");
});
// vthread-2: クラス初期化待機(JDK25: ピン留め、JDK26: アンマウント可能)
Thread vt2 = Thread.ofVirtual().name("vthread-2").unstarted(() -> {
String name = Thread.currentThread().getName();
System.out.println("[" + elapsedMs() + "ms] " + name + ": SlowInitClassにアクセス(初期化待機)");
int value = SlowInitClass.VALUE;
System.out.println("[" + elapsedMs() + "ms] " + name + ": 完了 (VALUE=" + value + ")");
});
// vthread-3: 単純な文字列出力
Thread vt3 = Thread.ofVirtual().name("vthread-3").unstarted(() -> {
String name = Thread.currentThread().getName();
String message = "Hello from " + name + "!";
System.out.println("[" + elapsedMs() + "ms] " + name + ": " + message);
});
// vt1を起動(クラス初期化を実行)
vt1.start();
// vt1が初期化中に入るのを待つ
Thread.sleep(500);
// vt2, vt3を同時起動
vt2.start();
vt3.start();
// 完了まで待機
vt1.join();
vt2.join();
vt3.join();
}
private static long elapsedMs() {
return System.currentTimeMillis() - programStart;
}
}
public class SlowInitClass {
public static final int VALUE;
static {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
VALUE = 26;
}
}
Mainは先ほどのシーケンス図に登場した3つのVirtual Threadを定義し実行します。
- vthread-1:
SlowInitClassを初期化 - vthread-2:初期化中の
SlowInitClassにアクセス - vthread-3:文字列
Hello from vthread-3!を標準出力に出力
SlowInitClassはstatic initializer内部で10秒間スリープすることで、クラス初期化に10秒かかる状態を作り出します。
これにより、10秒間のクラス初期化中に各スレッドがどのような挙動になるかを確認することができます。
さらに実行時には、VMオプション-Djdk.virtualThreadScheduler.parallelism=2を指定し、キャリアスレッドの上限を2に固定します。
これをJDK25と26で実行すると、結果はそれぞれ以下のようになります。
(26はEA Build 27を使用)
JDK25
Java version: 25.0.1
キャリアスレッド数: 2
[4ms] vthread-1: SlowInitClass初期化開始
[509ms] vthread-2: SlowInitClassにアクセス(初期化待機)
[10011ms] vthread-1: 初期化完了 (VALUE=26)
[10011ms] vthread-2: 完了 (VALUE=26)
[10020ms] vthread-3: Hello from vthread-3!
JDK26(EAビルド)
Java version: 26-ea
キャリアスレッド数: 2
[7ms] vthread-1: SlowInitClass初期化開始
[512ms] vthread-2: SlowInitClassにアクセス(初期化待機)
[513ms] vthread-3: Hello from vthread-3!
[10011ms] vthread-1: 初期化完了 (VALUE=26)
[10012ms] vthread-2: 完了 (VALUE=26)
25ではSlowInitClassの初期化が完了するまでキャリアスレッドに空きがないため、vthread-3の結果は一番最後に出力されます。
対して、26ではvthread-2がSlowInitClass初期化待機中にアンマウントされるため、その直後にvthread-3の処理が実行されます。
このことから、改善によって初期化中クラスへのアクセスによるピン留めが解消されていることがわかります。
まとめ
JDK24で行われたJEP 491: Synchronize Virtual Threads without Pinningに続き、プログラム修正なしで恩恵を享受できるありがたい改善でした。
ターゲットとなるシチュエーションは限定的ですが、初期化に時間のかかるクラスが含まれるアプリケーションでは相応の恩恵がありそうです。