概要
Web アプリケーションでリクエストごとのユーザーIDを保持したり、スレッドアンセーフな SimpleDateFormat をスレッドごとに安全に使いたい場面で、ThreadLocal は定番の解決策です。ただし、スレッドプール環境で remove() を呼び忘れると、前のリクエストのデータが次のリクエストに漏洩したり、大きなオブジェクトが GC されずメモリリークを引き起こしたりします。この記事では、ThreadLocal の基本的な使い方と withInitial による初期化、remove() を忘れた場合の影響、そして Java 21 の仮想スレッドとの組み合わせ時の注意点までを整理します。
使いどころ
Web フレームワークのフィルターでリクエスト開始時にユーザーIDを ThreadLocal に設定し、サービス層で参照する
スレッドアンセーフな SimpleDateFormat を ThreadLocal で包み、スレッドごとに独立したインスタンスを保持する
トランザクションIDやトレースIDを ThreadLocal に保持し、ログ出力時に自動的に付与する
コード例
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();
}
}Version Coverage
withInitial + ラムダ式で簡潔に初期化可能。record と組み合わせてコンテキスト情報をまとめられる。
// Java 17: withInitial + ラムダ式で簡潔に
private static final ThreadLocal<SimpleDateFormat>
holder = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd"));Library Comparison
注意点
スレッドプール環境では ThreadLocal.remove() を必ず呼ぶ。スレッドが再利用されるため、前のタスクのデータが残留する
remove() を忘れるとメモリリークの原因になる。特に ClassLoader を跨ぐ場合はリークの影響が深刻になりやすい
InheritableThreadLocal は親スレッドの値を子スレッドに引き継ぐが、スレッドプールでは親子関係が曖昧になるため意図通りに動かないケースがある
Java 21 の仮想スレッドは大量に生成できるため、ThreadLocal に大きなオブジェクトを持つとメモリ消費が爆発する。ScopedValue の利用を検討する
FAQ
try-finally でタスクの最後に必ず呼びます。Web アプリではリクエスト処理の完了時にフィルターで remove() するのが定石です。
はい。DateTimeFormatter はスレッドセーフなので ThreadLocal で包む必要がありません。新規コードでは DateTimeFormatter の利用を推奨します。
親スレッドの値を子に自動継承しますが、スレッドプールでは親子関係が曖昧で意図通りに動かないことがあります。明示的に値を渡す設計のほうが安全です。