ThreadLocal 不是银弹:一次内存泄漏把老年代打满的复盘

发布时间:2026/8/3 6:16:54

ThreadLocal 不是银弹:一次内存泄漏把老年代打满的复盘
引子为什么人人都用 ThreadLocalWeb 开发里ThreadLocal几乎无处不在Spring 的RequestContextHolder、MyBatis 的PageHelper、日志链路追踪的 traceId底层都靠它把只属于当前请求的数据绑在线程上免去一层层传参。它本身不难一行set、一行get但一旦用在线程池环境坑就来了。我们线上一次老年代被打满、Full GC 每 5 秒一次的事故根因就是ThreadLocal没清理。问题线程池 ThreadLocal 泄漏温床关键事实Tomcat、各种 RPC 框架处理请求用的都是线程池线程是复用的不会被销毁。而ThreadLocal的值存在线程自己的ThreadLocalMap里Thread不死这个 Map 就不释放。如果你在请求处理里set了一个大对象处理完没remove那么这个对象会一直挂在线程上被下一个请求、下下个请求反复复用和累积直到老年代撑爆。我们当时的代码长这样private static final ThreadLocalUserContext ctx new ThreadLocal(); public void handle(Request req) { ctx.set(loadUserContext(req)); // 1. set 了大对象 try { bizService.process(); // 2. 业务逻辑内部各处 get(ctx) } finally { // 3. 这里忘了 remove —— 事故起点 } }逐行看问题第 2 行set把UserContext里面塞了我们系统的用户权限树平均 200KB挂到当前线程。第 6 行finally里我们只写了日志没写remove。在线程池下这个线程处理完返回池子200KB 的UserContext跟着线程一起存活。线程池 200 个线程每个都残留一份就是 40MB而且因为对象一直被强引用永远到不了回收Full GC 也清不掉——这正是我们老年代暴涨的直接原因。源码/原理为什么泄漏以及为什么 JDK 已经尽力了ThreadLocal的存储不是存在ThreadLocal对象里而是存在Thread.threadLocals这个ThreadLocalMap里key 是ThreadLocal本身弱引用value 是你 set 的对象强引用。// ThreadLocalMap 的 Entry 定义简化 static class Entry extends WeakReferenceThreadLocal? { Object value; // 1. value 是强引用 Entry(ThreadLocal? k, Object v) { super(k); // 2. key 是弱引用指向 ThreadLocal value v; } }逐行解释这个设计的微妙之处第 2 行 key 用弱引用意思是当你代码里的ThreadLocal变量比如上面的ctx静态字段因为类卸载或置 null 而失去强引用时下一次 GC 会把 key 清掉。这是 JDK 为了减少泄漏做的努力。但第 1 行的value是强引用。key 被 GC 清成 null 后value仍然挂在Entry上而Entry挂在ThreadLocalMap上ThreadLocalMap挂在Thread上。Thread不死这条强引用链就断不了——value 泄漏了。所以弱引用只解决了key 那一半的问题value 这边的泄漏 JDK 帮不了你只能靠你自己remove。set/get时 JDK 会顺手做一次过期 entry 清理expungeStaleEntry但这只是擦边球——它只在你再次访问同一个 ThreadLocal 且恰好遇到 stale slot时才清理不能替代显式remove。实战事故当晚我们怎么定位和止血定位过程其实很简单粗暴用jmap -histo:live pid抓堆排第一的是一个UserContext类实例数 200和线程池大小吻合。再jstack一看这些UserContext的引用链都指向Thread.threadLocals。根因锁定没remove。止血用三步public void handle(Request req) { ctx.set(loadUserContext(req)); try { bizService.process(); } catch (Exception e) { log.error(process failed, e); throw e; } finally { ctx.remove(); // 1. 唯一的正解用完即清 } }第 8 行ctx.remove()会在ThreadLocalMap里删除这条 entry断开 value 的强引用下次 GC 就能回收。这是我们当晚加的补丁。但这里还有个细节如果bizService.process()抛异常且外层没捕获会不会跳过finally不会——finally无论如何都会执行所以把remove放finally是对的。真正的风险是有人把remove放在try的正常分支里而忘了异常分支那样异常时还是漏。第二步我们顺手把静态ThreadLocal改成每次请求用完后必然 remove并对所有ThreadLocal使用点做了一次全局 grep发现还有两处PageHelper风格的手动分页没清理一并修了。第三步加监控老年代使用率超过 80% 就告警避免下次再等到 Full GC 风暴才发现。对比几种请求级上下文方案的取舍方案是否泄漏风险适用静态 ThreadLocal 手动 remove忘了 remove 就泄漏简单请求上下文InheritableThreadLocal线程池下值串号父子线程传参慎用TransmittableThreadLocal (阿里)需引入依赖解决线程池传递线程池 上下文传递方法参数显式传递无泄漏但代码啰嗦调用链短的场景我的判断纯请求上下文、且你能保证finally里remove用原生ThreadLocal足够。但只要你的任务会被线程池跨线程传递比如把上下文塞进异步任务丢给别的线程池跑原生ThreadLocal直接失效——值传不过去这时候得上阿里的TransmittableThreadLocal。总结与我的取舍ThreadLocal的价值在于隐式传参但代价是隐式持有。它在线程池时代最大的敌人就是遗忘的remove。我现在给自己定的铁律任何ThreadLocal.set必须配对try/finally { remove() }宁可多写两行也不留泄漏口。我不建议在业务代码里大量自建ThreadLocal存大对象——大对象一旦泄漏危害远超传参那点便利。能显式传参的尽量显式传非用不可的把存的东西压到最小并确保remove。复盘数据那次事故的具体数字把当晚的监控数字摊开更有说服力出问题的服务是 4 核 8G 容器Tomcat 默认线程池 200 线程每个请求的UserContext平均 200KB里面是用户权限树 菜单树。泄漏后老年代在约 20 分钟内从 1.2GB 涨到 7.5GBFull GC 从几乎不发生变成每 5 秒一次单次停顿 600ms~1.2s接口 P99 从 80ms 飙到 3s 以上。加完remove并重启后老年代稳定在 1.5GB 上下Full GC 归零P99 回到 90ms。事后我们算了一笔账200 线程 × 200KB 40MB 常驻看着不多但它进的是老年代且永不被回收叠加每次请求新建的临时对象老年代上涨速度远超回收速度。这也是为什么jmap -histo:live一眼就能看出——UserContext实例数正好等于线程池大小是个很明显的指纹。补充一个版本相关的点ThreadLocal从 Java 1.2 就有InheritableThreadLocal同期但当任务要跨线程池传递上下文时原生方案会失效我们后来在异步链路里换成了阿里的TransmittableThreadLocal当时用的 2.12.1 版本它通过在任务提交时快照上下文、执行前回填来解决线程池传递问题。如果你也在做 traceId 透传这个依赖值得记一笔。还有一类比忘了写 remove更隐蔽的坑提前 return 漏清理。我们 review 时发现有一处if (whiteList) return;提前返回恰好跳过了后面的remove。这类问题静态扫不出来最好的办法是把set/remove收口到模板里让调用方改不了// 用模板方法把 set/remove 锁死调用方无法漏写 remove public static T, R R with(ThreadLocalT holder, T ctx, FunctionT, R fn) { holder.set(ctx); try { return fn.apply(ctx); // 1. 业务逻辑任意 return/throw 都走 finally } finally { holder.remove(); // 2. 清理固化在模板里调用方改不了 } }这段封装的关键在第 2 行remove被固化在finally中业务方在里面怎么return、throw都会执行从结构上消灭了提前返回漏清理。我们后来把所有ThreadLocal入口都收口到这类模板泄漏类故障基本绝迹。这也顺带回答了前面那个疑问只要对象还被Thread上的ThreadLocalMap强引用着哪怕只有 10MBYoung GC 也回收不了它因为它根本不在年轻代的新建链路里而是挂在久经不衰的线程上。思考题InheritableThreadLocal在线程池下为什么会出现值串号A 请求的 traceId 出现在 B 请求的日志里如果你用原生ThreadLocal存了一个 10MB 的缓存对象且忘了remove它会被 Young GC 回收吗欢迎讨论。

相关新闻

学生党如何低成本选择AI工具:去AIGC与率零对比

学生党如何低成本选择AI工具:去AIGC与率零对比

2026/8/3 6:16:54

1. 项目概述:学生党如何低成本选择AI工具去年我在准备毕业论文时,曾经连续两周每天花5小时对比各种AI工具的价格和性能。作为每月生活费只有1500元的穷学生,我深刻理解在预算有限的情况下选择AI工具的纠结。现在市面上主流的"去AIGC&quo…

Klick‘r深度解析:Android图像识别自动点击工具架构剖析

Klick‘r深度解析:Android图像识别自动点击工具架构剖析

2026/8/3 6:16:54

Klickr深度解析:Android图像识别自动点击工具架构剖析 【免费下载链接】Smart-AutoClicker An open-source auto clicker on images for Android 项目地址: https://gitcode.com/gh_mirrors/smar/Smart-AutoClicker Klickr是一款基于图像识别技术的Android自…

Java SPI 被 Spring 惯坏后:ServiceLoader 源码里 3 个把人坑哭的实例化细节

Java SPI 被 Spring 惯坏后:ServiceLoader 源码里 3 个把人坑哭的实例化细节

2026/8/3 6:16:54

引子:为什么一堆框架都爱用 SPIJava 标准库里有个不起眼的接口加载机制:Service Provider Interface。你在 classpath 下放一个 META-INF/services/全限定接口名 的文件,里面写几个实现类的全限定名,运行时调用 ServiceLoader.loa…

湘美书院谈AI写作,站在金字塔尖的人,还需要机器协作吗?

湘美书院谈AI写作,站在金字塔尖的人,还需要机器协作吗?

2026/8/3 7:06:57

金字塔尖的叩问:AI时代的文心与匠魂深夜的书房里,我对着电脑屏幕上闪烁的光标发呆。屏幕上是刚用AI生成的一篇散文,语言华丽,结构工整,却像一具没有灵魂的躯壳,冰冷得令人窒息。我想起了那些站在文学金字塔…

Java全栈面试题库2026版:从JVM调优到分布式架构

Java全栈面试题库2026版:从JVM调优到分布式架构

2026/8/3 7:06:57

1. 项目概述:Java全栈面试题库2026版这个题库的定位非常明确——为Java开发者提供从基础到高级的完整面试准备方案。不同于市面上零散的面试题集合,它按照技术层级和核心模块进行了系统化分类,覆盖了企业级开发中最关键的六大技术领域。我整理…

WGCNA加权基因共表达网络分析:从原理到R语言实战全流程

WGCNA加权基因共表达网络分析:从原理到R语言实战全流程

2026/8/3 7:06:57

在生物信息学领域,处理高通量基因表达数据时,我们常常面临一个核心挑战:如何从成千上万个基因中,识别出具有生物学意义的、协同变化的基因模块,并揭示这些模块与特定性状或疾病状态之间的关联。传统的差异表达分析虽然…

临床预测模型快速入门:从零构建逻辑回归与随机森林模型

临床预测模型快速入门:从零构建逻辑回归与随机森林模型

2026/8/3 7:06:57

这次我们来看一个关于临床预测模型自学的项目。标题“自学三天,学会了临床预测模型!我就是最棒的小羊”听起来像是一个学习者的经验分享,但背后指向的是一个非常具体且实用的技术领域:如何快速入门并实践临床预测模型的构建。对于…

从零搭建AI对话机器人:ChatGPT API接入、环境配置与排错全指南

从零搭建AI对话机器人:ChatGPT API接入、环境配置与排错全指南

2026/8/3 7:06:57

大家好,我是峰哥。最近在后台和评论区,经常看到有朋友留言,说“峰哥,你讲的那些AI工具、ChatGPT,我跟着操作了,但总是出问题,是不是我太笨了?” 或者 “一看就会,一用就废…

occt中的History机制

occt中的History机制

2026/8/3 6:56:56

1. 前言拓扑追踪机制,一般会有两个层次的概念:一种是某个操作前后新旧拓扑的映射,另一种是标志的持久化,一般会称之为“拓扑命名机制”,这种要求模型存盘后还能恢复。occt没有持久化的id或者Handle的概念,一…

ncmdumpGUI:一键解锁网易云音乐ncm文件的终极解决方案

ncmdumpGUI:一键解锁网易云音乐ncm文件的终极解决方案

2026/8/3 4:49:52

ncmdumpGUI:一键解锁网易云音乐ncm文件的终极解决方案 【免费下载链接】ncmdumpGUI C#版本网易云音乐ncm文件格式转换,Windows图形界面版本 项目地址: https://gitcode.com/gh_mirrors/nc/ncmdumpGUI 你是否曾经从网易云音乐下载了心爱的歌曲&am…

分布式配置中心选型实战:Nacos与Consul在创业场景下的对比

分布式配置中心选型实战:Nacos与Consul在创业场景下的对比

2026/8/2 0:04:43

分布式配置中心选型实战:Nacos与Consul在创业场景下的对比工程导读:本文深入讨论 分布式配置中心选型实战:Nacos与Consul在创业场景下的对比 在生产工程实践中的核心落地方案。基于 分布式架构与微服务设计 视角,剖析实际痛点、架…

MoneyPrinterPlus实战指南:AI视频批量生成与自动化发布完整解决方案

MoneyPrinterPlus实战指南:AI视频批量生成与自动化发布完整解决方案

2026/8/2 0:04:43

MoneyPrinterPlus实战指南:AI视频批量生成与自动化发布完整解决方案 【免费下载链接】MoneyPrinterPlus AI一键批量生成各类短视频,自动批量混剪短视频,自动把视频发布到抖音,快手,小红书,视频号上,赚钱从来没有这么容易过! 支持本地语音模型chatTTS,fasterwhisper,…

从提示词小白到AI内容架构师(20年技术老兵的6阶能力跃迁图谱,仅剩最后87个免费解读名额)

从提示词小白到AI内容架构师(20年技术老兵的6阶能力跃迁图谱,仅剩最后87个免费解读名额)

2026/8/3 0:06:20

更多请点击: https://codechina.net 第一章:AI写作能力跃迁的认知革命 过去五年,AI写作已从“模板填充”迈入“语义共建”阶段——模型不再仅复述训练数据中的句式,而是基于跨文档推理、意图锚定与风格自适应,动态构建…

AU-48八米拾音的信噪比衰减与降噪门限耦合分析

AU-48八米拾音的信噪比衰减与降噪门限耦合分析

2026/8/3 0:06:20

一、"拾音 8 米"这个指标该怎么读AU-48 的规格里,麦克风拾取范围写的是 10cm-800cm,配合 T1/T2 参数切换可选四档:中距离 0.5-2m、近距离 0.1-0.2m、远距离 0.5-5m、超远距离 0.5-8m。"能拾音 8 米"这句话本身没错&#…

LangChain 从 Demo 到团队落地,真正卡壳的是哪一步?

LangChain 从 Demo 到团队落地,真正卡壳的是哪一步?

2026/8/3 0:06:20

聊《LangChain并不难,难的是知道什么时候不该用》之前,先说一句实在的:别急着背概念,先看它在真实项目里到底解决什么问题。 摘要 摘要:很多人学 LangChain 都是从调个 API 开始,跑通一个 Demo 觉得挺简单…

摆脱论文困扰!盘点2026年全网爆红的的AI论文写作工具

摆脱论文困扰!盘点2026年全网爆红的的AI论文写作工具

2026/8/2 17:06:42

一天写完毕业论文在2026年已不再是天方夜谭。2026年最炸裂、实测能大幅提速的AI论文写作工具,覆盖选题构思、文献整理、内容生成、格式排版等核心场景,真正帮你高效搞定论文难题。 一、全流程王者:一站式搞定论文全链路(一天定稿首…

导师推荐!2026最新AI论文工具测评与实用推荐

导师推荐!2026最新AI论文工具测评与实用推荐

2026/8/2 5:08:03

2026年真正好用的AI论文工具,核心看生成的论文质量、低AI味、格式正确、学术适配四大指标。综合实测,千笔AI、ThouPen、豆包、DeepSeek、Grammarly 是当前最值得推荐的梯队,覆盖从免费到付费、从中文到英文、从文科到理工的全场景需求。 一、…

告别游戏崩溃:XCOM 2模组管理器的智能革命

告别游戏崩溃:XCOM 2模组管理器的智能革命

2026/8/3 2:41:27

告别游戏崩溃:XCOM 2模组管理器的智能革命 【免费下载链接】xcom2-launcher The Alternative Mod Launcher (AML) is a replacement for the default game launchers from XCOM 2 and XCOM Chimera Squad. 项目地址: https://gitcode.com/gh_mirrors/xc/xcom2-lau…