Java ThreadLocal 内存泄漏:原理、复现与正确姿势

📅 2026/7/15 9:32:23 👁️ 阅读次数 📝 编程学习
Java ThreadLocal 内存泄漏:原理、复现与正确姿势

Java ThreadLocal 内存泄漏:原理、复现与正确姿势

ThreadLocal是个好东西——给每个线程一份独立的变量副本,省去了加锁。但在用了线程池的 Web 应用里,它是内存泄漏的高发区,而且泄漏得很隐蔽:内存慢慢涨,GC 也回收不掉,最后OutOfMemoryError。这篇讲清楚为什么会漏、怎么复现、以及正确用法,顺带把「弱引用为什么救不了你」这个常见误解说透。

先看正常用法:它到底存哪了

ThreadLocal最典型的用途是存放「线程内共享、线程间隔离」的上下文,比如当前登录用户、数据库连接、事务上下文:

publicclassUserContext{privatestaticfinalThreadLocal<User>CURRENT=newThreadLocal<>();publicstaticvoidset(Useruser){CURRENT.set(user);}publicstaticUserget(){returnCURRENT.get();}publicstaticvoidclear(){CURRENT.remove();}}

关键要搞清楚数据存在哪。很多人以为值存在ThreadLocal对象里——。值存在每个 Thread 自己的ThreadLocalMap里。也就是说:

  • 每个Thread对象内部有个字段threadLocals,类型是ThreadLocalMap
  • 这个 map 的key 是 ThreadLocal 对象(弱引用),value 是你 set 进去的值(强引用)
  • 你调CURRENT.set(user),实际是往「当前线程的 map」里塞一条{CURRENT: user}

理解了这个结构,泄漏原因就呼之欲出了。

泄漏根因:线程活着,value 就一直被强引用

ThreadLocalMapEntry的定义(JDK 源码简化):

staticclassEntryextendsWeakReference<ThreadLocal<?>>{Objectvalue;// 注意:value 是普通强引用字段Entry(ThreadLocal<?>k,Objectv){super(k);// key 作为弱引用value=v;// value 强引用}}

Entry继承WeakReference,弱引用的是key(ThreadLocal 对象)。所以当外部对ThreadLocal的强引用没了,key 会被 GC 回收,变成null。但——value 是个普通强引用字段,不会因为 key 没了就自动回收

于是出现这个链条,只要线程还活着,value 就永远回收不掉:

Thread (存活) → threadLocals(ThreadLocalMap) → Entry → value(强引用) → 你的大对象

用完就销毁的线程里,这不是问题——线程死了,它的ThreadLocalMap整个被回收,value 跟着走。但线程池里的线程是复用的、长期存活的,这就要命了:

复现泄漏:线程池 + 忘记 remove

importjava.util.concurrent.*;publicclassLeakDemo{// 每个任务往 ThreadLocal 塞一个 5MB 的数组privatestaticfinalThreadLocal<byte[]>BIG=newThreadLocal<>();publicstaticvoidmain(String[]args)throwsException{// 只有 2 个线程的池,这 2 个线程会一直活着ExecutorServicepool=Executors.newFixedThreadPool(2);for(inti=0;i<1000;i++){pool.submit(()->{BIG.set(newbyte[5*1024*1024]);// 塞 5MB// 干活...// 忘了 BIG.remove() ← 泄漏就发生在这});}pool.shutdown();}}

-Xmx128m跑这段,很快OutOfMemoryError。原因:池里 2 个线程反复执行任务,每次set都覆盖不了别人——因为是同一个线程反复被复用,它的ThreadLocalMap里那条 Entry 的 value 被换成新的 5MB 数组,旧的确实能回收,但只要线程还在跑任务,当前这份 5MB 就一直被强引用。更糟的场景是每个任务用不同的 ThreadLocal,那 map 里会堆越来越多 Entry。

核心结论:线程池里用 ThreadLocal,任务结束不remove(),value 会随线程一直存活,累积成泄漏。

「弱引用」为什么救不了你

很多人看到 key 是弱引用,就以为「GC 会自动清理,不用管」。这是最大的误解。弱引用的是key,不是value。GC 回收 key 后,map 里会出现一条key=null 但 value 还在的「僵尸 Entry」:

Entry{ key=null(已回收), value=你的5MB数组(还活着) }

ThreadLocalMap确实有个「顺手清理」机制:在set/get/remove时会部分清理这些 key 为 null 的 Entry。但它是启发式的、不保证清理干净,尤其你如果之后再也不碰这个线程的这个 ThreadLocal,那条僵尸 Entry 的 value 就永远躺在那。弱引用 key 只是让 key 能被回收,救不了 value。唯一可靠的办法是你自己调remove()

正确姿势:try-finally 里 remove,一次都不能少

铁律:在线程池环境下,ThreadLocal 用完必须在finallyremove()

publicvoidhandleRequest(Requestreq){try{UserContext.set(parseUser(req));// 请求开始:设上下文doBusiness();// 业务逻辑里随处 UserContext.get()}finally{UserContext.clear();// 请求结束:一定清理,异常也清}}

放在finally是因为业务代码可能抛异常,不能因为异常就漏掉清理。Web 项目里,更常见的是把这个 try-finally 收进一个统一的过滤器或拦截器,保证每个请求进出都成对 set/remove:

// Servlet Filter:所有请求统一兜底,业务代码不用各自记得 removepublicclassUserContextFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequestreq,ServletResponseresp,FilterChainchain)throwsIOException,ServletException{try{UserContext.set(resolveUser(req));chain.doFilter(req,resp);}finally{UserContext.clear();// 请求处理完,归还线程给池子前清干净}}}

这样即便某个 Controller 忘了清理,过滤器也会兜底,线程归还线程池时是干净的。

另一个坑:父子线程传递不了,别指望 ThreadLocal

ThreadLocal线程私有的,你在主线程 set 的值,新起的子线程get出来是null。异步场景(线程池提交任务、CompletableFuture)里想把上下文传下去,ThreadLocal传不过去。JDK 提供了InheritableThreadLocal,但它只在创建子线程那一刻复制一次,对复用线程的线程池无效(线程是提前建好的,不是每次任务新建)。线程池场景要传递上下文,得用阿里开源的TransmittableThreadLocal(TTL),或者手动在提交任务前抓取、任务里恢复:

// 手动传递:提交前抓快照,任务里恢复Usersnapshot=UserContext.get();pool.submit(()->{try{UserContext.set(snapshot);// 恢复父线程的上下文doAsyncWork();}finally{UserContext.clear();}});

小结

  • ThreadLocal的值存在每个 Thread 的ThreadLocalMap里,key 弱引用 ThreadLocal 对象,value 是强引用
  • 泄漏根因:线程池的线程长期存活,value 被Thread → ThreadLocalMap → Entry强引用链一直挂着,不remove就回收不掉,累积成 OOM。
  • 「key 是弱引用所以不用管」是错的——弱引用救的是 key,value 得你自己remove();僵尸 Entry(key=null,value 在)只会被启发式部分清理,不可靠。
  • 正确姿势:线程池里用完必须 try-finally +remove(),Web 项目用统一的 Filter/拦截器兜底,保证线程归还前清干净。
  • 跨线程传上下文别指望 ThreadLocal;线程池场景用 TransmittableThreadLocal 或手动抓快照恢复。
  • 一句话记忆点:线程池 + ThreadLocal = 必须 remove,finally 里清,一次都不能漏。