threadlocal在线程池中未调用remove()会导致内存泄漏,因其key为弱引用而value为强引用,线程复用时key被回收后value仍长期驻留threadlocalmap中,形成大量key为null、value非null的脏entry,持续占用堆内存并阻碍gc。

线程池中 ThreadLocal 未调用 remove() 是 Java 内存泄漏最典型、最高发的实战场景之一。根本问题不在于“用了 ThreadLocal”,而在于线程复用后,value 一直被强引用持有却再无机会释放。
为什么线程池会让问题急剧放大
普通线程执行完就销毁,其内部的 ThreadLocalMap 也随之回收;但线程池中的工作线程长期存活(可能几小时甚至数天),只要没显式清理,每次请求写入的 value 就会滞留在该线程的 ThreadLocalMap 中。
- Entry 的 key 是弱引用 → ThreadLocal 实例一旦不可达,key 迅速变 null
- Entry 的 value 是强引用 → 即使 key == null,value 对象仍牢牢挂在 map 里
- map 不主动清理 null key 的 entry → “脏 entry”越积越多,形成内存雪球
泄漏的直观表现与定位方式
线上服务运行数小时后,频繁 Full GC、堆内存使用率持续攀升,但对象实例数不见明显下降。用 MAT(Memory Analyzer Tool)分析 dump 文件时,常见特征:
- 大量 ThreadLocalMap$Entry 实例,且其 value 字段指向业务大对象(如 ArrayList、UserContext、TraceContext、IO 缓冲区等)
- 这些 Entry 的 key 字段为 null,但 value 非 null —— 典型“stale entry”
- 堆中存在成百上千个相同类型的上下文对象,归属不同线程但都卡在同一个 ThreadLocalMap 里
真正有效的清理时机和写法
不能依赖“用完自动消失”,必须人工干预。关键不是“要不要删”,而是“在哪儿删、怎么删才可靠”:
- 在 Runnable/Callable 执行结束前调用 remove(),尤其在线程池 submit 后无法控制线程生命周期时
- Web 场景下,在 Filter 的 doFilter() 最后、或 Spring Interceptor 的 afterCompletion() 中统一清理
- 务必放在 try-finally 块中:异常发生时,也能确保 remove() 执行
- 禁止用 set(null) 替代 remove() —— 它只是塞了个新 entry,旧 value 仍在,还多占一个槽位
静态 ThreadLocal + 线程池 = 泄露加速器
static final ThreadLocal 看似规范,实则风险更高:类加载器不卸载,ThreadLocal 实例永不回收;每个线程都为其维护一份 value,线程池有 200 个线程,就存 200 份副本。
- 若 value 是 1MB 的缓存 Map,200 个线程就是 200MB 无效堆内存
- 即使业务逻辑只用一次,不 remove 就永远留着
- 更隐蔽的是:有些框架(如老版本 Dubbo、自定义日志 MDC)内部用了 static ThreadLocal,但未做清理,需额外封装兜底











