ThreadLocal为什么会发生内存泄漏?

张开发
2026/4/20 5:22:43 15 分钟阅读

分享文章

ThreadLocal为什么会发生内存泄漏?
排查技术问题有个基本套路先复现再分析。能稳定复现的问题基本就解决了一半。网上关于ThreadLocal内存泄漏的文章很多说法也不完全一致与其记住别人的结论不如自己写段代码验证一下亲眼看到的东西比看十篇博客都靠谱。ThreadLocal的内存泄漏也一样先不讲原理用Java17写一段代码把问题复现出来看看它到底长什么样。搞清楚现象之后再从JDK源码分析原因最后看Tomcat和Spring在生产环境里是怎么处理这个问题的。用代码复现问题下面这段代码模拟了线程池场景下最常见的ThreadLocal泄漏。5个线程每个往ThreadLocal里放10M数据不调用remove()。跑完之后对比一下调用remove前后的内存占用import java.util.concurrent.*; public class ThreadLocalLeakDemo { private static final ThreadLocalbyte[] CACHE new ThreadLocal(); public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(5); // 提交5个任务每个往ThreadLocal放10M for (int i 0; i 5; i) { pool.submit(() - { CACHE.set(new byte[10 * 1024 * 1024]); // 没有调用CACHE.remove() }); } Thread.sleep(1000); System.gc(); Thread.sleep(1000); printMemory(不调用removeGC后); // 让每个线程执行remove CountDownLatch latch new CountDownLatch(5); for (int i 0; i 5; i) { pool.submit(() - { CACHE.remove(); latch.countDown(); }); } latch.await(); System.gc(); Thread.sleep(1000); printMemory(调用remove后GC后); pool.shutdown(); } private static void printMemory(String tag) { Runtime rt Runtime.getRuntime(); long used (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024); System.out.println(tag - 内存占用: used M); } }在Java17 Windows11环境下跑一下输出是这样的不调用removeGC后 - 内存占用: 61M 调用remove后GC后 - 内存占用: 40M不调用remove的时候5个线程各持有10M数据GC回收不掉内存占用61M。调用remove之后降到了40M。没有精确降50M是因为GC回收了数组之后JVM已经扩展的堆空间不会立刻缩减Runtime.totalMemory()不变算出来的已用内存就比预期高。换个GC跑这段代码数字会不一样感兴趣可以自己试试。核心结论不变不remove的数据GC拿它没办法。这就是ThreadLocal内存泄漏的典型表现线程还活着数据不再需要了但GC回收不掉。下面从源码层面分析为什么会这样。ThreadLocal的存储结构每个Thread对象内部有一个字段叫threadLocals类型是ThreadLocal.ThreadLocalMap。这个Map不是我们常用的HashMap它是ThreadLocal的一个静态内部类自己实现了一套基于开放寻址法的哈希表。Map里存的是Entry数组每个Entry长这样static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { // key是弱引用 super(k); value v; } }Entry继承了WeakReferencekey也就是ThreadLocal实例本身是弱引用value是强引用。下面这张图画了整个引用链的关系栈上的ThreadLocal引用指向堆上的ThreadLocal对象Thread对象通过threadLocals字段持有ThreadLocalMapMap里的Entry通过弱引用指向同一个ThreadLocal对象Entry的value字段则通过强引用指向实际存储的值。回到刚才的复现代码。CACHE是一个静态字段ThreadLocal对象本身不会被回收。5个线程执行完任务后每个线程的ThreadLocalMap里都有一个Entrykey指向CACHEvalue指向10M的byte数组。任务跑完了但线程池里的线程不会销毁Entry也不会自动消失这50M就一直占着。这是最常见的一种泄漏模式。还有一种更隐蔽的情况ThreadLocal对象本身也被回收了。更隐蔽的泄漏场景假设ThreadLocal不是静态字段而是在方法里创建的public void doSomething() { ThreadLocalbyte[] tl new ThreadLocal(); // 往ThreadLocal里放了10M数据 tl.set(new byte[10 * 1024 * 1024]); // 方法结束tl这个栈上的引用没了 }方法执行完栈上的tl引用消失了。此时指向ThreadLocal对象的强引用没了只剩Entry里那个弱引用。下一次GC的时候ThreadLocal对象会被回收Entry的key变成null。key没了value还在。Entry里的value字段是强引用那个10M的byte数组释放不掉。在普通线程里这其实不是大问题。线程执行完Thread对象被回收ThreadLocalMap跟着一起没了value自然也释放了。真正出问题的场景是线程池。线程池里的线程是复用的一个线程可能活很久处理成千上万个请求。每次请求往ThreadLocal里塞数据如果不手动remove这些key为null的Entry就会一直累积内存占用越来越大。JDK也不是完全没管这事。ThreadLocalMap在执行get()、set()、remove()的时候会顺便清理遇到的key为null的Entry这个方法叫expungeStaleEntry()。它会把失效Entry的value设为null然后把Entry本身从数组中移除。不过这个清理是被动的、局部的只有在哈希定位的过程中碰到了才会清理没碰到的就继续留着。ThreadLocal内存泄漏的根因不是弱引用而是没有调用remove()。弱引用的设计反而是一道兜底防线。没有它的话只要忘了remove连key指向的ThreadLocal对象都回收不了泄漏只会更严重。Tomcat的解决方案Tomcat对ThreadLocal泄漏的处理比较全面核心思路两个检测泄漏 线程续期。泄漏检测Tomcat在Web应用停止的时候会通过WebappClassLoaderBase的checkThreadLocalsForLeaks()方法扫描所有线程的ThreadLocalMap。做法比较暴力通过反射拿到Thread的私有字段threadLocals先调用expungeStaleEntries()做一次全量清理再逐个检查剩余Entry的key和value是不是由当前Web应用的类加载器加载的。如果是说明发生了泄漏输出error级别的日志。如果你在Tomcat日志里见过The web application [xxx] created a ThreadLocal with key of type [xxx]这样的警告就是这个方法输出的。线程续期检测只是事后发现问题Tomcat还有一个更主动的策略线程续期。核心逻辑在两个方法里。当一个Web应用停止时ThreadLocalLeakPreventionListener找到关联的线程池调用contextStopping()public void contextStopping() { this.lastContextStoppedTime.set(System.currentTimeMillis()); int savedCorePoolSize this.getCorePoolSize(); // 临时设为0唤醒空闲线程 this.setCorePoolSize(0); this.setCorePoolSize(savedCorePoolSize); }记录下停止时间戳然后临时把核心线程数设为0再恢复目的是唤醒所有空闲线程。线程每执行完一个任务在afterExecute()里检查自己是否需要被替换。Tomcat自定义的TaskThread在构造时记录了创建时间判断逻辑就是拿创建时间和Context停止时间做对比protected boolean currentThreadShouldBeStopped() { Thread currentThread Thread.currentThread(); if (threadRenewalDelay 0 currentThread instanceof TaskThread) { TaskThread currentTaskThread (TaskThread) currentThread; // 线程创建时间早于Context停止时间说明是旧线程 if (currentTaskThread.getCreationTime() this.lastContextStoppedTime.longValue()) { return true; } } return false; }如果当前线程的创建时间早于Context的停止时间判定为旧线程抛出StopPooledThreadException让线程退出线程池线程池随后创建新线程补位。新线程的ThreadLocalMap是空的旧线程上累积的所有ThreadLocal数据就被彻底丢弃了。Tomcat的线程续期本质上是用「换线程」的方式来清理ThreadLocal不去动ThreadLocal本身而是把整个线程换掉。Spring的处理方式Spring没有重新造一套ThreadLocal它的策略比较务实在框架层面确保用完就清理。最典型的例子是RequestContextHolder。它用ThreadLocal保存当前请求的RequestAttributes请求处理完毕后通过Filter的finally块保证清理// RequestContextFilter的核心逻辑 protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { RequestContextHolder.setRequestAttributes( new ServletRequestAttributes(request, response)); try { filterChain.doFilter(request, response); } finally { // 请求结束必定清理 RequestContextHolder.resetRequestAttributes(); } }这个模式简单有效谁set的谁负责remove用try-finally保证不遗漏。Spring里所有用到ThreadLocal的地方基本都遵循这个模式包括事务管理器的TransactionSynchronizationManager、国际化的LocaleContextHolder等。Spring还提供了NamedThreadLocal给ThreadLocal加了个名字。功能上和普通ThreadLocal完全一样唯一的区别是toString()返回这个名字。排查泄漏的时候日志里能看到具体是哪个ThreadLocal出了问题不再是一堆匿名的ThreadLocalxxxxx。看着像个小功能实际排查问题时特别有用。最佳实践速查表各方案放在一起对比方案核心思路适用场景JDK ThreadLocal remove()用完手动清理通用场景Tomcat线程续期替换旧线程丢弃历史数据Servlet容器内部Spring Filter模式try-finally保证清理Web请求生命周期日常开发中防泄漏的几个关键做法ThreadLocal用完必须调用remove()放在finally块里Web应用优先用Spring提供的RequestContextHolder这类封装不要自己直接操作ThreadLocal自定义ThreadLocal时考虑用NamedThreadLocal方便排查问题线上怀疑有ThreadLocal泄漏可以用Arthas的vmtool命令检查线程上的ThreadLocalMap小结写了这么多你就记住三句话不remove的数据GC拿它没办法。ThreadLocal本身不危险危险的是用了不清理。谁set的谁remove。

更多文章