並列ストリームでのコンテキスト保持(ThreadLocal の注意) — 意図しない挙動回避
Java の並列ストリームは内部的に ForkJoinPool を使って複数スレッドで処理します。
このとき「スレッドごとに値を保持する仕組み」である ThreadLocal を使うと、意図しない挙動が起きやすいです。初心者が混乱しやすいポイントを、例題とテンプレートで整理します。
ThreadLocal の基本
- ThreadLocal: スレッドごとに独立した値を保持できる。
- 用途: ログのトレースID、ユーザーコンテキスト、トランザクション情報など。
- 直列ストリーム: 1スレッドで動くので ThreadLocal の値は一貫して見える。
- 並列ストリーム: 複数スレッドが走るため、ThreadLocal の値が「スレッドごとに違う」→ 意図せず欠落や混乱。
危険な例(ThreadLocal が効かない)
import java.util.stream.IntStream;
public class ThreadLocalExample {
private static final ThreadLocal<String> context = new ThreadLocal<>();
public static void main(String[] args) {
context.set("USER-123");
// 並列ストリームで ThreadLocal を参照
IntStream.rangeClosed(1, 5)
.parallel()
.forEach(i -> {
System.out.println(Thread.currentThread().getName()
+ " context=" + context.get());
});
}
}
Java- 出力例:
ForkJoinPool.commonPool-worker-1 context=null
ForkJoinPool.commonPool-worker-3 context=null
main context=USER-123
- 問題: main スレッドでは値が見えるが、ForkJoinPool のワーカースレッドでは
null。 - 理由: ThreadLocal は「スレッドごとに独立」なので、他スレッドには値が伝わらない。
意図しない挙動を回避する方法
1. ThreadLocal を使わない設計にする
- コンテキストは ストリームの要素に埋め込む。
- 例: ユーザーIDを ThreadLocal に置くのではなく、要素に持たせて処理。
record Task(int id, String userId) {}
List<Task> tasks = List.of(new Task(1,"USER-123"), new Task(2,"USER-123"));
tasks.parallelStream()
.forEach(t -> System.out.println(t.userId() + " -> " + t.id()));
Java2. 明示的にコンテキストを渡す
- ThreadLocal に頼らず、ラムダ引数に渡す。
String userId = "USER-123";
IntStream.rangeClosed(1, 5)
.parallel()
.forEach(i -> System.out.println(userId + " -> " + i));
Java3. 特殊なケースでは InheritableThreadLocal
InheritableThreadLocalを使うと「親スレッドの値を子スレッドにコピー」できる。- ただし ForkJoinPool の再利用スレッドでは期待通りにならないことが多い。推奨されない。
4. 並列ストリームを避ける
- コンテキスト保持が必須なら、直列ストリームで処理する方が安全。
- 並列化は「純粋関数的な処理」に限定する。
テンプレート集
- 直列ストリームで ThreadLocal を使う(安全)
context.set("USER-123");
list.stream().forEach(x -> use(context.get(), x));
Java- 並列ストリームで安全にコンテキストを渡す
String ctx = "USER-123";
list.parallelStream().forEach(x -> use(ctx, x));
Java- 要素にコンテキストを埋め込む
record Item(String ctx, int value) {}
items.parallelStream().forEach(i -> use(i.ctx(), i.value()));
Java落とし穴と回避策
- ThreadLocal はスレッドごと: 並列ストリームでは ForkJoinPool のワーカースレッドに値が伝わらない。
- InheritableThreadLocal の過信: ForkJoinPool の再利用スレッドではコピーされない場合がある。
- 副作用禁止: 並列ストリームで外部状態(ThreadLocal含む)を書き換えると競合や欠落が発生。
- 設計の原則: 並列ストリームは「副作用なし」「入力→出力が純粋関数的」な処理に限定する。
まとめ
- 並列ストリームで ThreadLocal を使うと「値が見えない」「nullになる」など意図しない挙動が起きる。
- 回避策は「要素にコンテキストを埋め込む」「ラムダ引数で渡す」「直列ストリームにする」。
- 並列ストリームは 純粋関数的処理専用と割り切るのが安全。
👉 練習課題: 「ユーザーIDを ThreadLocal に保持して並列ストリームで処理」→「要素にユーザーIDを埋め込んで並列処理」に書き換えて、出力の違いを確認してみましょう。
