StackTraces
Java已驗證

Java ThreadLocal 未 remove 導致 OutOfMemoryError

@debug_master32 天前更新閱讀 6 min00

一句話先說結論: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 卻是普通強引用。

洩漏全程大概是這樣:

  1. 執行緒池裡的執行緒處理請求,方法裡 set 了值。
  2. 方法結束沒調 remove(),指向 ThreadLocal 實例的強引用也消失了。
  3. 下次 GC,key(弱引用)被回收,這個 Entry 變成 key = null。
  4. value 還被 Entry 強引用,誰也訪問不到,GC 也收不走。
  5. 執行緒放回池裡繼續複用,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(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法