threadlocal内存泄漏本质是value强引用滞留:线程池中线程不销毁,threadlocalmap长期存活,key弱引用虽被回收但value仍被强引用持有,唯有显式remove()才能触发expungestaleentries彻底清理。

ThreadLocal 的内存泄漏,本质不是“没释放”,而是“本该释放却卡在了中间环节”。要透视这个问题,得从线程销毁时 ThreadLocalMap 的实际行为切入——原生线程销毁时,整个 ThreadLocalMap 确实会被 GC 回收,但前提是线程对象本身能被回收。而在线程池等复用场景中,线程永不销毁,这个“置空逻辑”根本不会触发。
线程销毁 ≠ ThreadLocal 自动清理
很多人误以为“线程结束,ThreadLocal 就清了”。实际上:
- Thread 类持有 threadLocals 字段(ThreadLocalMap 类型),是强引用
- 只要 Thread 实例还活着,它的 ThreadLocalMap 就不会被回收
- ThreadLocalMap 内部的 Entry 键是弱引用,值是强引用;线程不销毁,Entry 中 value 就一直被 map 持有
- 所以“原生线程销毁时的置空”,只对 new Thread().start() 后自然退出的短命线程有效,对线程池无效
真正起作用的“置空”发生在 map 内部探测阶段
ThreadLocalMap 并非被动等待线程销毁,它在 set、get、remove 等操作中会主动扫描并清理 key == null 的 stale entry(游离项):
- 每次 set 时,会线性探测 hash 冲突链,若发现 stale entry,就用新 value 覆盖,并顺带清理后续连续的 stale entry
- get 时遇到 key == null 的 entry,也会触发 cleanSomeSlots 清理部分槽位
- 但这个机制是“懒清理”:不调用 get/set,就不会触发;即使调用,也只清理局部,不保证全量清除
为什么 remove() 是不可替代的关键动作
remove() 不仅清掉当前 entry,还会主动遍历探测清理整个 hash 段的 stale entry:
- 它调用 expungeStaleEntries(),扫描并删除所有 key == null 的 entry,同时 rehash 剩余有效 entry
- 这是唯一能系统性切断 value 强引用链的操作
- 若业务中只 set 不 remove,哪怕后续调用 get,也可能因 hash 位置未命中而跳过清理路径
验证泄漏是否存在,看 key == null 的 entry 数量
用 MAT 或 JProfiler 打开 heap dump 后,直接筛选:
- java.lang.ThreadLocal$ThreadLocalMap$Entry
- 过滤 key == null 的实例
- 查看这些 entry 的 value 类型和大小——比如 UserContext、SQLConnection、大 byte[],就是泄漏源头
- 再看这些 entry 所属的 Thread 对象名(如 pool-1-thread-2),确认是否为长期存活线程
不复杂但容易忽略:ThreadLocal 的“置空”不是靠线程终结,而是靠你亲手调用 remove() 来驱动 map 内部的清理引擎。依赖线程销毁,等于把内存安全押在“线程会不会死”上——而生产环境里,它大概率不死。











