【Java踩坑笔记】22_ThreadLocal用完不remove,内存泄漏在等你

📅 2026/7/3 18:49:37 👁️ 阅读次数 📝 编程学习
【Java踩坑笔记】22_ThreadLocal用完不remove,内存泄漏在等你

22 | ThreadLocal 用完不 remove,内存泄漏在等你

摘要:线程池场景,ThreadLocal设置值后不remove(),值会一直保留在线程里,导致内存泄漏。ThreadLocalMap的 key 是弱引用,但 value 是强引用,不主动remove()就无法回收。


一、问题现象

publicclassThreadLocalLeakTest{privatestaticfinalThreadLocal<BigDecimal>THREAD_LOCAL=newThreadLocal<>();publicstaticvoidmain(String[]args){ExecutorServicepool=Executors.newFixedThreadPool(1);for(inti=0;i<10;i++){pool.submit(()->{THREAD_LOCAL.set(newBigDecimal("99999999999999"));// 大对象// ❌ 没调用 THREAD_LOCAL.remove()// 线程池的线程会一直持有这个 BigDecimal});}}}

现象:内存使用量持续增长,GC 无法回收ThreadLocal里的值。


二、踩坑现场

场景 1:Web 请求的上下文信息

// ❌ 错误:拦截器设置了用户信息,但没清理@ComponentpublicclassUserInterceptorimplementsHandlerInterceptor{privatestaticfinalThreadLocal<User>USER_CONTEXT=newThreadLocal<>();@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){Useruser=getUserFromSession(request);USER_CONTEXT.set(user);// 设置用户信息returntrue;}// ❌ 没有在 afterCompletion 里调用 USER_CONTEXT.remove()}

问题:Tomcat 的线程池复用线程,用户 A 的请求处理完后,线程里还保留着用户 A 的信息。下次请求复用同一个线程时,USER_CONTEXT.get()可能拿到用户 A 的数据!

场景 2:日期格式化

// ❌ 错误:每次 set 新对象,旧对象没法回收privatestaticfinalThreadLocal<SimpleDateFormat>DATE_FORMAT=ThreadLocal.withInitial(()->newSimpleDateFormat("yyyy-MM-dd"));publicStringformat(Datedate){returnDATE_FORMAT.get().format(date);// 如果线程池有 200 个线程,就有 200 个 SimpleDateFormat 对象一直活着}

三、原理解析

3.1 ThreadLocal 的内存模型

Thread └── ThreadLocalMap threadLocals └── Entry[] table ├── Entry(key=ThreadLocal 弱引用, value=你 set 的对象) ├── Entry(key=ThreadLocal 弱引用, value=...) └── ...

关键点

  • ThreadLocalMap.Entrykey 是弱引用WeakReference<ThreadLocal<?>>
  • value 是强引用(直接引用你set的对象)

3.2 为什么 value 会泄漏?

1. 线程池的线程不会销毁(一直活着) 2. 线程的 ThreadLocalMap 一直活着 3. key(ThreadLocal)可以被 GC 回收(弱引用) 4. 但 value 是强引用,只要线程活着,value 就活着 5. 如果没调用 remove(),这个 value 永远无法被回收

更可怕的是:key 被回收后,Entry 变成(null, value),这个 value永远无法被访问到,但也无法被回收(内存泄漏)。

3.3 弱引用不是万能药

很多人以为:“key 是弱引用,GC 后会自动清理”。

错了:弱引用只保证key 可以被 GC 回收,但value 不会自动清理。必须手动调用remove()

3.4 ThreadLocal 的正确清理时机

请求开始(preHandle) → ThreadLocal.set(userInfo) ↓ 请求处理(controller/service) → ThreadLocal.get() 获取用户信息 ↓ 请求结束(afterCompletion) → ThreadLocal.remove() ✅ 必须在这里清理

四、正确写法

4.1 在 finally 块里 remove

// ✅ 正确:用完立即 removeExecutorServicepool=Executors.newFixedThreadPool(4);pool.submit(()->{try{THREAD_LOCAL.set(newBigDecimal("9999999999"));// 业务逻辑doBusiness();}finally{THREAD_LOCAL.remove();// ✅ finally 保证一定执行}});

4.2 Web 拦截器:在 afterCompletion 里 remove

// ✅ 正确:Spring 拦截器里清理@ComponentpublicclassUserInterceptorimplementsHandlerInterceptor{privatestaticfinalThreadLocal<User>USER_CONTEXT=newThreadLocal<>();@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){USER_CONTEXT.set(getUserFromSession(request));returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){USER_CONTEXT.remove();// ✅ 请求结束后清理}}

4.3 用 try-with-resources 模式(Java 8+ 封装)

// ✅ 封装 ThreadLocal 的使用,自动清理publicclassThreadLocalScope<T>implementsAutoCloseable{privatefinalThreadLocal<T>threadLocal;privatefinalTvalue;publicThreadLocalScope(ThreadLocal<T>threadLocal,Tvalue){this.threadLocal=threadLocal;this.value=value;threadLocal.set(value);}publicstatic<T>ThreadLocalScope<T>with(ThreadLocal<T>tl,Tvalue){returnnewThreadLocalScope<>(tl,value);}@Overridepublicvoidclose(){threadLocal.remove();// ✅ AutoCloseable 自动调用}}// 使用:try(ThreadLocalScope.with(USER_CONTEXT,user)){// 业务逻辑doBusiness();}// 自动调用 close() → remove()

4.4 每次使用都初始化(不用线程池场景)

// ✅ 如果不用线程池,每次 new Thread 的场景,可以不 remove// 因为线程结束,ThreadLocalMap 也随之销毁// 但养成 remove 的习惯仍然是最好的

五、最佳实践

✅ ThreadLocal 使用的 5 条铁律

  1. 每次set()之后,必须在finally里调用remove()
  2. Spring 拦截器:在afterCompletionremove()
  3. 线程池场景必须remove(),否则内存泄漏
  4. 初始化放在try外面,remove()放在finally
  5. ThreadLocal.withInitial()代替手动set()初始化(但仍需remove()

🔍 如何排查 ThreadLocal 内存泄漏?

# 1. 用 jcmd 或 jmap 导出堆快照jmap -dump:live,format=b,file=heap.hprof<pid># 2. 用 Eclipse MAT 分析# 查找:java.lang.ThreadLocal$ThreadLocalMap$Entry# 筛选出 key=null 但 value 不为 null 的 Entry

🛠️ IDEA 的 Hints

开启ThreadLocal is not removed检查,让 IDE 在ThreadLocal.set()后没找到remove()时提醒你。


六、小结

  • ThreadLocalkey 是弱引用,value 是强引用
  • 线程池场景,线程不销毁,value 会一直积累,导致内存泄漏
  • 必须养成习惯:set()之后,在finallyremove()
  • Spring 拦截器里,在afterCompletion回调中remove()
  • 内存泄漏排查:用 MAT 分析堆快照,找ThreadLocalMap$Entrykey=null的条目

下一篇预告:double-checked locking 单例,你写的真的线程安全吗?—— 看似完美的双重检查锁,少了 volatile 就会返回半初始化的对象。