必须在业务逻辑结束前显式调用 remove(),否则线程池中复用的线程会持续持有强引用的 value,最终触发 OutOfMemoryError;因 ThreadLocalMap.Entry 的 key 是弱引用、value 是强引用,外部引用消失后 key 被回收而 value 滞留,且 get()/set() 的被动清理不彻底,故泄漏必然发生。

必须在业务逻辑结束前显式调用 remove(),否则线程池中复用的线程会持续持有强引用的 value,最终触发 OutOfMemoryError: Java heap space。
为什么线程池里不调用 remove() 就一定会泄漏
线程池中的工作线程(尤其是核心线程)长期存活,不会随任务结束而销毁。而 ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用——一旦外部对 ThreadLocal 实例的引用消失(比如 Spring Bean 销毁、局部变量出作用域),key 被 GC 回收,但 value 仍牢牢挂在 ThreadLocalMap 的数组里,无法访问也无法自动释放。
- 常见现象:堆 dump 显示大量业务对象(如
UserContext、SimpleDateFormat、byte[])被ThreadLocalMap$Entry.value强引用,且对应 Entry 的 key 为null - 泄漏不是“可能”,而是“必然”:只要 value 对象生命周期短于线程,又没调
remove(),就一定会堆积 -
get()和set()的被动清理只扫哈希桶附近几个 stale entry,不保证全量、不保证触发、不保证及时
在 Spring MVC 中,remove() 必须放在 HandlerInterceptor.afterCompletion()
这是 Web 场景下唯一能覆盖所有请求路径(含异常流)的可靠时机。它在响应已写出、视图渲染完成之后执行,无论 controller 是否抛异常都会调用。
- 不能用
postHandle():此时视图尚未渲染,后续仍可能抛出RuntimeException导致跳过清理 - 不能依赖
@PreDestroy或方法退出:Spring 不保证其执行顺序,异步请求、Filter 中断等场景完全不触发 - 注册拦截器时必须匹配
/**,避免漏掉静态资源或 API 前缀外的路径 - 务必包在
try-finally里:ex != null时也要执行remove(),否则异常路径照样泄漏
remove() 的调用必须和 set()/get() 在同一线程内完成
ThreadLocal 是线程绑定的,跨线程调用 remove() 完全无效——子线程操作的是自己的 ThreadLocalMap,主线程的 value 依然挂着。
- 异步场景(如
CompletableFuture.supplyAsync()、@Async)必须手动传递并清理:不能直接在子线程里调用主线程定义的ThreadLocal.remove() - 使用
InheritableThreadLocal也不解决泄漏问题,只是让子线程“继承”了泄漏风险,仍需各自remove() - 多个
ThreadLocal实例(如USER_TL、TRACE_ID_TL)要分别调用remove(),少一个就漏一个 - 静态
ThreadLocal必须声明为private static final,否则类卸载失败会加剧泄漏
最易被忽略的一点:remove() 不是“用了再清”,而是“只要线程可能复用,就必须清”。哪怕只在一个 filter 里 set 了一次,也得在 afterCompletion 里 remove;哪怕 value 是个空字符串,只要它被强引用着,就占堆、就阻碍 GC。这不是防御性编程,是内存管理的硬边界。










