JEP 506 : Scoped Values

blog1.mammb.com


はじめに

java.lang.ScopedValue は、java.lang.ThreadLocal と同様に、スレッド毎のローカル変数を提供します。 java.lang.ThreadLocal と異なり、この変数はイミュータブルで限定的な生存期間を持ちます。 java.lang.ScopedValue は、仮想スレッド(JEP 444)や構造化並行処理(JEP 505)で効率的なスレッドローカル変数を扱うことができます。

Scoped Values は、以下のプレビュー段階を経て、JDK 25 で正式公開となりました。

  • JDK 20 - JEP 429 Scoped Values (Incubator)
  • JDK 21 - JEP 446 Scoped Values (Preview)
  • JDK 22 - JEP 464 Scoped Values (Second Preview)
  • JDK 23 - JEP 481 Scoped Values (Third Preview)
  • JDK 24 - JEP 487 Scoped Values (Fourth Preview)
  • JDK 25 - JEP 506 Scoped Values


ThreadLocal の問題点

スレッドローカル変数には3つの問題点があります。

  • Unconstrained mutability - スレッドローカル変数は変更可能(どこからでもsetで値を変更できる)なため、どのメソッドがどのような順序で共有ステートを更新するのか判別しにくくなる可能性がります(一般的なニーズは、あるメソッドから他のメソッドへの単純な一方向のデータ転送である)。

  • Unbounded lifetime - スレッドローカル変数の値はスレッドの有効期間中、またはスレッド内のコードがremoveメソッドを呼び出すまで保持されます。remove を呼び忘れた場合、変数は必要以上に長く保持されます。特にスレッド・プールが使用されている場合、あるタスクで設定されたスレッド・ローカル変数の値が、適切にクリアされず、誤って他のタスクに漏れてしまい、潜在的に危険なセキュリティ脆弱性につながる可能性があります。

  • Expensive inheritance - 親スレッドのスレッドローカル変数が子スレッドに継承される可能性があり、多数のスレッドを使用する場合、スレッドローカル変数のオーバーヘッドが無視できなくなる可能性があります。スレッドローカル変数のスレッドコピーを変更しても他のスレッドからは見えないようにする必要があるため、子スレッドは親スレッドが使用するストレージを共有することはできません。実際には、子スレッドが継承されたスレッドローカル変数の set メソッドを呼び出すことはほとんどないため、これは残念なことです。

これらの問題は、仮想スレッド(JEP 444)が登場したことで、より切迫したものとなりました。


Scoped Values

ScopedValue は以下のように使用します(スレッド間で共有される ScopedValue は、値が不変オブジェクトであるか、値へのすべてのアクセスが適切に同期化されている必要があります)。

private static final ScopedValue<String> NAME = ScopedValue.newInstance();

public void handle() {
    ScopedValue.where(NAME, "duke").run(() -> doSomething());
}

private void doSomething() {
    var v = NAME.get();
}

ScopedValue.where()ScopedValue と、それがバインドされるオブジェクトを指定します。 run() メソッドを呼び出すと、スコープされた値がバインドされ、現在のスレッドに固有のコピーが提供され、引数として渡されたラムダ式が実行されます。 run() 呼び出しの有効期間中、ラムダ式、またはその式から直接または間接的に呼び出されるメソッドは、値のgetメソッドを通じてスコープされた値を読み取ることができます。 run() メソッドが終了すると、バインディングは破棄されます。

ScopedValue には set()メソッドがないため、呼び出し元は、同じスレッド内の呼び出し先に確実に値を伝達するためだけに使用することができます。

呼び出し元の1つが、同じスコープ値を使用して別の値を呼び出し元に伝える必要がある場合は、後続の呼び出しに対して新しいネストされたバインディングを確立することができます。

private static final ScopedValue<String> X = ScopedValue.newInstance();

void foo() {
   where(X, "hello").run(() -> bar());
}

void bar() {
    System.out.println(X.get()); // prints hello
    where(X, "goodbye").run(() -> baz());
    System.out.println(X.get()); // prints hello
}

void baz() {
    System.out.println(X.get()); // prints goodbye
}

call() メソッドでは例外を投げることもできます。

try {
    var result = where(X, "hello").call(() -> bar());
    ... use result ...
catch (Exception e) {
    handleFailure(e);
}

呼び出し先で複数のスコープ値をバインドすることもできます。

where(X, v).where(Y, w).run(() -> ... );


ScopedValue は Structured Concurrency API の StructuredTaskScope クラスと合わせて利用することが有用です。

親スレッドの ScopedValue は、StructuredTaskScope で作成された子スレッドに自動的に継承されます。 子スレッドのコードは、親スレッドのスコープされた値に対して確立されたバインディングを最小限のオーバーヘッドで使用できます(スレッドローカル変数とは異なり、親スレッドのスコープ値バインディングが子スレッドにコピーされることはありません)。

private static final ScopedValue<String> NAME = ScopedValue.newInstance();

ScopedValue.where(NAME, "duke").run(() -> {
    try (var scope = StructuredTaskScope.open()) {

         scope.fork(() -> childTask1());
         scope.fork(() -> childTask2());
         scope.fork(() -> childTask3());

         scope.join();

         ..
     }
});