ThreadLocal 内存泄漏:`Entry` 的 key 都用弱引用了,为什么还会泄漏
前言ThreadLocal是个好东西给每个线程一份独立的变量副本天然线程隔离常用来存用户上下文、事务、SimpleDateFormat这类每线程一份的东西。但它有个著名的坑用不好会内存泄漏。更让人困惑的是很多人知道ThreadLocalMap的Entry用了弱引用来防泄漏于是产生一个疑问——既然都用弱引用了为什么还会泄漏这正是这篇文章要讲清楚的。弱引用确实解决了一半问题但另一半value它管不到而线程池又把这个隐患放大成了实实在在的线上事故。环境说明本文基于 JDK 8。涉及的引用类型、GC 概念属于 Java 内存管理基础。一、先复现一个会让内存慢慢涨的接口设想一个 Web 接口用ThreadLocal缓存一个比较大的上下文对象publicclassUserContextHolder{privatestaticfinalThreadLocalUserContextCONTEXTnewThreadLocal();publicstaticvoidset(UserContextctx){CONTEXT.set(ctx);}publicstaticUserContextget(){returnCONTEXT.get();}// 注意这里没有提供 remove()也没人调用}// 拦截器里每个请求进来就 set 一份publicclassContextInterceptorimplementsHandlerInterceptor{publicbooleanpreHandle(HttpServletRequestreq,...){UserContextctxbuildContext(req);// 假设这个对象不小UserContextHolder.set(ctx);returntrue;}// 请求结束后没有 remove}这段代码功能上完全正常测试也没问题。但把它放到生产环境用 Tomcat 默认的线程池跑一段时间后你会观察到堆内存缓慢但持续地增长老年代越堆越满最终频繁 Full GC 甚至 OOM。问题在于ThreadLocal用完了没有remove()而线程池里的线程一直活着不销毁那些UserContext对象就一直被挂在线程上GC 回收不掉。要理解为什么得先看ThreadLocal的存储结构。二、根因/底层弱引用只保护了 keyvalue 没人管2.1 数据到底存在哪不是存在 ThreadLocal 里第一个反直觉的点ThreadLocal.set(value)的值并不存在ThreadLocal对象里而是存在当前线程身上。每个Thread对象内部有一个字段threadLocals类型是ThreadLocal.ThreadLocalMap。你调用threadLocal.set(value)时实际是以这个ThreadLocal实例为 key、你的value为 value存进了当前线程的那个ThreadLocalMap。Thread线程对象 └─ threadLocals: ThreadLocalMap └─ Entry[] 每个 Entry 是一个 key-value 对 key ThreadLocal 实例弱引用 value 你 set 进去的值强引用这个设计的好处是天然隔离不同线程有各自的ThreadLocalMap互不干扰。2.2 关键Entry的 key 是弱引用value 是强引用ThreadLocalMap里的Entry定义是这样的简化staticclassEntryextendsWeakReferenceThreadLocal?{Objectvalue;Entry(ThreadLocal?k,Objectv){super(k);// keyThreadLocal作为弱引用valuev;// value 是强引用普通字段}}注意这个不对称的设计keyThreadLocal实例是弱引用Entry extends WeakReferenceThreadLocalkey 被弱引用持有。value你 set 的值是强引用value就是个普通字段被Entry强引用着。为什么 key 要用弱引用就是为了防泄漏当外部不再引用这个ThreadLocal时比如ThreadLocal变量被置空或超出作用域弱引用不阻止 GCkey 就能被回收掉Entry的 key 变成null。设计者的本意是好的但问题恰恰出在这个一半弱、一半强上。2.3 泄漏是怎么发生的key 没了value 还在设想这样一条引用链当外部对ThreadLocal的强引用消失后key 是弱引用 →GC 时被回收Entry的 key 变成null但 value 是强引用它的引用链是Thread→ThreadLocalMap→Entry→value。只要线程还活着这条强引用链就一直在value就永远回收不掉。结果就是ThreadLocalMap里出现一堆key 为null、value 还占着内存的僵尸 Entry——这就是内存泄漏。2.4 为什么线程池让问题致命如果是普通线程用完就结束Thread对象被回收它的ThreadLocalMap连同里面所有Entry、value 一起被回收泄漏也就自愈了——所以短生命周期的线程问题不明显。但线程池里的线程是复用的、长期存活的。一个线程处理完请求 A不会销毁而是回到池里等着处理请求 B、C、D……它的ThreadLocalMap一直存在。于是每个请求set一个UserContext用完不remove线程不死ThreadLocalMap不释放僵尸 Entry或旧 value越积越多内存持续增长最终 OOM。“ThreadLocal 线程池 忘记 remove” 是内存泄漏的黄金三角。这也是为什么这个坑在 Web 应用Tomcat 线程池里特别常见。JDK 其实做了点补救ThreadLocalMap在set/get/remove时会顺带清理一些 key 为null的僵尸 Entry探测式清理。但这个清理是碰运气的、不彻底的绝不能依赖它。根治办法只有一个手动remove。三、正解用完一定remove()最好放在finally里根治方案非常简单每次用完ThreadLocal显式调用remove()。remove()会把当前线程ThreadLocalMap里对应的整个Entrykey 和 value都删掉斩断强引用链。关键是要保证remove()一定被执行所以放在finally里publicbooleanpreHandle(HttpServletRequestreq,...){UserContextHolder.set(buildContext(req));returntrue;}// 在请求结束的回调里 removeSpring 的 afterCompletionpublicvoidafterCompletion(HttpServletRequestreq,...){UserContextHolder.remove();// ✓ 请求结束清理}或者在业务代码里用标准的try-finally包裹try{UserContextHolder.set(ctx);doBusiness();}finally{UserContextHolder.remove();// ✓ 无论是否异常都清理}finally里做清理正是它的正确用法——可参考上一篇《try-finally 里的 return》。几个补充实践拦截器/过滤器场景在afterCompletion或finally里统一remove别依赖 JDK 的探测式清理。把ThreadLocal声明为static final让它跟随类存在避免它被意外回收其实反而是key 不该被过早回收也便于统一管理。注意这和防泄漏不矛盾——防泄漏靠的是remove不是让 key 被回收。父子线程传递用InheritableThreadLocal但线程池下要谨慎线程复用会导致继承的值错乱阿里的TransmittableThreadLocalTTL是更完善的方案。四、常见误区与面试高频问答QEntry的 key 用了弱引用不就是为了防泄漏吗为什么还漏弱引用只解决了keyThreadLocal 实例的回收让没人引用的ThreadLocal能被 GC。但value 是强引用它通过Thread → ThreadLocalMap → Entry → value这条链被线程强引用着只要线程活着就回收不掉。弱引用防了 key防不了 value——这才是泄漏的根源。Q那 key 为什么不干脆也用强引用或者 value 也用弱引用key 用强引用会更糟ThreadLocalMap会强引用ThreadLocal导致ThreadLocal实例本身也回收不掉泄漏更严重。value 用弱引用又不行value 通常没有其他强引用一 GC 就没了ThreadLocal就存不住值了。所以现在这个key 弱、value 强是权衡后的设计代价就是需要你手动remove。QJDK 不是会自动清理 null key 的 Entry 吗会但不可靠。set/get/remove时会触发探测式/启发式清理顺路清掉一些 key 为null的 Entry。但它只清理碰到的部分槽位不保证全清更不会主动触发。如果后续不再调用这个ThreadLocal的方法僵尸 Entry 就一直留着。不能依赖它必须手动remove。Q为什么普通线程没事线程池才严重普通线程执行完就销毁Thread及其ThreadLocalMap整个被回收泄漏自动消失。线程池的线程长期复用、不销毁ThreadLocalMap一直存在不remove的话 value 越积越多泄漏就暴露了。Qremove()和set(null)一样吗不一样。set(null)只是把 value 设为nullEntry本身key 和这个 null value还留在 map 里是半清理。remove()会把整个Entry从 map 中删除才是彻底清理。要remove()。总结“ThreadLocal 的 key 是弱引用为什么还泄漏”答案在那个不对称的设计里ThreadLocal的值存在线程的ThreadLocalMap里Entry的keyThreadLocal是弱引用、value你的值是强引用。弱引用让没人用的 key 能被 GC 回收key 变null但value 仍被Thread → ThreadLocalMap → Entry → value强引用链拴着只要线程活着就回收不掉形成key 为 null、value 常驻的僵尸 Entry。线程池里线程长期复用、不销毁把这个隐患放大成持续的内存泄漏直至 OOM。根治办法只有一个用完remove()并放在finally/afterCompletion里确保执行。别指望 JDK 的探测式清理。一句话记忆弱引用只保护 keyvalue 是强引用、被活着的线程拴着回收不掉ThreadLocal 线程池 忘记 remove 内存泄漏用完必须remove()。