根本原因在于线程不退出、key弱引用提前失效、value强引用长期悬空三者叠加;threadlocalmap的entry仅弱引用key而强引用value,线程池复用导致stale entry堆积,被动清理机制不可靠,remove()是唯一能彻底释放value的操作。

根本原因不在“没删”,而在于线程不退出 + key 弱引用提前失效 + value 强引用长期悬空,三者叠加形成不可自动解的死结。
ThreadLocalMap 的 Entry 设计埋下隐患
Entry 继承自 WeakReference<threadlocal></threadlocal>,只对 key(即 ThreadLocal 实例)做弱引用;value 却是普通强引用。这意味着:
- 只要外部不再持有该 ThreadLocal 实例(比如 Spring Bean 销毁、类卸载、或局部变量作用域结束),GC 下一轮就可能回收 key → Entry.key 变为 null
- 但 value 仍被这个 Entry 对象牢牢持有着,而 Entry 又被当前线程的
threadLocals(ThreadLocalMap)强引用着 - 只要线程活着,这条引用链就一直有效:
Thread → threadLocals → Entry → value
线程池让“活着”变成“长期驻留”
普通线程执行完就销毁,整个 ThreadLocalMap 随之释放;但线程池中的工作线程:
- 默认存活数小时甚至数天,反复复用
- 每次请求调用
set(),都往同一个线程的 map 中塞新 Entry - 旧 Entry 的 key 已 null,但 value 还在 —— 成为“stale entry”
- map 不主动遍历清理,这些脏 entry 就越积越多,像滚雪球一样吃掉堆内存
自动清理机制靠不住,不是“一定会触发”
ThreadLocalMap 确实有 expungeStaleEntry() 和启发式清理逻辑,但它只在以下场景被动触发:
-
set()时探测到哈希冲突,顺带清理附近 stale entry -
get()时发现 key == null,才清理该槽位 - 没有 set/get 的空闲线程?永远不触发
- 大量 stale entry 分散在 map 各处?一次只能清一串连续段
也就是说:它不保底、不兜底、不全量扫描。一旦业务逻辑中某次请求只 get() 不 set(),或线程长时间 idle,泄漏就静默累积。
remove() 是唯一能主动斩断引用链的操作
remove() 干了三件事:
- 定位对应 Entry
- 将 value 设为 null,切断强引用
- 将整个 Entry 置为 null,释放 slot,避免后续探测误判
只有这一步,才能真正让 value 进入 GC 可达范围。而 set(null) 只是把 value 改成 null,Entry 还在,key 还在(哪怕已是 null),slot 还占着 —— 泄漏照旧。











