← 開発ノート

JDK 25 ScopedValue:ThreadLocalを超えるリクエストコンテキスト

この記事の目次

JDK 25では、ScopedValueは、リクエストコンテキストなどのデータを限定された実行スコープへ結び付け、呼び出し元から呼び出し先へ渡すための確定済みAPIです。JEP 506で確定したため、プレビューフラグは不要です。バインディングはrun()またはcall()へ渡した処理の実行中に有効で、正常終了でも例外でも以前の状態へ戻ります。

ThreadLocalとはどう違いますか?

テナントIDやトレースIDをサービスコードの深い場所へ渡す際、途中のすべてのメソッドに引数を追加する必要がある場合に検討します。その値が業務ロジックの明示的な入力なら、通常の引数のほうが分かりやすい場合もあります。

ThreadLocalは、set()で現在のスレッドの値を変更し、remove()で削除します。リクエストスコープで使う場合は、通常finallyで慎重に後始末する必要があります。ScopedValueは、値を設定したままにする代わりに、その値を必要とする処理をバインディング内で実行します。

比較項目 ThreadLocal ScopedValue
データアクセス 現在のスレッドに保存された値を読み書きします 現在の動的実行スコープのバインディングを読みます
スコープ終了 リクエスト単位の後始末を明示的に管理します run()またはcall()の終了時に以前のバインディングへ戻します
呼び出し先で異なる値を使う set()で既存の保存値を変更できます 入れ子のスコープで再バインドし、その後外側の値へ戻します
適した用途 スレッドごとの変更可能な状態などです 呼び出し元から呼び出し先へ渡すリクエストコンテキストなどです

ScopedValueはThreadLocalのすべての用途を置き換えるものでも、非推奨にするものでもありません。private static finalフィールドに保存されるのは、コンテキストの値自体ではなく、アクセス用のキーです。同じキーでも、実行中のスレッドとスコープに応じて異なる値を参照できます。

仮想スレッドでスコープと復元を観察する

仮想スレッドで外側の値をバインドし、入れ子の正常終了と失敗後の復元、通常の子スレッドに継承されないことを確認してからスコープを抜けます

図は以下の例に対応しています。外側のテナントはtenant-aで、入れ子のスコープはtenant-bを読みます。失敗する入れ子のスコープは、その外側で例外を捕捉してから復元を確認します。この例は、HTTPサーバーやフレームワーク連携なしに、リクエストコンテキストの意味を示します。

javacとjavaが同じJDK 25インストール先を指すディレクトリで実行します。ローカルの検証環境は、Eclipse Temurin 25.0.5+8のmacOS AArch64版でした。ScopedValueはjava.langに属するため、明示的なimportは不要です。

ScopedContextExample.java

import java.util.NoSuchElementException;
import java.util.concurrent.Executors;

public class ScopedContextExample {
    private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
    record RequestContext(String tenant, String traceId) {}

    public static void main(String[] args) throws Exception {
        try (var tasks = Executors.newVirtualThreadPerTaskExecutor()) {
            tasks.submit(ScopedContextExample::handleRequest).get();
        }
    }

    private static String handleRequest() throws InterruptedException {
        System.out.println("virtual=" + Thread.currentThread().isVirtual());
        System.out.println("before=" + CONTEXT.isBound());
        String result = ScopedValue.where(CONTEXT,
                new RequestContext("tenant-a", "trace-123")).call(() -> {
            System.out.println("outer=" + service());
            ScopedValue.where(CONTEXT,
                    new RequestContext("tenant-b", "trace-123")).run(() ->
                System.out.println("nested=" + service()));
            System.out.println("restored=" + service());

            try {
                ScopedValue.where(CONTEXT,
                        new RequestContext("tenant-fail", "trace-123")).run(() -> {
                    throw new IllegalStateException("demonstration");
                });
            } catch (IllegalStateException expected) {
                System.out.println("after-failure=" + service());
            }

            Thread child = Thread.ofVirtual().start(() ->
                System.out.println("plain-child-bound=" + CONTEXT.isBound()));
            child.join();
            return service();
        });
        System.out.println("result=" + result);
        System.out.println("after=" + CONTEXT.isBound());
        try {
            CONTEXT.get();
        } catch (NoSuchElementException expected) {
            System.out.println("outside=" + expected.getClass().getSimpleName());
        }
        return result;
    }

    private static String service() {
        RequestContext context = CONTEXT.get();
        return context.tenant() + "/" + context.traceId();
    }
}
javac --release 25 ScopedContextExample.java
java ScopedContextExample

この標準出力は、プレビューオプションなしで観測しました。get()をFutureに対して呼ぶとタスクの失敗が呼び出し元へ伝わり、join()は子スレッドの出力を待ちます。

virtual=true
before=false
outer=tenant-a/trace-123
nested=tenant-b/trace-123
restored=tenant-a/trace-123
after-failure=tenant-a/trace-123
plain-child-bound=false
result=tenant-a/trace-123
after=false
outside=NoSuchElementException

service()は、コンテキスト引数なしで現在のバインディングを読みます。結果を返さない処理にはrun()を使い、結果を返すか例外を伝える処理にはCarrier.call()を使います。Carrierをwhere()で作るだけでは、バインディングは有効になりません。

restoredとafter-failureはいずれも外側の値を示します。外側のcall()が終わるとisBound()はfalseとなり、未バインドのget()はNoSuchElementExceptionを投げます。バインディングを抜けても、参照先が即座にGCされる保証はなく、AutoCloseableリソースも自動では閉じません。

仮想スレッドを作ると値を継承しますか?

通常のThread.ofVirtual().start()は、親のバインディングを自動継承しません。 例ではplain-child-bound=falseでこれを示しています。ExecutorServiceやCompletableFutureに処理を渡す場合も、コンテキストが自動伝播すると仮定しないでください。例の最初の仮想スレッドは、バインディングを継承するのではなく、タスク内で設定しています。

公式の継承規則は、StructuredTaskScopeの作成時にバインディングを取得し、そのスコープのfork()で開始した子へ継承する動作を説明しています。構造化並行処理は、JDK 25の別のプレビューAPIで、JEP 505の対象です。この記事のコードでは使用しません。この例が、その継承機構を検証したものだと解釈しないでください。

リクエストコンテキストを導入する際の境界

バインディングを変更できなくても、参照先が深く不変になるわけではありません。この例では二つの文字列を含むレコードを使います。変更可能なコレクションには、別の並行処理設計が必要です。キーにアクセスできる範囲も狭くします。

JDK 25のAPIを確認し、JDK 21などの古いプレビュー例をそのままコピーしないでください。特に、JDK 25ではorElse(null)を許可しません。また、nullへのバインディングと、バインディングがないことは異なります。

まず、呼び出し階層に沿ってリクエストデータを渡すThreadLocalと、実行中の変更可能な状態を保持する用途を区別します。前者では、入口のバインディングと非同期処理の境界の両方を設計します。フレームワークのトランザクション、MDC、セキュリティコンテキストは自動連携しません。この例は、それらの連携や性能を検証していません。仮想スレッド自体の動作は、JDK 21の機能記事を参照してください。

コメント

GitHubで見る ↗