threadlocal 不手动清理会导致 classloader 级内存泄漏,因静态 threadlocal 持有应用对象形成 webappclassloader 引用闭环;线程池复用使数据长期驻留,弱 key 无法解除 value 对类加载器的强引用;必须在 handlerinterceptor.aftercompletion() 中 try-finally 调用 remove() 彻底清理。

在 Tomcat 容器中,不手动清理 ThreadLocal 变量,不仅会导致普通对象内存泄漏,更严重的是可能引发 ClassLoader 级别的内存泄漏——这种泄漏会直接阻碍 Web 应用卸载,造成服务器重启失败、内存持续增长甚至 OOM。
为什么 ThreadLocal 会卡住整个 ClassLoader?
Tomcat 为每个 Web 应用分配独立的 WebAppClassLoader,该加载器负责加载应用内所有类(如 MyService、UserContext)。当应用停止或重部署时,Tomcat 期望卸载这个加载器及其全部类。但若某个类(比如一个 @Component 或 Servlet)持有 静态 ThreadLocal 实例,而该 ThreadLocal 又存入了与当前应用强关联的对象(如 Spring 上下文、数据库连接、自定义 DTO),就会形成一条顽固引用链:
WebAppClassLoader → MyService.class → static ThreadLocal → Entry.value → 应用级对象 → … → WebAppClassLoader
这条闭环引用让 GC 无法判定 ClassLoader 已“无用”,导致整个加载器及其加载的成百上千个类长期驻留堆中。
线程池 + 静态 ThreadLocal = 卸载失败的温床
Tomcat 默认复用工作线程(来自线程池),这些线程生命周期远超单次请求,也远超单个应用的生命周期。一旦某个请求往静态 ThreadLocal 中写入数据:
- 该数据不会随请求结束自动消失
- 线程复用后,数据继续保留在
ThreadLocalMap中 - 若值对象持有了
ServletContext、ApplicationContext或任意本应用类的实例,就等于把整个应用的类图“钉”在了线程里
即使应用已标记为“stopped”,只要还有线程持有这些引用,WebAppClassLoader 就无法被回收——Tomcat 日志中常见提示:SEVERE: The web application [xxx] appears to have started a thread named [xxx] but has failed to stop it.
关键细节:弱 Key 并不能救 ClassLoader 泄漏
有人误以为 ThreadLocal 的 key 是弱引用,GC 能自动清理。但问题在于:
- Key 被回收只解决
Entry.key == null的“脏 entry”,不影响 value 对应用对象的强引用 - value 所引用的对象若包含对本应用类的引用(例如
new User()中的User类由 WebAppClassLoader 加载),就锁死了整个加载器 - ThreadLocal 自清理机制(
expungeStaleEntries)依赖后续调用get/set/remove,而应用停机后线程可能长期空闲,不再触发清理
换句话说:弱 Key 防不住 value 拉住 ClassLoader;只有主动 remove() 才能斩断这条跨生命周期的强引用链。
可靠清理必须落在请求生命周期终点
在 Spring MVC 场景下,唯一能覆盖所有路径(含异常、重定向、异步回调)的清理点是:
-
HandlerInterceptor.afterCompletion():视图渲染完成、响应写出后执行,无论是否抛异常都会触发 - 必须配合
try-finally,确保remove()不因中间 NPE 或逻辑跳转而遗漏 - 拦截器需注册到
/**,避免静态资源、错误页等路径绕过清理
示例代码中清理多个 ThreadLocal 时,应逐个调用 remove(),不可省略:
finally { traceIdTL.remove(); userInfoTL.remove(); tenantContextTL.remove(); }











