並列処理をより安全に
Project Loomは、並列処理を改善していくためのプロジェクトで今回は2つの機能がリリースされました。これらの新機能を理解するために、まず従来のコードが抱えていた問題点から見ていきましょう。
スレッド内で変数管理をする際の既存の問題点
スレッドごとに独立したデータを管理する場合、ThreadLocalを利用できました。しかしこのThreadLocalには、見落としがちな落とし穴がありました。
リスト4は、ThreadLocalを使ってスレッド内で変数を管理しようとする例です。
// (省略)
// (1) スレッドで管理する変数
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
// (省略)
private static void threadLocalSample() throws Exception{
// (2) 異なるスレッドで実行する
ExecutorService service = Executors.newFixedThreadPool(2);
// (3) 同じスレッドを使い回す
//ExecutorService service = Executors.newSingleThreadExecutor();
// (4) スレッドで処理をする
service.submit(() ->{
CONTEXT.set("sample1");
IO.println(Thread.currentThread().toString() + "(1)->" + CONTEXT.get());
Thread child = new Thread(() -> {
// (5) ここでは値は見えない
IO.println(Thread.currentThread().toString() + "(1-1)->" + CONTEXT.get());
});
// 省略
//CONTEXT.remove(); // (6) 削除し忘れる
});
Thread.sleep(10); // スレッドの実行順を分かりやすいように遅延させる
// (7) スレッドで処理をする
service.submit(() -> {
//CONTEXT.set("sample2"); // (8) 設定を忘れた場合はどうなる? ( スレッド次第 )
IO.println(Thread.currentThread().toString() + "(2)->" + CONTEXT.get());
});
// (9) 本来はアクセスさせたくないスコープ
IO.println(Thread.currentThread().toString() + "(3)->" + CONTEXT.get());
service.shutdown();
}
(1)ではThreadLocalインスタンスを、(2)(3)では、スレッドプールをそれぞれ準備します。詳しくは後述しますが、(2)(3)でのスレッドプールの違いによって、このコードは異なった結果となります(結果に違いが出るように、意図的に2つの問題を含ませており、その点についても後述します)。この(2)と(3)で実際にどのような違いが生じるのか、実行結果から見比べてください。
まずは(2)を有効にした場合の結果からです。
Thread[#25,pool-1-thread-1,5,main](1)->sample1 Thread[#26,Thread-0,5,main](1-1)->null Thread[#3,main,5,main](3)->null Thread[#27,pool-1-thread-2,5,main](2)->null
一方、(3)を有効にした場合の結果です。
Thread[#25,pool-1-thread-1,5,main](1)->sample1 Thread[#26,Thread-0,5,main](1-1)->null Thread[#3,main,5,main](3)->null Thread[#25,pool-1-thread-1,5,main](2)->sample1 (結果が変化する!)
なぜこういった違いが生まれたかと言うと、ThreadLocalがもともとスレッド間で異なる変数を保持する仕組みだからです。(2)では、別々のスレッドインスタンスで処理が実行されますが、(3)では同じスレッドが使い回されています。結果、(3)では意図せず値が共有されてしまっているのです。
この問題には、以下の2つの技術的な課題が関わっています。
- 値の削除漏れ(6):ThreadLocalは、使用後にremove()メソッドで値を削除することが推奨されます。これを忘れると、特にスレッドプールのようにスレッドが再利用される環境で、前の処理で設定された値が予期せず残ってしまい、バグの原因になります。
- 値の設定漏れ(7):スレッドで値を設定し忘れると、nullが返されます。この場合、値が設定されていないのか、意図的にnullなのかが不明瞭になり、デバッグが困難になります。
さらに子スレッドを作成して実行する場合、(5)の扱いや、スレッド外で実行されるケース(9)など、このコードだけを見ても、どのような結果になるのかがわかりにくい部分もあります。
現在のThreadLocalはスレッドの種類によって開発者が意図しない挙動を引き起こす可能性があり、特に仮想スレッドのような新しい機能と組み合わせる場合、問題がさらに複雑になります。
この問題を解決し、より安全な並列処理を実現するためにJEP505とJEP506が導入されました。
JEP506:スコープ内のスレッドのおける変数管理の改善
ThreadLocalが抱える課題を解決するために、JEP506で導入された新しいクラスがScopedValueです。このクラスは、スレッド内で安全に変数を共有するための仕組みを提供します。
// (省略)
// (1) ScopedValueのインスタンスを作成
private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();
ExecutorService service = Executors.newSingleThreadExecutor();
// スレッド処理を実行
service.submit(() -> {
// (2) 値を設定して、実行
ScopedValue.where(CONTEXT,"sample1").run(() -> {
IO.println(Thread.currentThread().toString() + "(1)->" + CONTEXT.get());
});
});
try {
// (3) アクセスしてはいけないはずのスコープ
if(CONTEXT.isBound()) {
// ここでは必ずfalseになる
IO.println(Thread.currentThread().toString() + "->" + CONTEXT.get());
}
}
catch (Exception e){
// isBound()でチェックせずアクセスすると、java.util.NoSuchElementException: ScopedValue not bound のエラーが発生する
e.printStackTrace();
}
service.shutdown();
// (省略)
ThreadLocalとの最も大きな違いは、コード上の制約が厳格になった点です。
ScopedValue(1)は(2)のようにwhere()メソッドで値を設定し、run()メソッドで処理を実行します。ThreadLocalのように直接set()するメソッドは存在しませんし、スコープもより明示的な宣言が必要になります。
また、スコープが終了すると、自動的に値が破棄されるため、ThreadLocal.remove()のような終了処理を書き忘れる心配がありません。さらに、ThreadLocalでは、スコープ外からアクセスした場合にnullが返ってくることがありましたが、ScopedValueではisBound()メソッドで値が設定されているか安全に確認できます(3)。
もしisBound()のチェックをせずにアクセスすると、NoSuchElementExceptionがスローされ、誤った使い方をしていることがすぐにわかります。このように、ScopedValueはThreadLocalが抱えていた問題を解決し、スレッド間でデータを安全かつ厳密に共有するための堅牢な仕組みを提供します。
JEP505:構造化された並列処理に関するAPI(プレビュー版)
並列処理の安全性と管理を向上させるために、StructuredTaskScopeという新しいクラスが導入されました。
このAPIは、複数のタスクを単一の作業単位として扱い、そのタスク群のライフサイクルを明確に管理します。
ScopedValueはスレッド内で安全に変数を共有できますが、新しい子スレッドには値が引き継がれません。StructuredTaskScopeは、この課題を解決するために作られました。このクラスを使うと、親スレッドのスコープを子スレッドに安全に引き継ぎ、並列タスクをより堅牢に扱うことが可能です。
リスト7は、StructuredTaskScopeの基本的な使い方です。
ExecutorService service = Executors.newCachedThreadPool();
service.submit(() -> {
ScopedValue.where(CONTEXT,"sample1").run(() -> {
IO.println(Thread.currentThread().toString() + "(1)->" + CONTEXT.get());
try {
// (1) 子スレッドでもCONTEXTの値を使いたい場合
Thread child = new Thread(() -> {
if(CONTEXT.isBound()) {
// 必ずfalseになる
}
else{
IO.println(Thread.currentThread().toString() + "(2)-> NOT BOUND");
}
});
child.start();
child.join();
}
}
// (省略)
// (2) 子スレッドで処理 ( プレビュー機能のため--enable-preview をつけてください )
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
IO.println(Thread.currentThread().toString() + "(1-3)->" + CONTEXT.get());
});
try {
scope.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
});
// (省略)
この例では、(1)のように通常のThreadを作成した場合、CONTEXTの値は子スレッドに引き継がれません。しかし、(2)のようにStructuredTaskScopeを使ってfork()メソッドでタスクを実行すると、親スレッドのScopedValueが子タスクにも伝播されます。
これにより、関連するタスク群のライフサイクルが単一のスコープにまとめられ、エラーハンドリングも一元化されます。現在プレビュー版ですが、今後の正式リリースに向けて、その動向に注目していくといいでしょう。
最後に
Java21以降、プレビュー版として提供されてきた多くの新機能が、いよいよJava 25で正式リリースされました。特に、mainメソッドの簡略化やモジュールインポートの改善は、スクリプト言語に比べてJavaが面倒に感じられていた点を大きく解消します。これにより、Javaを学ぶ人や、小規模なプロジェクトでJavaを使いたい人にとって、開発がより簡単で直感的なものになるでしょう。
また、今回のJDK25では、これまであまり取り上げられることのなかったJFR(Java Flight Recorder)関連の改善が3つ含まれています。次回は、このJFRの基本的な使い方から、今回のバージョンで追加された変更点について解説します。
