根本原因是ThreadLocalMap.Entry对key用弱引用、value用强引用,且线程长期存活时stale entry无法自动清理;GC回收key后value仍被强引用链持有,必须调用remove()主动切断引用。

根本原因不在 ThreadLocal 本身,而在 ThreadLocalMap.Entry 对 key 使用弱引用、对 value 使用强引用,且线程长期存活时 Entry 无法自动清理——这三者叠加,构成了内存泄漏的完整链路。
key 是弱引用,但 value 是强引用
看 JDK 源码中的 Entry 定义:
static class Entry extends WeakReference<threadlocal>> {
Object value;
Entry(ThreadLocal> k, Object v) {
super(k); // key 被包装成 WeakReference
value = v; // value 是普通强引用
}
}</threadlocal>
这意味着:当外部不再持有某个 ThreadLocal 实例(比如 Spring Bean 销毁、匿名内部类被回收),JVM GC 可以立即回收该 ThreadLocal 对象,Entry 的 key 就变成 null。但 value 仍被 Entry 强引用着,不会被 GC 回收。
此时 Entry 处于“stale entry”(陈旧条目)状态:Entry(null → 大对象)。它卡在 ThreadLocalMap 的数组里,既不能被 get/set 触达,又持续占用堆内存。
线程不退出,stale entry 就不清理
ThreadLocalMap 的清理机制是「被动触发」的:
- 每次
set()时,会顺带扫描并清理当前哈希桶附近 stale entry; - 每次
get()时,若命中 stale entry,也会尝试清理; - 扩容(
resize())时会整体遍历并清除所有 stale entry。
但这些都依赖「有线程主动调用 ThreadLocal 方法」。在线程池场景中,工作线程复用、长期存活,如果某次任务用了 ThreadLocal 但没 remove,之后这个线程可能长时间 idle,不再调用 set/get —— stale entry 就一直挂着,value 永远得不到释放。
ThreadLocal 实例被回收 ≠ Entry 被移除
这是最常被误解的一点。很多人以为「key 是弱引用,GC 后 Entry 就没了」,其实完全相反:
- GC 确实回收了 key,Entry 对象本身还活着(它自己是 map 数组里的一个元素);
- Entry.value 仍强引用着业务对象(如 byte[]、UserContext、Connection);
- 而 Thread → threadLocals → table → Entry 这条强引用链完整存在,value 无法进入 GC Roots 不可达路径。
所以 MAT 分析堆转储时,常见到大量 ThreadLocalMap$Entry 实例,其 value 字段指向巨型对象,而 key 字段为 null —— 这就是泄漏的铁证。
remove() 为什么是唯一可靠解
threadLocal.remove() 的源码逻辑是:定位到当前线程的 ThreadLocalMap,找到以 this 为 key 的 Entry,把 value 设为 null,并把整个 Entry 置为 null(同时触发清理邻近 stale entry)。
它不是等 GC,而是主动切断 value 的强引用路径。只要在业务逻辑结束前调用(推荐放在 finally 块),就能确保 value 随即变为 GC 可回收状态。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











