ThreadLocal内存泄漏根因是ThreadLocalMap中value为强引用、key为弱引用,线程长期存活且未调用remove()时,value无法被GC回收。

为什么 Path to GC Roots 看不到 ThreadLocal 里的对象?
因为 ThreadLocal 的值实际存放在 Thread 对象的 threadLocals 字段(类型是 ThreadLocalMap)里,而这个 ThreadLocalMap 是 Thread 的强引用字段。MAT 默认的「Path to GC Roots」只显示**强引用链**,但 ThreadLocalMap 内部用的是弱引用键(WeakReference<threadlocal></threadlocal>),值却是强引用。所以只要线程还活着、threadLocals 没被清空,里面的业务对象就一直可达——它确实就是 GC Roots 的一部分,只是 MAT 不会默认把 Thread 当作 GC Root(除非线程还在运行且未被中断)。
怎么让 MAT 把活跃线程也纳入 GC Roots?
MAT 默认只把以下对象视为 GC Roots:system class、JNI global ref、busy monitor、local variable(栈帧)、native stack、finalizer queue、unfinalized object、unreachable object(仅用于对比)。Thread 对象本身只有在“正在运行”或“已启动但未终止”时,才可能通过 native stack 或 local variable 被间接标记为 root;但很多泄漏场景里,线程已处于 WAITING 或 TIMED_WAITING 状态,却仍持有 threadLocals——这时需手动扩展 GC Roots:
- 打开 MAT → 右键 dump 中任意一个
Thread对象 → 选择 Path to GC Roots → with all references(不是默认的 “with outgoing references”) - 或者更直接:在
Thread实例上右键 → Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references,确保不漏掉因弱键残留导致的强值引用 - 若目标线程已终止但
threadLocals未清理(比如用了自定义线程池且没调remove()),它不会出现在常规 GC Roots 中,此时必须用 dominator tree + threadLocals 过滤 配合查找
如何快速定位哪个 ThreadLocal 存了巨大业务对象?
别从对象出发反推,先缩小范围:
- 在 MAT 的 Dominator Tree 中按 retained heap 排序,找到疑似业务类(比如
OrderContext、UserSessionData)的大对象,右键 → Path to GC Roots → with all references - 如果路径终点是某个
ThreadLocalMap$Entry,展开它,看value字段是否指向你的业务对象,再往上翻到ThreadLocalMap→threadLocals→Thread - 更高效的做法:用 OQL 查询所有非空的
threadLocals:SELECT t, t.threadLocals.table.* FROM java.lang.Thread t WHERE t.threadLocals != null
,然后对结果中每个table数组元素,检查value的 class 和 retained heap - 注意:
ThreadLocalMap的 table 是数组,里面大量null元素,OQL 结果要过滤e.value != null AND e.value.@retainedHeap > 1000000(单位 byte)
为什么 remove() 了还是泄漏?常见陷阱有哪些?
ThreadLocal.remove() 只清当前线程的 ThreadLocalMap 中对应 key 的 entry,但它不触发 rehash,也不压缩 table。如果之前 put 过大量不同 ThreadLocal 实例(尤其匿名内部类或 lambda 创建的),map 的 table 可能长期膨胀,entry.value 占着内存又无法访问——这种“逻辑已删、物理未清”的情况在长生命周期线程(如 Tomcat worker、Netty event loop)中极常见:
- 线程复用场景下,忘记在 finally 或 try-with-resources 里调
tl.remove(),尤其异步回调、AOP 拦截后容易遗漏 -
ThreadLocal<list></list>类型:每次 get() 后往 list 里 add,但没清空,导致 list 持续增长,即使 key 被回收,value(list 及其元素)仍被 map 强持 - 使用了
InheritableThreadLocal但子线程没清理,父线程的 map 已清,子线程的却滞留 - MAT 中看到
ThreadLocalMap$Entry的value是java.lang.ref.WeakReference?那是错觉——entry.value 是你 put 的原始对象,WeakReference 是 key 所在位置(即 entry.key)
真正难排查的从来不是“有没有泄漏”,而是“哪个 ThreadLocal 在哪个线程里悄悄养大了对象”。多线程环境下的引用链,得靠线程上下文 + map 结构 + value 大小三者交叉验证,缺一不可。

















