Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

JJUG CCC 2025 Fall: Virtual Thread Deep Dive

Avatar for ternbusty ternbusty
November 15, 2025

JJUG CCC 2025 Fall: Virtual Thread Deep Dive

Avatar for ternbusty

ternbusty

November 15, 2025
Tweet

More Decks by ternbusty

Other Decks in Programming

Transcript

  1. © LY Corporation 2 Ayako Hayasaka LINEヤフー株式会社 Software Engineer 2023

    年度⼊社 SWAT チーム所属 Web バックエンド領域で全社横断的な技術⽀援を実施 メインは Spring Boot + Java / Kotlin • RAG 技術を利⽤した業務効率化ツール SeekAI 開発 • Yahoo!知恵袋の AI 回答機能「みんなの知恵袋」開発 • ⽣成 AI を利⽤した QA 領域の⽣産性向上ツール開発 • Yahoo!きっずフィルタリング機能 AI 導⼊ © LY Corporation
  2. © LY Corporation 3 Virtual Thread (VT) のよくある説明 • Java

    21 で正式に導⼊された • I/O 待ち時間を有効活⽤し、スループット向上! • とても軽量なので、⼤量に⽴ち上げても⼤丈夫!
  3. © LY Corporation • VT は必要に応じて OS レベルの Carrier Thread

    (CT) にマウントされて処理を⾏う • I/O 待ちになると、VT は CT からアンマウントされ、その CT は他のタスクのマウントが 可能になる • I/O 処理が完了すると、VT は再び CT にマウントされ、処理が再開される 4 Virtual Thread (VT) のよくある説明 Carrier Thread VT1 VT2 cf. Spring Framework 6.1 / Spring Boot 3.2 の注⽬機能紹介 I/O 待ち発⽣ I/O 処理完了 VT1 VT2 VT1 何もしない VT2 何もしない
  4. © LY Corporation 5 よく考えてみると、わからない I/O 待ちを どうやって検知してるの? 中断したタスクを どうやって再開するの?

    スレッド⼤量に⽴てて メモリ消費は⼤丈夫なの? Carrier Thread は 何本くらいあるの? Carrier Thread に張り付いて 剥がれなくなることはないの? ThreadLocal も 使えるの?
  5. © LY Corporation Virtual Thread の Mount/Unmount の仕組み Virtual Thread

    のスケジューリングの仕組み JDK24 以前に存在した問題点 Virtual Thread 利⽤時のアンチパターン ThreadLocal と ScopedValue 01 03 02 04 05 6 Agenda
  6. © LY Corporation 前提: Java のメモリ空間について簡単に復習 ヒープ スタック スタック new

    された オブジェクトの実体は 全てヒープに載る スタックは OS スレッド 1 本ごとに 1MB〜確保され スタックフレームが格納 される print() - Object o doSomething()  0CKFDUP  *OUJ Object o オブジェクトの実体ではなく その参照が格納される 8
  7. © LY Corporation 前提: Platform Thread はメモリをどう使う? ヒープ スタック スタック

    スレッドのスタックフレーム 1 Platform Thread あたり 1MB 確保が 必要なので数百万本など⼤量に⽣やすのは不可能 スタックにある変数のインスタンスではなく その参照が格納されているため中⾝はスカスカ Thread Instance 9 Thread オブジェクト ⾃体もインスタンスなので metadata などがヒープに 格納されている • start() などのメソッド • Thread の id • Thread の名前 • ThreadLocal の Map など Cf. https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/Thread.java
  8. © LY Corporation Virtual Thread も⼤まかな仕組みは⼀緒 ヒープ スタック スタック Virtual

    Thread のスタックフレーム その Virtual Thread が mount されている Carrier Thread ⽤に確保された領域を使⽤ 10 Virtual Thread インスタンス (中⾝に関しては後述)
  9. © LY Corporation Unmount 時はどうしたらいい? ヒープ スタック スタック Virtual Thread

    のスタックフレーム 次にこの Carrier Thread を利⽤する Virtual Thread の ためにこのスタック領域を開け渡さなければならない! だが、単に削除するとスタックフレームを失って 再開できなくなってしまう! 11 Virtual Thread インスタンス (中⾝に関しては後述)
  10. © LY Corporation Unmount 時はどうしたらいい? ヒープ スタック スタック Virtual Thread

    のスタックフレーム Unmount 時のスタックの状態を保存して ヒープに書き出してしまえばいい! 12 Virtual Thread インスタンス (中⾝に関しては後述)
  11. © LY Corporation Unmount 時はどうしたらいい? ヒープ スタック スタック Virtual Thread

    のスタックフレーム 別 Virtual Thread のスタックフレームを無事 スタック領域に乗せて使い回すことができた 13 Virtual Thread インスタンス (中⾝に関しては後述)
  12. © LY Corporation 再度 mount する時はどうしたらいい? ヒープ スタック スタック Virtual

    Thread のスタックフレーム 保存しておいたスタックフレームをコピーして 復元すれば再開できる! 14 Virtual Thread インスタンス (中⾝に関しては後述)
  13. © LY Corporation 15 Virtual Thread のクラス定義を⾒てみよう final class VirtualThread

    extends BaseVirtualThread { // 中略 private final Executor scheduler; // 中略 private volatile int state; // 中略 private final Continuation cont; (遡っていくと) Thread クラスを継承しているので ThreadLocalMap とかも持ってる なんらか状態を 持っているらしい Continuation って なんだ……? cf. https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java
  14. © LY Corporation 16 Continuation (継続) • これがさっきの「スタックフレームを保存して書き出したもの」の正体 public class

    Continuation { // 中略 private StackChunk tail; // 中略 private volatile boolean mounted; // 中略 private Object[] scopedValueCache; 実際のスタックデータは StackChunk として保持されている cf. https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/jdk/internal/vm/Continuation.java
  15. © LY Corporation 17 ヒープ上にスタックの状態を保持する仕組み • スタックを⼀気にメモリに書き出すのではなく、⼩さい単位の Chunk に分け、 その

    Linked List として保持 • Continuation は Linked List の末尾への参照を保持している • 最⼩限のメモリを保持し、必要に応じて伸びていく形 public final class StackChunk { // 中略 private StackChunk parent; // 実際のスタックデータも保持する ⼀つ前のチャンクへの参照を保持する cf. https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/jdk/internal/vm/StackChunk.java
  16. © LY Corporation 18 ヒープを圧迫することはないの? • Virtual Thread はそれ専⽤の巨⼤なスタック領域を確保しない。適宜ヒープに Continuation

    が保存されるだけ • 疑問: Continuation 保存によりヒープが圧迫されることはないの? • 回答: スタックの中⾝はオブジェクトの参照であり、サイズは⼩さいので、数百万個の Virtual Thread を⽣やしてそのスタックをヒープに保持したところで⼤きな問題には ならない • ただし ThreadLocal の扱いには注意が必要なので、後述
  17. © LY Corporation • I/O 時にソケットをブロッキングモードで利⽤ • read(2) の結果が返ってくる (I/O

    が完了する) まで Platform Thread は ブロックされ続ける • つまりブロッキング I/O 20 前提: Platform Thread での I/O Platform Thread 何もしない read() syscall read() 結果の受け取り Virtual Thread を利⽤しない場合はどうなる?
  18. © LY Corporation Virtual Thread のスケジューリング全体像 Virtual Thread1 (VT1) read(2)

    I/O 結果受け取り RUNNING RUNNING PARKED UNPARKED EAGAIN epoll_wait(2) で I/O タスク完了待ち ForkJoinPool に Task をスケジュール Poller Thread Scheduler Carrier Thread fd を登録 スケジューリング 順番待ち VT1 VT1 read(2) unmount mount
  19. © LY Corporation Virtual Thread のスケジューリング全体像 Virtual Thread1 (VT1) read(2)

    I/O 結果受け取り RUNNING RUNNING PARKED UNPARKED EAGAIN epoll_wait(2) で I/O タスク完了待ち ForkJoinPool に Task をスケジュール Poller Thread Scheduler Carrier Thread fd を登録 スケジューリング 順番待ち VT1 VT1 read(2) unmount mount
  20. © LY Corporation • Virtual Thread の利⽤が検知されると、ソケットはノンブロッキングモードに • read(2) 発⾏後、結果がすぐに得られない場合は即座に

    EAGAIN が返却される 23 Platform Thread ブロック検知の仕組み Virtual Thread を利⽤するとどうなる? • EAGAIN 返却は I/O 開始の知らせ! • イベントループスレッドである Poller Thread に当該 file descriptor が登録され、I/O の 結果が来たら通知を受け取れる ようになる • VT は unmount され、スタックが Continuation へ退避された状態 (PARKED) になる
  21. © LY Corporation 24 実⾏して確認してみよう #37 "Read-Poller" RUNNABLE 2025-10-28T15:50:05.879962Z at

    java.base/sun.nio.ch.KQueue.poll(Native Method) at java.base/sun.nio.ch.KQueuePoller.poll(KQueuePoller.java:68) at java.base/sun.nio.ch.Poller.pollerLoop(Poller.java:248) at java.base/java.lang.Thread.run(Thread.java:1474) at java.base/jdk.internal.misc.InnocuousThread.run(InnocuousThread.java:148) #34 "" virtual WAITING 2025-10-28T15:50:05.881511Z at java.base/java.lang.VirtualThread.park(VirtualThread.java:738) at java.base/java.lang.System$1.parkVirtualThread(System.java:2284) at java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:367) at java.base/sun.nio.ch.Poller.poll(Poller.java:197) // 中略 at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:307)
  22. © LY Corporation • Poller Thread が I/O 結果到着通知を 受け取ると、VT

    は PARKED から UNPARKED (実⾏可能状態) になる • 実⾏可能なので、スケジューラに登録 され、マウントの順番が回ってくるの を待つ • デフォルトではForkJoinPool の スケジューラに登録され、 ForkJoinPool 内の Carrier Thread にmount されるのを待つ 25 I/O 結果が返ってきたらどうなる? Virtual Thread1 (VT1) Poller Thread Scheduler Carrier Thread
  23. © LY Corporation • ForkJoinPool は Platform Thread の pool

    • VT はデフォルトでこの pool のスレッド上に mount される • スレッド数はデフォルトで CPU コア数と同じ è Carrier Thread はせいぜい CPU コア数くらいの本数しかない! • ワークスティーリングアルゴリズム • キューは各 Platform Thread にあるが、⾃分のキューが空の場合は他の スレッドからタスクを奪える • そのため、 Platform Thread は⾃分のタスクが終わったら他のスレッドの タスクを実⾏することができ、効率的な処理が可能 26 ForkJoinPool って何? cf. https://docs.oracle.com/javase/jp/8/docs/api/java/util/concurrent/ForkJoinPool.html
  24. © LY Corporation • マウントの順番が回ってきたら、 VT は Carrier Thread にマウントされる

    • VT の状態は RUNNING に • スタックも Continuation から復元されている • その後改めて read(2) を発⾏すると、今度は 結果が到着済みなので即座にI/O 結果が 得られる 27 順番が回ってきたら mount される Virtual Thread1 (VT1) Poller Thread Scheduler Carrier Thread
  25. © LY Corporation • JEP444 の段階 (JDK21) には、本来 unmount されるべき

    VT が CT に固定されて 外れなくなってしまう問題 (Pinning) が報告されている • 特に synchronized ロックでブロッキングな操作をした場合 unmount できなく なってしまう現象が問題となっていた • 背景 • Synchronized ロックは、ロックの所有者情報として Platform Thread を 記録する • もしその Platform Thread 上の VT が unmount して free に なったり、別のスレッドが mount されたりしてしまうと、所有者不在に なったり、所有者が変わってしまったりする • そのため、やむなく Platform Thread をブロックする対応を採っていた 29 Pinning 問題とは? JDK24 以前に存在した問題点
  26. © LY Corporation 30 性能低下では済まずにデッドロックが起こるケースも JDK24 以前で特に問題になっていた点 final ReentrantLock lock

    = new ReentrantLock(true); lock.lock(); // 中略 Thread unpinnedThread = Thread.ofVirtual().name("unpinned").start(takeLock); // 中略 List<Thread> pinnedThreads = IntStream.range(0, Runtime.getRuntime().availableProcessors()) .mapToObj(i -> Thread.ofVirtual.start(() -> { synchronized (new Object()) { takeLock.run(); }})).toList(); // 中略 lock.unlock(); ReentrantLock のロック を取得できるが Platform Thread を mount できな いので待ち Platform Thread を mount しているが ReentrantLock のロックを 取得できないので待ち https://gist.github.com/DanielThomas/0b099c5f208d7deed8a83bf5fc03179e
  27. © LY Corporation • Synchronized ブロック内の pinning 問題が解消された! • JVM

    のモニタの実装を⾒直し、モニタの所有者を OS スレッドではなく 仮想スレッドと紐付けるような変更が⾏われた • ロックの実装は ReentrantLock ではなく synchronized で書いても問題なくなった • ただし、pinning 問題が解消されただけであって、ロック取得待ち⾃体に時間は かかるので注意 • JDK24 以降も以下のような例外的なケースで pinning は発⽣しうるが、多くのケース では問題にならなくなった • シンボル解決の待ちの間のブロッキング操作 • クラス初期化の待ちの間のブロックング • JNI やネイティブコード呼び出しによるブロッキング 31 JEP491 による改善点 JDK24 以降の現状 cf. https://openjdk.org/jeps/491
  28. © LY Corporation • CPU bound な処理について VT を利⽤して性能向上を期待する •

    Unmount される余地のない処理を実⾏したところで VT の恩恵はない • Virtual Thread を pooling する • VT は Platform Thread と違って⾼価ではないので、pool する意味がない • タスクごとに⼤量⽣成・⼤量破棄する設計思想 • 同時実⾏数を制限する意図で pool を使いたい場合は、semaphore を⽤いること が推奨されている 33 Virtual Thread のアンチパターン cf. https://openjdk.org/jeps/444
  29. © LY Corporation • Virtual Thread は Thread を継承しているので、各 Virtual

    Thread について固有の ThreadLocal を保持したり読んだり書いたりできるが…… • 同時に⼤量の Virtual Thread を起動した場合、その数と同じだけの ThreadLocal が初期化 & ヒープに載り、その後 VT の終了に伴って全て破棄され る形になる • この挙動は、ThreadLocal の使い⽅によっては問題になりうる 34 ThreadLocal を利⽤する場合の注意点
  30. © LY Corporation • MDC (Mapped Diagnostic Contexts) など、軽量な context

    を保持したい パターン • 理由 • そもそも軽量なので⽣成コストが重 くない • 1 req ごとに 1 VT を⽴ち上げるよ うな状況では、リクエストの寿命と VT の寿命が⼀致している • Cipher など、⽣成コストが⾼い & スレッドセーフでないリソースを保持して おいて使い回すパターン • 理由 • VT を⼤量に⽣成すると、でかい オブジェクトが⼤量に new されては、⼀度だけ利⽤され、 その後⼤量に破棄される形になる • ヒープを圧迫する上ものすごい無駄 • リソースの pool を⽤意し、それを static に or 後述の ScopedValue で保持が望ま しい 問題にならないケース 問題になるケース 35 ThreadLocal を利⽤する場合の注意点 どんなユースケースが問題になる?
  31. © LY Corporation 36 ThreadLocal の代わりに ScopedValue は? JDK25 で正式機能になった

    ScopedValue を使ってみる • ScopedValue は、JDK25 で正式機能となった、 private static final ScopedValue<StringBuilder> SB = ScopedValue.newInstance(); void main() { var sb = new StringBuilder(""); ScopedValue.where(SB, sb) // where で、ScopedValue に値をマッピングする .run(() -> { // run で、関数を実⾏する sub1(); sub2(); }); } private void sub1() { StringBuilder sb = SB.get(); // マッピングされている値が取得できる sb.append("sub1."); } private void sub2() { StringBuilder sb = SB.get(); // マッピングされている値が取得できる sb.append("sub2."); } https://www.ne.jp/asahi/hishidama/home/tech/java/preview/scopedvalue.21.html を元に⼀部改変
  32. © LY Corporation スレッドセーフでないリソースの取り回し ScopedValue に実体そのものではなく pool を bind する

    • ScopedValue は、JDK25 で正式機能となった、 public final class CryptoService { static final ScopedValue<CipherPool> CIPHER_POOL = ScopedValue.newInstance(); public static void withPool(CipherPool pool, Runnable task) { ScopedValue.where(CIPHER_POOL, pool).run(task); } public static byte[] encrypt(byte[] input, byte[] key, byte[] iv) { try (var lease = CIPHER_POOL.get().acquire()) { Cipher c = lease.get(); // 後略 この pool の借⽤待ちを semaphore を利⽤して制御する
  33. © LY Corporation その他のアドバイス • Virtual Thread を新規採⽤する場合、利⽤するメモリの領域が変わる可能性がある • 従来は

    Platform Thread を⽣やして pool -> Stack 領域を⼤量に確保 • Virtual Thread の場合、VT が利⽤する stack 領域は ForkJoinPool 分のみ 代わりにヒープ利⽤が⼤きくなりうる • 場合によっては、ヒープに割り当てるメモリの上限などを変更した⽅が良いケースも出てくる • いずれにしろ VirtualThread の状況は監視した⽅が良いので、Micrometer の Virtual Thread Metrics を監視対象に加えておくのが良いと考えられる (*1) (*1) https://docs.micrometer.io/micrometer/reference/reference/jvm.html
  34. © LY Corporation 39 まとめ • Virtual Thread は、ノンブロッキング I/O

    を実現する軽量なユーザレベルスレッド • JEP491 (JDK24) 以降で導⼊のハードルは⼤きく下がった • ThreadLocal との併⽤は、問題になるケースとならないケースがある • Virtual Thread を正しく知って正しく使おう • 導⼊時のトラブルシューティングの意味でも、利⽤するメモリ領域などを把握しておく ことは重要