1774915032
2026-03-26 02:30:00
Web バックエンドエンジニアの早坂です。本稿は、2025年11月に開催された JJUG CCC 2025秋 における登壇「Virtual Thread Deep Dive」を記事としてまとめたものです。以下では、Java の Virtual Thread について、その内部実装や利用時の注意点を解説します。発表スライドは スピーカーデッキ からご覧いただけます。
Virtual Thread の基本概念
Virtual Thread は Java 21 で正式導入された軽量スレッドであり、「I/O待ち時間を有効活用してスループットが向上する」「軽量なので大量に生成できる」といった特徴がよく知られています。例えば、次のような図を見たことがある方も多いのではないでしょうか。
Virtual Thread(VT)は、必要に応じてOSレベルの Carrier Thread(CT)にマウントされて処理を行います。I/O 待ちが発生すると、まずVTはCTからアンマウントされます。アンマウント中、CT は他の VT をマウントできるため、待ち時間中もリソースを有効活用できます。I/O 処理完了後、VT は再び CT にマウントされ、処理を再開します。
しかし、この仕組みがどのように実装されているのか、どのように I/O 待ちを検知しているのか、スタックの保存・復元がどのように行われているのかといった内部の詳細は、あまり知られていません。そこで、本記事では、Deep Dive として次のような疑問に答える形で解説を進めます。
- 大量に生成した場合のメモリ消費は問題ないのか?
- 中断した処理をどのように再開しているのか?
- Virtual Thread はどのように I/O 待ちを検知しているのか?
- Carrier Thread は何本くらいあるのか?
- Carrier Thread に張り付いて剥がれなくなることはないのか?
- ThreadLocal は使えるのか?
Virtual Thread の Mount/Unmount 機構
Java のメモリ空間について復習
「大量に生成した場合のメモリ消費は問題ないのか?」の答えを知るためには、まず Java のメモリ空間を理解する必要があります。

Java のメモリ空間は大まかにヒープ領域とスタック領域に分かれます。ヒープ領域には、new されたオブジェクトの実体が格納されます。一方、スタック領域は OS スレッド1本ごとにデフォルトで 1MB 確保され、内部にはスタックフレームが格納されます。
Platform Thread のメモリ利用

続いて、従来の Platform Thread(OS スレッド)がどのようにメモリを使用するかを見ていきましょう。1つの OS スレッドには、対応する Thread クラスのインスタンスがあり、これ自体はヒープに存在します。このインスタンスには、start() などのメソッド、スレッド ID や名前などのメタデータ、ThreadLocal の Map などが含まれます。一方、スタック領域には、先述の通り OS スレッドのスタックフレームが格納されます。
1 スレッドあたり 1MB のスタック領域を確保するため、Platform Thread を数百万本生成するのは非現実的であることがわかります。また、スタックにはオブジェクトの実体ではなく参照のみが格納されるため、スタック領域の実際の使用率は低く、1MB 確保しても大部分は未使用となることが多いです。そのため、メモリの利用効率は良くありません。
Virtual Thread のメモリ利用

一方、Virtual Thread はどうでしょうか。上図は、Virtual Thread が Carrier Thread にマウントされている状態を示しています。Platform Thread と同様に、ヒープ上に Virtual Thread のインスタンスが存在し、スタックフレームがスタック領域に格納されます。Virtual Thread のスタックフレームは、Carrier Thread にマウントされている間は、その Carrier Thread 用に確保されたスタック領域を自分の領域として利用します。
Unmount 時のスタック保存

では、I/O待ちが発生して Virtual Thread がアンマウントされる場面を考えてみましょう。次の Virtual Thread が同じ Carrier Thread を利用する可能性があるため、その前に現在のスタック領域を空ける必要があります。しかし、単にこのスタック領域をクリアすると、実行時のスタックフレームが失われ、I/O 完了後に元の Virtual Thread を再開できなくなってしまいます。では、どのようにしたらいいでしょうか?

この問題の解決策として考えられたのが、「アンマウント時のスタックの状態をヒープに保存する」という方法です。上図のように、スタックの内容をヒープ上の Virtual Thread インスタンスに退避します。

こうすれば、退避完了後、別の Virtual Thread のスタックフレームを同じスタック領域に載せて再利用できます。

I/O の結果が返り、元の Virtual Thread を再びマウントする際は、ヒープに退避しておいたスタックを Carrier Thread のスタック領域にコピーして復元することで、処理を再開できるのです。
Continuation によるスタック管理
ここまでは図を使って説明してきましたが、せっかくなので、Deep Dive として実際の JDK のソースコードを見ていきましょう。Virtual Threadのクラス定義 の抜粋を示します。
final class VirtualThread extends BaseVirtualThread {
// 中略
private final Executor scheduler;
// 中略
private volatile int state;
// 中略
private final Continuation cont;
}
Virtual Thread クラスはThreadクラスを継承しているため、Thread が持っているメンバ変数、例えばThreadLocalMapなどのメンバ変数も保持しています。そのメンバ変数の1つにContinuation があります。これは一体なんでしょうか? 実際に 実装 をみてみましょう。
public class Continuation {
// 中略
private StackChunk tail;
// 中略
private volatile boolean mounted;
// 中略
private Object[] scopedValueCache;
}
実は、この Continuation が、先ほどの説明で登場した「スタックフレームを保存して書き出したもの」の正体です。このクラスの StackChunk というメンバ変数が実際のスタックデータを保持しています。さらに StackChunk の 実装 を追ってみましょう。
public final class StackChunk {
// 中略
private StackChunk parent;
// 実際のスタックデータも保持する
}
コードからわかる通り、これは Linked List の構造をしています。つまり、StackChunk の実装では、スタック全体を一度にメモリに書き出すのではなく、小さい単位の chunk に分割して Linked List とし
#Virtual #Thread #Deep #Dive #内部実装からアンチパターンまで