1. なぜ ThreadLocal は通用しにくくなっているのか
そもそも ThreadLocal は何のためにあるのか?
古典的なマルチスレッド環境(たとえばサーバー)では、スレッドが長寿命で、各スレッドごとに他と交わらない固有データを保持したいことがあります。たとえば、ユーザー名、リクエストID、一時バッファなどです。
そのために Java には ThreadLocal<T> が用意されています。スレッド専用の「プライベートスペース」のようなもので、他のスレッドに干渉せずデータを保持できます。
ThreadLocal<String> user = new ThreadLocal<>();
user.set("Alice"); // このスレッドにだけ値が保存される
String name = user.get(); // ここでは "Alice" が返る。ほかのスレッドでは null
ThreadLocal が仮想スレッドと相性が悪い理由
仮想スレッドは従来の「重い」スレッドとは生存様式がまったく異なります。数千単位で生成・消滅し、ときにはミリ秒未満で終わります。一方、ThreadLocal はデータを特定スレッドに強く結び付け、あたかもそのスレッドが永遠に生き続けるかのように扱います。
仮想スレッドが終了しても、ThreadLocal に入れたデータがメモリにぶら下がり続けることがあります。スレッド自体はすでに死んでいてもです。これはリークにつながります。JVM はそれらの値がもう不要であることを常に認識できるわけではありません。
さらにスレッドが再利用される(たとえばプールで)場合、より厄介な状況が起きえます。よそのコンテキストが新しいリクエストに紛れ込むのです。たとえば、ユーザーのペーチャがヴァーシャのデータを受け取ってしまう—バグや脆弱性の温床になります。
ThreadLocal は、スレッド数が少なく長寿命な環境では快適に機能します。しかし仮想スレッド相手では、毎秒消えてしまうクローゼットに荷物をしまうようなものです。
2. Scoped Values: コンテキスト伝搬の新しい方法
Scoped Values は Java 21 で導入された新しい道具で、古くからある ThreadLocal の課題をよりエレガントに解決します。ThreadLocal のようにスレッド内部にデータを置くのではなく、実行スコープ—特定のコード領域—にデータを「紐づけ」ます。値はその領域の実行中だけ生き、その後は自動的に消えるため、メモリに痕跡を残しません。
import java.lang.ScopedValue;
ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println("Hello, " + USER.get()); // 出力: Hello, Alice
});
コードが run ブロックの外に出た時点で、その値はもう参照できません—アクセスしようとすると例外になります。手動でクリーンアップする必要はありません。
Scoped Values はメモリを汚さず、スレッド間でコンテキストが混ざることもなく、内側のスコープで外側の値を一時的に上書きできるネストも可能です。とりわけ仮想スレッドの世界で、コンテキストを渡すためのクリーンで予測可能かつ安全な手法です。
3. Scoped Values の使用例
例 1: ユーザーコンテキストの伝搬
複数ユーザーからのリクエストを処理するサーバーがあるとします。各リクエストについて、誰がそれを起こしたのかを把握したいとします。
import java.lang.ScopedValue;
public class ServerExample {
static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
processRequest("Alice");
processRequest("Bob");
}
static void processRequest(String userName) {
ScopedValue.where(USER, userName).run(() -> {
handleBusinessLogic();
});
}
static void handleBusinessLogic() {
System.out.println("ユーザー向けに処理中: " + USER.get());
}
}
何が起こるか:
- 各リクエストごとに独自のスコープが作られ、その中では USER が "Alice" または "Bob" になります。
- handleBusinessLogic() の中では、常に正しいユーザー名が取得されます。
- リクエスト処理が終わると、値は消えます。
例 2: コンテキスト付きログ
リクエストIDを自動的にログへ差し込みたいとします。
import java.lang.ScopedValue;
public class LoggingExample {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
String reqId = "REQ-" + i;
ScopedValue.where(REQUEST_ID, reqId).run(() -> {
log("処理開始");
doWork();
log("処理終了");
});
}
}
static void log(String message) {
System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
}
static void doWork() {
log("作業中...");
}
}
結果(例):
[REQ-1] 処理開始
[REQ-1] 作業中...
[REQ-1] 処理終了
[REQ-2] 処理開始
[REQ-2] 作業中...
[REQ-2] 処理終了
[REQ-3] 処理開始
[REQ-3] 作業中...
[REQ-3] 処理終了
各スコープが自分専用のリクエストIDを保持するため、スレッド間で取り違えることはありません。
4. Scoped Values と仮想スレッド: 理想的な組み合わせ
Scoped Values が仮想スレッドで特に有用な理由
仮想スレッドは短命で、時に数千単位で生成・破棄され、秒未満で終わります。そのため、ThreadLocal のようにデータをスレッドそのものに強く「固定」する旧来の手法はうまくいきません。スレッドが速やかに消え、コンテキストがリークまたは取り違えられる恐れがあるからです。
ScopedValue は逆に、データをタスク—すなわち実行スコープ—に結び付けます。つまりコンテキスト(ユーザー名やリクエストIDなど)はスレッドではなくコードに付随します。タスクが終われば値は自動的に消えます。仮想スレッドにとって、安全でクリーン、サプライズのない理想解です。
例: 仮想スレッドで大量タスクを処理
import java.lang.ScopedValue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadScopedValueDemo {
static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10_000; i++) {
int taskId = i;
executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
processTask();
}));
}
executor.shutdown();
}
static void processTask() {
// 各タスクに固有の TASK_ID
System.out.println("タスク処理中 #" + TASK_ID.get());
}
}
ポイント:
- 各タスクごとに TASK_ID のスコープが生成されます。
- タスクが並列実行されても、値がスレッド間で混同されることはありません。
- メモリリークはありません。スコープはタスクとともに「死に」ます。
5. 比較: ThreadLocal vs ScopedValue
| 観点 | ThreadLocal | ScopedValue |
|---|---|---|
| 紐づけ先 | スレッド | コードスコープ(scope) |
| ライフサイクル | スレッドの生存期間 | スコープ実行中 |
| 安全性 | リークや取り違えのリスク | リークなし、取り違えなし |
| 仮想スレッド | 非効率で危険 | 最適 |
| 使い方 | |
|
| ネスト | オーバーライド不可 | 値のオーバーライドが可能 |
6. ネストしたスコープ: 値のオーバーライド
ScopedValue<String> INFO = ScopedValue.newInstance();
ScopedValue.where(INFO, "外側").run(() -> {
System.out.println(INFO.get()); // "外側"
ScopedValue.where(INFO, "内側").run(() -> {
System.out.println(INFO.get()); // "内側"
});
System.out.println(INFO.get()); // "外側"
});
結果:
外側
内側
外側
たとえば、1 つのタスク内で一時的にコンテキスト値を上書きしたい場合に便利です。
Scoped Values: 典型的なユースケース
- ユーザーIDやリクエストIDの伝搬: ログ出力や権限チェックのため。
- ロギング: ログに自動的にコンテキストを差し込む。
- トレーシング: デバッグやプロファイリングのため。
- トランザクションのパラメータ: 例: 分離レベルや動作モード。
- 1 つのタスク(またはそのサブタスク)内だけで見える「コンテキスト」全般。
7. そのほかの新機構: Structured Concurrency
Structured Concurrency は、関連するタスク(たとえば 1 つの処理のサブプロセス群)をひとまとまりとして管理するアプローチです。親タスクが終了または失敗した場合、すべての子タスクは自動的にキャンセルされます。これにより「取り残された」あるいは「ぶら下がった」スレッドのリスクが下がります。
例(ごく概念的):
try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result1 = scope.fork(() -> fetchData1());
Future<String> result2 = scope.fork(() -> fetchData2());
scope.join(); // 両方の完了を待つ
scope.throwIfFailed(); // どちらか一方でも失敗したら例外を投げる
String combined = result1.resultNow() + result2.resultNow();
System.out.println(combined);
}
利点:
- タスクのライフサイクル管理がよりクリーン。
- ぶら下がったサブプロセスがなくなる。
- エラー処理が容易になる。
Structured Concurrency は現在プレビュー段階ですが、すでに活発に発展しています。
8. 実践的なヒントと制約
Scoped Values を使うべきとき
- タスク間でコンテキストを渡したいとき、特に仮想スレッドを使う場合。
- 以前 ThreadLocal を使っていたなら、ScopedValue への移行を検討しましょう。
それでも ThreadLocal が必要な場合
- スレッドが非常に長寿命で、その生存期間全体にわたりコンテキストを「固定」したいまれなケース(レガシーコード対応など)。
制約
- Scoped Values はスコープ作成後に変更できません(読み取り専用)。
- スコープ外で Scoped Values は使えません。スコープ外で値を取得しようとすると例外になります。
- 巨大なオブジェクトの保存には使わないでください—スコープは軽量・高速であるべきです。
9. Scoped Values のよくある間違い
エラー №1: スコープ外で値を取得しようとする。 ScopedValue.where(...) ブロックの外で USER.get() を呼ぶと、NoSuchElementException が発生します。参照は必ずスコープ内で行ってください。
エラー №2: スコープ内の値を変更しようとする。 Scoped Values は不変コンテナです。一時的に値を「上書き」したい場合は、ネストしたスコープを作成してください。
エラー №3: ThreadLocal と ScopedValue を併用する。 やむを得ない場合を除き、これらの仕組みを混在させるべきではありません。コンテキストの混乱や不具合の原因になります。
エラー №4: ロジックを run() ブロックに入れ忘れる。 ScopedValue.where(USER, "Alice") と書いただけで .run(() -> { ... }) がなければ、スコープは作られません!
エラー №5: 長寿命のグローバル情報に Scoped Value を使おうとする。 そのような用途には通常の変数や(必要に応じて)ThreadLocal を使いましょう。
GO TO FULL VERSION