ThreadLocal 内存泄漏:`Entry` 的 key 都用弱引用了,为什么还会泄漏

发布时间:2026/9/28 9:22:20

ThreadLocal 内存泄漏:`Entry` 的 key 都用弱引用了,为什么还会泄漏
前言ThreadLocal是个好东西给每个线程一份独立的变量副本天然线程隔离常用来存用户上下文、事务、SimpleDateFormat这类每线程一份的东西。但它有个著名的坑用不好会内存泄漏。更让人困惑的是很多人知道ThreadLocalMap的Entry用了弱引用来防泄漏于是产生一个疑问——既然都用弱引用了为什么还会泄漏这正是这篇文章要讲清楚的。弱引用确实解决了一半问题但另一半value它管不到而线程池又把这个隐患放大成了实实在在的线上事故。环境说明本文基于 JDK 8。涉及的引用类型、GC 概念属于 Java 内存管理基础。一、先复现一个会让内存慢慢涨的接口设想一个 Web 接口用ThreadLocal缓存一个比较大的上下文对象publicclassUserContextHolder{privatestaticfinalThreadLocalUserContextCONTEXTnewThreadLocal();publicstaticvoidset(UserContextctx){CONTEXT.set(ctx);}publicstaticUserContextget(){returnCONTEXT.get();}// 注意这里没有提供 remove()也没人调用}// 拦截器里每个请求进来就 set 一份publicclassContextInterceptorimplementsHandlerInterceptor{publicbooleanpreHandle(HttpServletRequestreq,...){UserContextctxbuildContext(req);// 假设这个对象不小UserContextHolder.set(ctx);returntrue;}// 请求结束后没有 remove}这段代码功能上完全正常测试也没问题。但把它放到生产环境用 Tomcat 默认的线程池跑一段时间后你会观察到堆内存缓慢但持续地增长老年代越堆越满最终频繁 Full GC 甚至 OOM。问题在于ThreadLocal用完了没有remove()而线程池里的线程一直活着不销毁那些UserContext对象就一直被挂在线程上GC 回收不掉。要理解为什么得先看ThreadLocal的存储结构。二、根因/底层弱引用只保护了 keyvalue 没人管2.1 数据到底存在哪不是存在 ThreadLocal 里第一个反直觉的点ThreadLocal.set(value)的值并不存在ThreadLocal对象里而是存在当前线程身上。每个Thread对象内部有一个字段threadLocals类型是ThreadLocal.ThreadLocalMap。你调用threadLocal.set(value)时实际是以这个ThreadLocal实例为 key、你的value为 value存进了当前线程的那个ThreadLocalMap。Thread线程对象 └─ threadLocals: ThreadLocalMap └─ Entry[] 每个 Entry 是一个 key-value 对 key ThreadLocal 实例弱引用 value 你 set 进去的值强引用这个设计的好处是天然隔离不同线程有各自的ThreadLocalMap互不干扰。2.2 关键Entry的 key 是弱引用value 是强引用ThreadLocalMap里的Entry定义是这样的简化staticclassEntryextendsWeakReferenceThreadLocal?{Objectvalue;Entry(ThreadLocal?k,Objectv){super(k);// keyThreadLocal作为弱引用valuev;// value 是强引用普通字段}}注意这个不对称的设计keyThreadLocal实例是弱引用Entry extends WeakReferenceThreadLocalkey 被弱引用持有。value你 set 的值是强引用value就是个普通字段被Entry强引用着。为什么 key 要用弱引用就是为了防泄漏当外部不再引用这个ThreadLocal时比如ThreadLocal变量被置空或超出作用域弱引用不阻止 GCkey 就能被回收掉Entry的 key 变成null。设计者的本意是好的但问题恰恰出在这个一半弱、一半强上。2.3 泄漏是怎么发生的key 没了value 还在设想这样一条引用链当外部对ThreadLocal的强引用消失后key 是弱引用 →GC 时被回收Entry的 key 变成null但 value 是强引用它的引用链是Thread→ThreadLocalMap→Entry→value。只要线程还活着这条强引用链就一直在value就永远回收不掉。结果就是ThreadLocalMap里出现一堆key 为null、value 还占着内存的僵尸 Entry——这就是内存泄漏。2.4 为什么线程池让问题致命如果是普通线程用完就结束Thread对象被回收它的ThreadLocalMap连同里面所有Entry、value 一起被回收泄漏也就自愈了——所以短生命周期的线程问题不明显。但线程池里的线程是复用的、长期存活的。一个线程处理完请求 A不会销毁而是回到池里等着处理请求 B、C、D……它的ThreadLocalMap一直存在。于是每个请求set一个UserContext用完不remove线程不死ThreadLocalMap不释放僵尸 Entry或旧 value越积越多内存持续增长最终 OOM。“ThreadLocal 线程池 忘记 remove” 是内存泄漏的黄金三角。这也是为什么这个坑在 Web 应用Tomcat 线程池里特别常见。JDK 其实做了点补救ThreadLocalMap在set/get/remove时会顺带清理一些 key 为null的僵尸 Entry探测式清理。但这个清理是碰运气的、不彻底的绝不能依赖它。根治办法只有一个手动remove。三、正解用完一定remove()最好放在finally里根治方案非常简单每次用完ThreadLocal显式调用remove()。remove()会把当前线程ThreadLocalMap里对应的整个Entrykey 和 value都删掉斩断强引用链。关键是要保证remove()一定被执行所以放在finally里publicbooleanpreHandle(HttpServletRequestreq,...){UserContextHolder.set(buildContext(req));returntrue;}// 在请求结束的回调里 removeSpring 的 afterCompletionpublicvoidafterCompletion(HttpServletRequestreq,...){UserContextHolder.remove();// ✓ 请求结束清理}或者在业务代码里用标准的try-finally包裹try{UserContextHolder.set(ctx);doBusiness();}finally{UserContextHolder.remove();// ✓ 无论是否异常都清理}finally里做清理正是它的正确用法——可参考上一篇《try-finally 里的 return》。几个补充实践拦截器/过滤器场景在afterCompletion或finally里统一remove别依赖 JDK 的探测式清理。把ThreadLocal声明为static final让它跟随类存在避免它被意外回收其实反而是key 不该被过早回收也便于统一管理。注意这和防泄漏不矛盾——防泄漏靠的是remove不是让 key 被回收。父子线程传递用InheritableThreadLocal但线程池下要谨慎线程复用会导致继承的值错乱阿里的TransmittableThreadLocalTTL是更完善的方案。四、常见误区与面试高频问答QEntry的 key 用了弱引用不就是为了防泄漏吗为什么还漏弱引用只解决了keyThreadLocal 实例的回收让没人引用的ThreadLocal能被 GC。但value 是强引用它通过Thread → ThreadLocalMap → Entry → value这条链被线程强引用着只要线程活着就回收不掉。弱引用防了 key防不了 value——这才是泄漏的根源。Q那 key 为什么不干脆也用强引用或者 value 也用弱引用key 用强引用会更糟ThreadLocalMap会强引用ThreadLocal导致ThreadLocal实例本身也回收不掉泄漏更严重。value 用弱引用又不行value 通常没有其他强引用一 GC 就没了ThreadLocal就存不住值了。所以现在这个key 弱、value 强是权衡后的设计代价就是需要你手动remove。QJDK 不是会自动清理 null key 的 Entry 吗会但不可靠。set/get/remove时会触发探测式/启发式清理顺路清掉一些 key 为null的 Entry。但它只清理碰到的部分槽位不保证全清更不会主动触发。如果后续不再调用这个ThreadLocal的方法僵尸 Entry 就一直留着。不能依赖它必须手动remove。Q为什么普通线程没事线程池才严重普通线程执行完就销毁Thread及其ThreadLocalMap整个被回收泄漏自动消失。线程池的线程长期复用、不销毁ThreadLocalMap一直存在不remove的话 value 越积越多泄漏就暴露了。Qremove()和set(null)一样吗不一样。set(null)只是把 value 设为nullEntry本身key 和这个 null value还留在 map 里是半清理。remove()会把整个Entry从 map 中删除才是彻底清理。要remove()。总结“ThreadLocal 的 key 是弱引用为什么还泄漏”答案在那个不对称的设计里ThreadLocal的值存在线程的ThreadLocalMap里Entry的keyThreadLocal是弱引用、value你的值是强引用。弱引用让没人用的 key 能被 GC 回收key 变null但value 仍被Thread → ThreadLocalMap → Entry → value强引用链拴着只要线程活着就回收不掉形成key 为 null、value 常驻的僵尸 Entry。线程池里线程长期复用、不销毁把这个隐患放大成持续的内存泄漏直至 OOM。根治办法只有一个用完remove()并放在finally/afterCompletion里确保执行。别指望 JDK 的探测式清理。一句话记忆弱引用只保护 keyvalue 是强引用、被活着的线程拴着回收不掉ThreadLocal 线程池 忘记 remove 内存泄漏用完必须remove()。

相关新闻

自动驾驶仿真利器Carla:从游戏引擎到算法测试沙盒

自动驾驶仿真利器Carla:从游戏引擎到算法测试沙盒

2026/8/15 7:45:15

1. 从游戏引擎到自动驾驶仿真:Carla的诞生与定位 如果你正在研究自动驾驶,或者对机器人仿真感兴趣,那么“Carla”这个名字你大概率不会陌生。它不是一个简单的游戏,也不是一个纯粹的物理引擎,而是一个专门为自动驾驶研…

正定矩阵四大核心性质:从定义到Cholesky分解的工程实践

正定矩阵四大核心性质:从定义到Cholesky分解的工程实践

2026/9/26 15:41:01

1. 项目概述:为什么我们要深挖正定矩阵的性质? 在工程计算、机器学习优化和物理系统分析里,我们经常会遇到一类特殊的矩阵,它们被称为“正定矩阵”。我第一次系统性地理解这个概念,是在研究一个结构力学仿真问题的时候…

Unity InputSystem跨平台输入管理:告别旧InputManager,实现一套代码支持PC、手机与手柄

Unity InputSystem跨平台输入管理:告别旧InputManager,实现一套代码支持PC、手机与手柄

2026/8/16 14:12:04

1. 项目概述:为什么我们要告别InputManager? 如果你还在用Unity自带的旧InputManager,吭哧吭哧地为PC键盘、手机触屏、Xbox手柄、PS手柄分别写几套输入处理代码,那今天这篇内容就是为你准备的。我经历过那个阶段,一个简…

CANN/GE ACL数据集缓冲区添加函数

CANN/GE ACL数据集缓冲区添加函数

2026/9/28 4:08:17

aclmdlAddDatasetBuffer 【免费下载链接】ge GE(Graph Engine)是面向昇腾的图编译器和执行器,提供了计算图优化、多流并行、内存复用和模型下沉等技术手段,加速模型执行效率,减少模型内存占用。 GE 提供对 PyTorch、Te…

用ffmpeg高效批量调整图片尺寸的实战指南

用ffmpeg高效批量调整图片尺寸的实战指南

2026/9/27 1:30:29

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

Transformers 音频特征提取工具库 audio_utils 全解析:从 Mel 刻度换算到对数 Mel 频谱

Transformers 音频特征提取工具库 audio_utils 全解析:从 Mel 刻度换算到对数 Mel 频谱

2026/9/28 2:15:29

Transformers 音频特征提取工具库 audio_utils 全解析:从 Mel 刻度换算到对数 Mel 频谱 【免费下载链接】transformers 🤗 Transformers: the model-definition framework for state-of-the-art machine learning models in text, vision, audio, and mu…

RustFS 多节点集群重启与滚动升级实战:Readiness、Quorum 与 Degraded 模式完全指南

RustFS 多节点集群重启与滚动升级实战:Readiness、Quorum 与 Degraded 模式完全指南

2026/9/28 3:14:54

RustFS 多节点集群重启与滚动升级实战:Readiness、Quorum 与 Degraded 模式完全指南 【免费下载链接】rustfs 🚀2.3x faster than MinIO for 4KB object payloads. RustFS is an open-source, S3-compatible high-performance object storage system sup…

Java Integer缓存揭秘:128陷阱原理、避坑与面试全解

Java Integer缓存揭秘:128陷阱原理、避坑与面试全解

2026/9/28 3:58:00

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

RustFS Scanner 数据用量发布权威性决策:配额准入如何获得可用的权威依据

RustFS Scanner 数据用量发布权威性决策:配额准入如何获得可用的权威依据

2026/9/28 3:47:14

RustFS Scanner 数据用量发布权威性决策:配额准入如何获得可用的权威依据 【免费下载链接】rustfs 🚀2.3x faster than MinIO for 4KB object payloads. RustFS is an open-source, S3-compatible high-performance object storage system supporting mi…

远程协作的工作台整理

远程协作的工作台整理

2026/9/26 14:29:04

远程协作的工作台整理远程协作的核心不是再加一个工具,而是让交接信息足够完整。异步任务要写明目标、输入位置、完成标准和需要决策的人。 工作台的最小配置 将日程、待办、代码和沟通入口收拢到少数固定位置;通知按紧急程度分层。工作台不需要模仿办公…

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

2026/9/28 5:05:21

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能分类:[AI/大模型]细分主题:AI 增强型 CI/CD 流水线自动化与 GitOps 实践:Agent 工作流、工具调用与任务拆解:从原型到生产的验收清单很多团队在尝试用大…

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

2026/9/26 23:35:16

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场分类:[工程技术]细分主题:Kubernetes 生产环境运维与排障实战:可复制的项目复盘模板与决策记录大部分团队的事故复盘报告,最后都变成了躺在 Confluence 或钉…