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与率零对比

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

2026/10/14 12:41:43 阅读更多 →
Klick‘r深度解析:Android图像识别自动点击工具架构剖析

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

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

2026/10/12 15:40:32 阅读更多 →
Java SPI 被 Spring 惯坏后:ServiceLoader 源码里 3 个把人坑哭的实例化细节

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

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

2026/10/13 13:54:48 阅读更多 →

最新新闻

命令行待办事项工具开发实战:Python实现高效任务管理

命令行待办事项工具开发实战:Python实现高效任务管理

1. 为什么选择命令行待办事项应用 在图形界面应用泛滥的今天,命令行工具依然保持着独特的魅力。作为一个常年与终端打交道的开发者,我发现命令行待办事项应用有几个不可替代的优势: 首先,它的启动速度极快。不需要等待臃肿的GUI加…

2026/10/14 12:41:47 阅读更多 →
Linux磁盘阵列与阵列卡全指南:从选型到故障排查

Linux磁盘阵列与阵列卡全指南:从选型到故障排查

前阵子例行巡检,发现某台业务服务器上一块SAS硬盘的状态灯开始闪烁异常,登录系统一查,/var/log/messages里已经刷满了ATA error。当时第一反应是"阵列卡撑不撑得住、磁盘要不要马上换"。说实话,磁盘阵列和阵列卡这个东西…

2026/10/14 12:41:47 阅读更多 →
用Verilog在FPGA上实现数字钟:分频计数、按键校时与LCD显示全解析

用Verilog在FPGA上实现数字钟:分频计数、按键校时与LCD显示全解析

简介:基于Verilog HDL设计的多功能数字钟完整工程包,面向FPGA初学者、电子设计课程学生及硬件工程师,覆盖自动计时、手动校时、倒计时、闹钟及整点报时五大核心功能,并在Cyclone II平台上完成综合与LCD显示驱动,适合直…

2026/10/14 12:41:47 阅读更多 →
CAD入门界面全面拆解:从分区认知到实操避坑

CAD入门界面全面拆解:从分区认知到实操避坑

简介:面向刚接触CAD的初学者,这份PPT课件系统讲解CAD基本入门界面与核心操作,重点介绍标题栏、绘图工具栏、修改工具栏、命令行、状态栏等功能区域的作用,帮助读者快速建立对CAD工作界面的整体认知,并为后续绘制二维图…

2026/10/14 12:41:47 阅读更多 →
基环树与有源汇上下界最大流建模实战解析

基环树与有源汇上下界最大流建模实战解析

第一次看到这个题目标题的时候,我确实愣了一下。“基环树”加“有源汇有上下界最大流”,这两个词放在一起,就像那种“每个知识点我都学过,但合起来就不会写”的经典压轴题。这道题说白了就是给你一张特殊结构的图,要你…

2026/10/14 12:41:47 阅读更多 →
JVM对象晋升老年代的机制与优化实践

JVM对象晋升老年代的机制与优化实践

1. JVM内存模型与分代回收基础 在讨论对象进入老年代的时机之前,我们需要先理解JVM的内存结构设计。HotSpot虚拟机将堆内存划分为新生代(Young Generation)和老年代(Old Generation)两大区域,这种分代设计基…

2026/10/14 12:40:47 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/14 7:53:34 阅读更多 →