一句話先說結論:ThreadLocal 的 key 是弱引用,value 卻是強引用。執行緒複用後 key 被 GC 回收,value 還掛在 ThreadLocalMap 裡,Full GC 收不回來,最終
OutOfMemoryError。解決就一條:用完在finally裡調remove()。
背景
ThreadLocal 給每個執行緒一份私有變數副本,執行緒之間互不干擾。典型用途是保存請求上下文、用戶會話、事務信息這類「每個執行緒一份」的數據。
每個 Thread 內部都有一個 ThreadLocalMap,它是執行緒私有的微型哈希表,ThreadLocal 的值就存在這裡。問題恰恰出在這張表上。
現象
出問題的往往是執行緒池加容器環境。應用跑一段時間後堆持續上漲,Full GC 也壓不下去,最後拋出錯誤。
堆被吃滿的情況:
Exception in thread "http-nio-8080-exec-3" java.lang.OutOfMemoryError: Java heap space
at com.example.service.ReportService.buildReport(ReportService.java:66)
at com.example.controller.ReportController.download(ReportController.java:24)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:199)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
容器熱部署、反覆重載 classloader 的場景還會拋另一種:
java.lang.OutOfMemoryError: Metaspace
後者更隱蔽:ThreadLocal 強引用擋住了 classloader 的回收,元空間逐漸耗盡。
JDK 官方 bug 庫 JDK-6558265 給了一個最小復現,用 2 個執行緒的執行緒池跑兩次任務就能觸發:
public class Main {
private static final int HUGE_BLOCK_SIZE =
(int) (Runtime.getRuntime().maxMemory() / 2 + 1);
private static ThreadLocal<Object> tls = new ThreadLocal<Object>() {
protected Object initialValue() {
return new byte[HUGE_BLOCK_SIZE];
}
};
private static ExecutorService executor = Executors.newFixedThreadPool(2);
static void execute(Runnable task) throws Exception {
Future<?> future = executor.submit(task);
future.get();
}
public static void main(String[] args) throws Exception {
Runnable task = () -> tls.get();
execute(task); // 第一次:大对象塞进 worker-1 的 threadLocals
execute(task); // 第二次:worker-1 还占着,直接 OOM
executor.shutdown();
}
}
只要第二次任務佔不滿剩餘堆,就會在下一次分配時爆掉。
根因分析
關鍵在 ThreadLocalMap 的 Entry 定義。它繼承自 WeakReference:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}
super(k) 把 key 包成弱引用,value 卻是普通強引用。
洩漏全程大概是這樣:
- 執行緒池裡的執行緒處理請求,方法裡
set了值。 - 方法結束沒調
remove(),指向ThreadLocal實例的強引用也消失了。 - 下次 GC,key(弱引用)被回收,這個 Entry 變成
key = null。 - value 還被 Entry 強引用,誰也訪問不到,GC 也收不走。
- 執行緒放回池裡繼續複用,
ThreadLocalMap一直活著,key = null的 Entry 越攢越多。
弱引用本意是「key 沒了就能順手清掉整個 Entry」。可實際只在 ThreadLocal.get()/set() 時,ThreadLocalMap 才順帶清理碰到的廢舊 Entry。執行緒此後不再碰這個 ThreadLocal,那些 key = null 的 Entry 就一直躺著。
執行緒池把「執行緒短命」這個前提打破了。普通執行緒用完就死,threadLocals 跟著回收;執行緒池裡的執行緒長命百歲,表一直存在。
解決方案
首選:finally 裡 remove
最直接也最有效的是主動清理:
ThreadLocal<UserContext> ctx = new ThreadLocal<>();
void handle() {
try {
ctx.set(new UserContext());
// 业务逻辑
} finally {
ctx.remove();
}
}
remove() 會把當前執行緒這個 ThreadLocal 對應的 Entry 整個刪掉。業務拋例外也不怕,finally 保證執行。
把 ThreadLocal 聲明成 static final
頻繁 new ThreadLocal 會造出多個實例,而弱引用清理依賴「同一個實例」的引用生命週期。聲明成常量,一來只維護一個 key,二來更容易追蹤:
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
框架層兜底
Tomcat 這類容器會檢測到「執行緒結束時 ThreadLocal 沒清」並打警告日誌,幫你揪出漏清理的地方。它只提示、不替你清理,最終還是得回到 remove()。
別指望 JDK 自動處理。JDK-6558265 提出過 ThreadLocalScope(任務結束後自動擦除執行緒局部變數),至今仍是 Unresolved 狀態,說明官方把它當增強特性掛著,沒當 bug 修。
思考過程
社區流傳一個說法:「ThreadLocal 用了弱引用,所以不會洩漏」。這是錯的。
弱引用只讓 key 能被回收,value 才是洩漏的本體。弱引用的設計,是防止 ThreadLocal 實例本身長期佔記憶體,不是防 value 洩漏。分清這點,面試和排查都不會被繞進去。
另一個容易漏的點:InheritableThreadLocal 一樣會漏,只要值被執行緒池裡的執行緒持有且不清理,機理完全相同。
小結
- ThreadLocal 洩漏的根子是 value 強引用加執行緒複用,不是弱引用失效。
- 表現是堆或元空間緩慢吃滿,最終
OutOfMemoryError。 - 修法就一條:
finally裡remove(),再把ThreadLocal聲明成static final。 - 別等 JDK 兜底,
ThreadLocalScope至今沒落地。
References(參考來源)
- JDK-6558265: Using thread locals with thread pools may lead to unintentional object retentionblog
- Azure/azure-sdk-for-java #48018: Static ThreadLocal<Collator> is never cleaned upgithub
- Azure/azure-sdk-for-java #48037: Replace ThreadLocal Collator with instance Collatorgithub
- Troubleshooting Memory Leaks — Oracle Java Platformblog
