【Java踩坑笔记】22_ThreadLocal用完不remove,内存泄漏在等你
📅 2026/7/3 18:49:37
👁️ 阅读次数
📝 编程学习
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.Entry的key 是弱引用(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 条铁律
- 每次
set()之后,必须在finally里调用remove() - Spring 拦截器:在
afterCompletion里remove() - 线程池场景必须
remove(),否则内存泄漏 - 初始化放在
try外面,remove()放在finally里 - 用
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()时提醒你。
六、小结
ThreadLocal的key 是弱引用,value 是强引用- 线程池场景,线程不销毁,value 会一直积累,导致内存泄漏
- 必须养成习惯:
set()之后,在finally里remove() - Spring 拦截器里,在
afterCompletion回调中remove() - 内存泄漏排查:用 MAT 分析堆快照,找
ThreadLocalMap$Entry中key=null的条目
下一篇预告:double-checked locking 单例,你写的真的线程安全吗?—— 看似完美的双重检查锁,少了 volatile 就会返回半初始化的对象。
编程学习
技术分享
实战经验