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

发布时间:2026/7/3 18:48:31
【Java踩坑笔记】22_ThreadLocal用完不remove,内存泄漏在等你 22 | ThreadLocal 用完不 remove内存泄漏在等你摘要线程池场景ThreadLocal设置值后不remove()值会一直保留在线程里导致内存泄漏。ThreadLocalMap的 key 是弱引用但 value 是强引用不主动remove()就无法回收。一、问题现象publicclassThreadLocalLeakTest{privatestaticfinalThreadLocalBigDecimalTHREAD_LOCALnewThreadLocal();publicstaticvoidmain(String[]args){ExecutorServicepoolExecutors.newFixedThreadPool(1);for(inti0;i10;i){pool.submit(()-{THREAD_LOCAL.set(newBigDecimal(99999999999999));// 大对象// ❌ 没调用 THREAD_LOCAL.remove()// 线程池的线程会一直持有这个 BigDecimal});}}}现象内存使用量持续增长GC 无法回收ThreadLocal里的值。二、踩坑现场场景 1Web 请求的上下文信息// ❌ 错误拦截器设置了用户信息但没清理ComponentpublicclassUserInterceptorimplementsHandlerInterceptor{privatestaticfinalThreadLocalUserUSER_CONTEXTnewThreadLocal();OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){UserusergetUserFromSession(request);USER_CONTEXT.set(user);// 设置用户信息returntrue;}// ❌ 没有在 afterCompletion 里调用 USER_CONTEXT.remove()}问题Tomcat 的线程池复用线程用户 A 的请求处理完后线程里还保留着用户 A 的信息。下次请求复用同一个线程时USER_CONTEXT.get()可能拿到用户 A 的数据场景 2日期格式化// ❌ 错误每次 set 新对象旧对象没法回收privatestaticfinalThreadLocalSimpleDateFormatDATE_FORMATThreadLocal.withInitial(()-newSimpleDateFormat(yyyy-MM-dd));publicStringformat(Datedate){returnDATE_FORMAT.get().format(date);// 如果线程池有 200 个线程就有 200 个 SimpleDateFormat 对象一直活着}三、原理解析3.1 ThreadLocal 的内存模型Thread └── ThreadLocalMap threadLocals └── Entry[] table ├── Entry(keyThreadLocal 弱引用, value你 set 的对象) ├── Entry(keyThreadLocal 弱引用, value...) └── ...关键点ThreadLocalMap.Entry的key 是弱引用WeakReferenceThreadLocal?value 是强引用直接引用你set的对象3.2 为什么 value 会泄漏1. 线程池的线程不会销毁一直活着 2. 线程的 ThreadLocalMap 一直活着 3. keyThreadLocal可以被 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// ✅ 正确用完立即 removeExecutorServicepoolExecutors.newFixedThreadPool(4);pool.submit(()-{try{THREAD_LOCAL.set(newBigDecimal(9999999999));// 业务逻辑doBusiness();}finally{THREAD_LOCAL.remove();// ✅ finally 保证一定执行}});4.2 Web 拦截器在 afterCompletion 里 remove// ✅ 正确Spring 拦截器里清理ComponentpublicclassUserInterceptorimplementsHandlerInterceptor{privatestaticfinalThreadLocalUserUSER_CONTEXTnewThreadLocal();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 的使用自动清理publicclassThreadLocalScopeTimplementsAutoCloseable{privatefinalThreadLocalTthreadLocal;privatefinalTvalue;publicThreadLocalScope(ThreadLocalTthreadLocal,Tvalue){this.threadLocalthreadLocal;this.valuevalue;threadLocal.set(value);}publicstaticTThreadLocalScopeTwith(ThreadLocalTtl,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,formatb,fileheap.hprofpid# 2. 用 Eclipse MAT 分析# 查找java.lang.ThreadLocal$ThreadLocalMap$Entry# 筛选出 keynull 但 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中keynull的条目下一篇预告double-checked locking 单例你写的真的线程安全吗—— 看似完美的双重检查锁少了 volatile 就会返回半初始化的对象。