当前位置: 首页 > news >正文

【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 就会返回半初始化的对象。

http://www.gsyq.cn/news/1629048.html

相关文章:

  • 开源反勒索工具AntiRansom:行为监控与诱饵文件防御实战
  • STM32与MC6470 IMU的硬件协同与姿态控制实现
  • 跨境电商蓝海模式:反向海淘搭建
  • 字节序转换 + 模板
  • 2026年7月1日“每日芯闻”
  • AI建站工具从0到1全流程攻略:不懂代码也能做出专业网站
  • PostgresSQL服务部署
  • 工厂里几十台设备“各说各话“,数据孤岛正在吞噬你的效率
  • MC0479四大名著-红楼签到、MC0480宝物排序、MC0481丫鬟的月例银、MC0482院落管理
  • 一文读懂什么是 GEO 优化?服务商挑选方法与行业避坑完整指南
  • Java后端开发(二十一)-- WinSW将jar包注册为服务,实现开机自启
  • 企业级AGV通信标准化实战:VDA 5050协议的完整实施指南与ROI分析
  • 半导体百科 | 半导体制造中的量测技术:从CD-SEM到GRR系统分析实战
  • 万象RK3506-EG1800网关使用说明
  • 【JAVA毕设源码分享】基于springboot智慧医疗管理系统的设计与实现(程序+文档+代码讲解+一条龙定制)
  • 【芯片设计时序约束深度解析:set_max_delay set_min_delay 的原理与应用】
  • 吴恩达《深度学习》之看懂 Momentum 的“惯性天平”
  • 植物大战僵尸宽屏补丁终极教程:3步实现全屏沉浸体验
  • ubuntu26上原生使用root账号安装最新版openclaw
  • Meta 智能眼镜“对话聚焦”设时长限制,每月 20 美元解锁更多使用时间!
  • 5分钟掌握华硕笔记本性能控制:GHelper轻量级工具完整使用指南
  • 模型路由与提示预处理:控制大语言模型成本、提升令牌使用效果的新方法!
  • 保障用电安全,电能质量监测该用在何处?
  • 3步实战:如何让《艾尔登法环》在高端硬件上释放全部潜能
  • SnapLogic 推出 MCP Builder:无需代码,加速企业 AI 应用落地!
  • # XLua WinForm桌面环境部署与运行说明本次完成了原生XLua在VS2022 WinForm桌面程序的完整部署与功能验证,全程解决编译、库加载、类型兼容三类核心问题。首先通过CMake编译
  • GPT工程能力全景图谱:场景映射、标准化工作流与落地实战指南
  • Prompt Engineering在AI Agent中的高级技巧:从Chain-of-Thought到Tree-of-Thought
  • gsplat安装与使用指南:高效实现3D高斯溅射渲染
  • Dify 1.15人工介入功能详解:构建可控AI工作流实战