概要

Web アプリケーションでリクエストごとのユーザーIDを保持したり、スレッドアンセーフな SimpleDateFormat をスレッドごとに安全に使いたい場面で、ThreadLocal は定番の解決策です。ただし、スレッドプール環境で remove() を呼び忘れると、前のリクエストのデータが次のリクエストに漏洩したり、大きなオブジェクトが GC されずメモリリークを引き起こしたりします。この記事では、ThreadLocal の基本的な使い方と withInitial による初期化、remove() を忘れた場合の影響、そして Java 21 の仮想スレッドとの組み合わせ時の注意点までを整理します。

使いどころ

Web フレームワークのフィルターでリクエスト開始時にユーザーIDを ThreadLocal に設定し、サービス層で参照する

スレッドアンセーフな SimpleDateFormat を ThreadLocal で包み、スレッドごとに独立したインスタンスを保持する

トランザクションIDやトレースIDを ThreadLocal に保持し、ログ出力時に自動的に付与する

コード例

ThreadLocalDemo.java
import java.text.SimpleDateFormat;
import java.util.Date;

public class ThreadLocalDemo {

    // withInitial でスレッドごとに独立した SimpleDateFormat を保持
    private static final ThreadLocal<SimpleDateFormat> dateFormat =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    // リクエストコンテキストの模擬
    private static final ThreadLocal<Integer> userId = new ThreadLocal<>();

    public static void setUserId(int id) { userId.set(id); }
    public static Integer getUserId() { return userId.get(); }
    public static void clearUserId() { userId.remove(); }

    public static String formatDate(Date date) {
        return dateFormat.get().format(date);
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== ThreadLocal でスレッド固有データを保持 ===");

        Runnable task = () -> {
            var threadId = (int) (Thread.currentThread().getId() % 1000);
            setUserId(threadId);
            try {
                Thread.sleep(50);
                System.out.println(Thread.currentThread().getName()
                    + " -> userId=" + getUserId());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                clearUserId(); // 必ず remove()
            }
        };

        var t1 = new Thread(task, "thread-A");
        var t2 = new Thread(task, "thread-B");
        var t3 = new Thread(task, "thread-C");
        t1.start(); t2.start(); t3.start();
        t1.join(); t2.join(); t3.join();

        System.out.println("\n=== SimpleDateFormat の ThreadLocal 解決策 ===");
        Runnable formatTask = () -> {
            var result = formatDate(new Date());
            System.out.println(Thread.currentThread().getName()
                + " -> " + result);
        };
        var f1 = new Thread(formatTask, "format-1");
        var f2 = new Thread(formatTask, "format-2");
        f1.start(); f2.start();
        f1.join(); f2.join();
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

withInitial + ラムダ式で簡潔に初期化可能。record と組み合わせてコンテキスト情報をまとめられる。

Java 17
// Java 17: withInitial + ラムダ式で簡潔に
private static final ThreadLocal<SimpleDateFormat>
    holder = ThreadLocal.withInitial(
        () -> new SimpleDateFormat("yyyy-MM-dd"));

Library Comparison

ThreadLocal(標準 API)スレッド固有のデータを保持し、メソッドの引数で受け渡さずにアクセスしたいとき。remove() を忘れるとメモリリークや情報漏洩のリスクがある。スレッドプール環境では特に注意。
ScopedValue(Java 21 Preview)仮想スレッド環境で ThreadLocal の代わりにスコープ付きの値を安全に共有したいとき。Java 21 時点ではまだ Preview API。正式化は Java 23 以降の見込み。既存コードとの互換性はない。
MDC(SLF4J)ログにリクエストIDやトレースIDを自動付与したいとき。内部的に ThreadLocal を使用。ロギングフレームワーク依存。MDC.clear() を忘れるとスレッドプールでの情報漏洩リスクは ThreadLocal と同じ。

注意点

スレッドプール環境では ThreadLocal.remove() を必ず呼ぶ。スレッドが再利用されるため、前のタスクのデータが残留する

remove() を忘れるとメモリリークの原因になる。特に ClassLoader を跨ぐ場合はリークの影響が深刻になりやすい

InheritableThreadLocal は親スレッドの値を子スレッドに引き継ぐが、スレッドプールでは親子関係が曖昧になるため意図通りに動かないケースがある

Java 21 の仮想スレッドは大量に生成できるため、ThreadLocal に大きなオブジェクトを持つとメモリ消費が爆発する。ScopedValue の利用を検討する

FAQ

ThreadLocal の remove() はいつ呼ぶべきですか。

try-finally でタスクの最後に必ず呼びます。Web アプリではリクエスト処理の完了時にフィルターで remove() するのが定石です。

SimpleDateFormat の代わりに DateTimeFormatter を使えばThreadLocal は不要ですか。

はい。DateTimeFormatter はスレッドセーフなので ThreadLocal で包む必要がありません。新規コードでは DateTimeFormatter の利用を推奨します。

InheritableThreadLocal は使うべきですか。

親スレッドの値を子に自動継承しますが、スレッドプールでは親子関係が曖昧で意図通りに動かないことがあります。明示的に値を渡す設計のほうが安全です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます