Java 逆引き集 | 並列ストリームでのコンテキスト保持(ThreadLocal の注意) — 意図しない挙動回避

Java Java
スポンサーリンク

並列ストリームでのコンテキスト保持(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()));
Java

2. 明示的にコンテキストを渡す

  • ThreadLocal に頼らず、ラムダ引数に渡す
String userId = "USER-123";

IntStream.rangeClosed(1, 5)
         .parallel()
         .forEach(i -> System.out.println(userId + " -> " + i));
Java

3. 特殊なケースでは 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を埋め込んで並列処理」に書き換えて、出力の違いを確認してみましょう。

タイトルとURLをコピーしました