Tomcat线程池不调用ThreadLocal.remove()会导致内存泄露,因复用线程使ThreadLocalMap中value长期持有Web应用类加载器对象,阻碍classloader卸载。

为什么 Tomcat 线程池里不调用 remove() 就会内存泄露
Tomcat 默认复用工作线程(Executor 线程池),一个请求结束后线程不会销毁,而是归还给池中等待下一次调度。如果在请求处理中用了 ThreadLocal.set() 存了大对象(比如用户上下文、数据库连接、日志 MDC),但没调用 remove(),这个对象就会一直被当前线程的 ThreadLocalMap 持有——而线程不退出,GC 就无法回收 value,更关键的是:value 引用的对象(如 UserContext)往往又持有 Web 应用类加载器(WebappClassLoader)加载的类实例,导致整个 classloader 无法卸载。
必须在请求生命周期末尾调用 remove(),不能依赖 get() 或 set() 的自动清理
ThreadLocal.get() 和 set() 确实会在内部触发对 key == null 的 stale entry 的扫描和清理,但只清理「当前操作位置附近」的过期条目(开放地址法探测链上的几个 slot),不是全量扫描。一旦你只写不读、或只读不写,那些早已失效的 entry 就会被跳过,value 长期滞留。
- 典型误用:
if (tl.get() == null) tl.set(new ExpensiveObject());—— 后续没remove(),也没再调用get()或set(),清理永远不会触发 - 正确姿势:无论是否使用了
get(),只要set()过,就在请求结束时无条件tl.remove() - 注意:
tl.remove()是线程安全的,且幂等;即使tl.get()返回 null,调用它也无副作用
在 Tomcat 中最可靠的位置是 Filter 或 Servlet 的 doFilter() / service() 末尾
Spring Boot 用户优先用 @ControllerAdvice + @AfterReturning 不够,因为异常路径会跳过;纯 Servlet 环境下,Filter 是唯一能覆盖所有请求路径(含异常、重定向、异步)的钩子。
- Filter 实现示例:
public class ThreadLocalCleanupFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(req, res); } finally { MyUserContextHolder.remove(); // 假设这是你的 ThreadLocal MyMdcContextHolder.remove(); } } } - 务必注册为
@Order(Ordered.HIGHEST_PRECEDENCE),确保它是最后一个执行的 filter(避免被其他 filter 提前中断) - 不要在
destroy()或contextDestroyed()里做清理——那时线程可能已归属其他应用,或正在 shutdown,remove()可能作用于错误线程
static ThreadLocal 是 Tomcat 下最危险的写法
静态 ThreadLocal 实例生命周期与类绑定,而类由 WebappClassLoader 加载。只要这个类没被卸载(通常只有应用 stop/redeploy 才尝试),静态引用就一直存在。此时即使你每个请求都 remove(),key(即 static ThreadLocal 实例)本身不会被 GC,但它的 value 清空后,entry 的 key 仍非 null;真正危险的是:若你在 static ThreadLocal 里存了任何来自当前 Web 应用的类实例(比如 new UserDto()),这些实例会把整个 classloader 钉在内存里。
- 绝对禁止:
private static final ThreadLocal<userdto> USER_CTX = new ThreadLocal();</userdto> - 可接受替代:用非 static 的 ThreadLocal,配合 Spring 的
@Scope("prototype")Bean 或 request-scoped bean 管理其生命周期 - 若必须 static(如工具类中的
SimpleDateFormat缓存),确保 value 是 JDK 原生类型或跨应用共享类(如String,Long),绝不可引用 Web 应用自定义类
最易被忽略的一点:线程池线程可能执行完你的请求后,又被 Tomcat 用来跑健康检查、JNDI 查找、或 backgroundProcessor 任务——这些任务不走 Filter 链,也不会触发你的清理逻辑。所以,remove() 必须在你代码可控的最后一步执行,不能假定“线程用完就清”。

















