Article

☕ JavaのThreadLocalとスレッドプール:remove()を忘れると起きる問題

java, concurrency, threadlocal, backend

個人開発しているもの

日々の開発で得た知識を発信しながら、「あったら便利」「作ってみたい」と思ったアイデアを形にしています。

これまでに、グループ旅行アプリ Trip Supporter、スコア管理サービス PointPia、リアルタイム共有サービス JustNow などを開発しました。

さらに、刺繍やオリジナルキャラクターを中心としたアパレルブランド MAD TOMMY WITH... も運営しています。

開発したサービスや技術記事は chicchi.work で紹介しています。

JavaのThreadLocalは、同じ変数のように見えても、スレッドごとに異なる値を保持する仕組みです。リクエストIDや処理中の追跡情報を扱う場面で便利ですが、スレッドプールと組み合わせると、処理を終えたのに前の値が残ることがあります。

この記事ではJava 17のAPI仕様を基に、問題の再現、try-finallyによる対策、非同期処理での使い分けをコードで確認します。ThreadLocalはリクエスト単位ではなく、スレッド単位の保存領域であることがポイントです。

なぜ次の処理に値が残るのか

  • スレッド:プログラムの処理を実行する単位です。
  • スレッドプール:実行用スレッドを毎回作り直さず、複数のタスクで再利用する仕組みです。
  • ThreadLocal:その変数に対する値を、スレッドごとに別々に保持します。

例えば、タスクAがThreadLocal.set("request-A")を実行して終了しても、値を消す操作をしていなければ、同じスレッドで走るタスクBは"request-A"を読めます。

JavaのExecutors.newSingleThreadExecutor()は、原則として一つのワーカースレッドでタスクを順番に処理します。これを使うと再利用時の挙動を確かめやすくなります。なお、ワーカースレッドが異常終了した場合などには新しいスレッドが作られることもあります。

再現コード:片付けないと前の値を読める

以下をThreadLocalCleanupDemo.javaとして保存してください。これは説明用に値の持ち越しを再現するコードです。実際のユーザーIDや認証情報ではなく、ダミーの文字列を使っています。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutionException;

public class ThreadLocalCleanupDemo {
    private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        try {
            // 悪い例:値を設定したままタスクを終了
            pool.submit(() -> REQUEST_ID.set("request-A")).get();
            String leaked = pool.submit(REQUEST_ID::get).get();
            System.out.println("片付けなし: " + leaked);
            if (!"request-A".equals(leaked)) {
                throw new AssertionError("想定外の結果");
            }

            // 良い例:タスクが正常終了しても必ず削除
            pool.submit(() -> {
                REQUEST_ID.set("request-B");
                try {
                    System.out.println("処理中: " + REQUEST_ID.get());
                } finally {
                    REQUEST_ID.remove();
                }
            }).get();

            String after = pool.submit(REQUEST_ID::get).get();
            System.out.println("片付け後: " + after);
            if (after != null) {
                throw new AssertionError("古い値が残っています");
            }

            // 例外が起きてもfinallyが実行されることを確認
            try {
                pool.submit(() -> {
                    REQUEST_ID.set("request-C");
                    try {
                        throw new IllegalStateException("デモ用の失敗");
                    } finally {
                        REQUEST_ID.remove();
                    }
                }).get();
            } catch (ExecutionException expected) {
                System.out.println("例外発生: " + expected.getCause().getMessage());
            }

            if (pool.submit(REQUEST_ID::get).get() != null) {
                throw new AssertionError("例外後に古い値が残っています");
            }
            System.out.println("例外後も片付け成功");
        } finally {
            pool.shutdown();
        }
    }
}

Java 17以降のJDKが使える環境で、ファイルを保存したディレクトリから次を実行します。

javac --release 17 ThreadLocalCleanupDemo.java
java ThreadLocalCleanupDemo
  • javac:Javaソースコードをコンパイル(実行用のバイトコードに変換)します。
  • --release 17:Java 17の言語仕様・公開APIをターゲットにしてコンパイルします。実行するjava側も対応バージョンが必要です。
  • java ThreadLocalCleanupDemo:コンパイルしたクラスのmainメソッドを実行します。

実行結果の例です。

片付けなし: request-A
処理中: request-B
片付け後: null
例外発生: デモ用の失敗
例外後も片付け成功

この例はOpenJDK 21でjavac --release 17を指定してコンパイル・実行し、期待した結果を確認しました。Future.get()は、投入したタスクの完了を待ち、例外があればExecutionExceptionを通じて受け取ります。

前半のrequest-Aが表示されるのは、別のタスクなのにワーカースレッドが同じだからです。後半ではremove()を呼んだため、後続タスクがget()したときnullになっています。ここで使ったnew ThreadLocal<>()の初期値はnullです。

対策:設定した側がfinallyで片付ける

実装上は、set()から処理終了までを一つのスコープとして扱うと見通しがよくなります。

private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

static void runWithRequestId(String requestId, Runnable action) {
    REQUEST_ID.set(requestId);
    try {
        action.run();
    } finally {
        REQUEST_ID.remove();
    }
}

この例のRunnableは、戻り値を持たない処理を表すJavaのインターフェースです。try-finallyにしておくと、action.run()が例外で終わった場合にもremove()が実行されます。

ただし、このヘルパーは同じThreadLocalに対する入れ子のスコープがないことを前提とします。入れ子で一時的に値を差し替える必要がある場合、内側が無条件にremove()すると、外側の値まで失われます。呼び出し元の値を退避して復元する設計にするか、そもそも入れ子を禁止してください。

また、ThreadLocal.withInitial(...)で初期値を指定している場合、remove()後の次回get()は初期化処理を再び呼ぶことがあります。remove()は「値を初期値で上書きする」処理ではありません。

実務でハマりやすいポイント

「リクエスト終了」と「スレッド終了」は別

HTTPリクエストの処理が終わっても、スレッドプールのワーカーは次のリクエストに再利用され得ます。ThreadLocalに保存した追跡IDや処理用コンテキストを片付けないと、後続処理で誤った値を参照するリスクがあります。データを保持し続けることはメモリ使用量の面でも問題になり得ます。

全てのタスクが値を設定する保証があるから安全と考えるのも危険です。例外、早期リターン、値を設定しないタスクなどを含めて確認しましょう。

非同期タスクに値が勝手に引き継がれるわけではない

ThreadLocalは呼び出しの単位ではなく実行スレッドに結び付いています。親タスクから別スレッドへ非同期処理を投げても、通常のThreadLocalの値が自動的に渡るとは限りません。特に、ワーカーが再利用される環境で値の引き継ぎを期待してはいけません。

安全で分かりやすい方法は、必要なIDなどをメソッド引数やタスクのデータとして明示的に渡すことです。

String requestId = "request-123";
pool.submit(() -> process(requestId)); // processへ明示的に渡す

process(String requestId)はアプリ側の処理メソッドを想定した例です。スレッドをまたぐ情報の受け渡しをThreadLocalに暗黙に任せない方が、動作を追いやすくなります。

remove()してもアプリ全体のリソース管理は終わらない

ThreadLocal.remove()は現在のスレッドに保存された値を消すだけです。他スレッドの値や、ThreadLocal以外から参照されているオブジェクトを解放する操作ではありません。DB接続やファイルなどは、それぞれの仕組みに応じて明示的に閉じます。

今回のExecutorServiceもshutdown()で終了処理を開始しています。shutdown()は既に送ったタスクの完了を待つメソッドではないため、本番では必要に応じてawaitTermination()で終了確認を行ってください。

どう使い分けるか

方針 向いている場面 注意点
メソッド引数でIDを渡す 非同期処理、追跡IDの受け渡し 引数の追加が必要になる
ThreadLocalを使う 同一スレッド内で処理コンテキストを参照する必要がある try-finallyで必ず後片付けする
フレームワークのコンテキスト管理を使う トレーシング・ログ相関など 伝播・クリアの仕様と使用する実行基盤を確認する

結論は、ThreadLocalを「リクエスト専用変数」と思わないことです。スレッドが使い回されるなら、後続タスクが古い値を読めることを前提に設計します。set()した処理でremove()まで責任を持つこと、スレッドをまたぐ値は明示的に渡すことが基本です。

参考資料(公式)