news 2026/8/13 8:07:57

Java开发日志:一次线上内存溢出问题的排查与反思

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java开发日志:一次线上内存溢出问题的排查与反思

凌晨一点四十七分,手机在床头柜上震动,像一只被踩住翅膀的知了。我摸到屏幕,钉钉群里的红色告警正一闪一闪:生产A应用内存使用率超过95%,服务已OOM并自动重启。这是当天第三次了。前两次,我都以为只是流量峰值压垮了堆,看着监控曲线跌回低点便转身睡去。但这一次,我坐到了电脑前,决定把问题彻底挖出来。

同一批机器,白天一切正常,一到夜间定时任务跑起来就出问题。我打开监控面板,调出最近24小时的内存曲线。内存从凌晨一点开始缓慢爬坡,持续十分钟后突然断层——那是JVM崩掉、k8s拉起新Pod的信号。重启后内存回到低点,但新Pod像踩了油门一样继续往上爬。故障日志里除了OutOfMemoryError,还有一些任务超时的堆栈,堆栈都指向同一个批处理入口。

我先用jmap -histo:live快速扫了一遍存活对象的分布。结果并不意外,排在前面的是byte[],但数量非常多。真正让我皱眉的是,即使我在GC之后立刻执行histo,byte[]的总量也没有明显下降。这说明这不只是“一次性大对象”的问题,而是一些byte[]一直被别的东西引用着,根本收不掉。

生产环境最可怕的不是报错,而是重启后一切如常。只要你没抓到时机的堆转储,根本不知道是谁吃掉了内存。我决定等下一次内存爬到80%时,直接抓全量堆转储。

GC日志里的老年代,像一条溺水者的手臂

等待期间我翻了GC日志。Full GC几乎每隔一分钟就触发一次,每次耗时两三秒。老年代回收前后的占用率差不了多少,每次只降几个点,又立刻涨回去。这是典型的“持续泄漏”特征——堆里存在一批可以被GC标记、但永远无人回收的对象。更奇怪的是,这种上涨不是从0开始的,而是从凌晨一点批处理启动后才出现。白天同样有大量请求,却没有增长。

内存溢出是会在不同实例间“轮岗”的,谁能触发,取决于它当时手里攒了多少垃圾。我横向对比了所有节点,发现每个Pod在批处理时段都有内存抬升,只是幅度不同。有些Pod因为白天流量高,内存本来就不低,反而没有触发阈值;偏偏那几个空闲节点的内存底数低,抬升之后就越过了90%。

把堆摊开,顺着强引用往上游走

凌晨两点半,监控告警再次响起。我立刻让运维执行jmap -dump:live,format=b,抓下一份三四个G的堆转储。下载到本地后,我用MAT打开,点开Dominator Tree,按保留堆排序。排在第一的是一个byte[][]数组,大小接近300MB。它太惹眼了,但我克制住了“就是它”的冲动,因为Java里byte[][]太常见了,可能是网络缓冲、文件缓存,也可能是业务数据。必须找到是谁引用了它。

在MAT里勾掉弱引用和软引用,只保留强引用路径,顺着这个byte[]往上走。路径很快收窄到一个类:UserContextHolder。这是一个ThreadLocal的静态引用,而ThreadLocal的value里,挂着一个包含了完整用户资料、权限列表的UserProfile对象。这个对象里有一个字段是byte[],正好指向了那300MB的数组。

把堆摊开看,你会发现所有疯狂的对象背后,都站着一个看似无辜的持有者。UserContextHolder的代码很简单:一个静态ThreadLocal,提供set/get/clear三个方法。看起来人畜无害。但搜索代码库时,我发现在拦截器里只有set和get,没有clear。请求结束时,谁都没有调用clear。

ThreadLocal留下的“遗产”

这里有一个关键认知:拦截器在每个请求进来时调用UserContextHolder.set(profile),下一次请求又会用新的profile覆盖旧的引用。只要请求是并发的,每个线程各自维护自己的ThreadLocal,旧值会被新值替换,变成垃圾后正常被GC回收,内存不会持续增长。但批处理任务打破了这种平衡。

夜间批处理从同一个线程池里拿线程执行任务。任务内部调用了一个老接口,这个老接口在实现时顺手把当前用户信息set进了UserContextHolder。批处理循环每次处理一个用户,就set一次,从不remove。关键是,批处理的任务量与线程数不匹配——线程池里只有十几个线程,而循环可能跑几百次。同一个线程执行多次循环,ThreadLocal里的旧值永远被下一个用户覆盖,但问题来了:覆盖前,旧值是强引用,不可回收;覆盖后,旧值虽然成为垃圾,可如果新值里带着一个巨大的byte[],内存就会迅速膨胀。

线程池复用线程,也复用了上一位请求留下的“遗产”。这比在普通请求里泄漏更隐蔽,因为你永远不知道上一次在同一个线程里跑任务的是谁。

为什么偏偏是在白天不爆呢?因为白天的请求量足够大,线程被高频复用,每个线程上的ThreadLocal几乎每毫秒都被新请求覆盖,旧对象很快变成垃圾,GC能及时收走。而夜间批处理并发低,很多线程长时间闲置,但ThreadLocal的值并不会因为线程闲置而消失。更糟的是,批处理里set进去的用户对象,画像字段为了省一次外部RPC,被序列化成base64再转成byte[],直接变成了接近300MB的庞然大物。一个线程跑几十次循环,内存里就有几十个版本的引用,虽然最后只留着最后一个,但每一次替换都意味着几百MB的垃圾需要GC去回收。GC速度赶不上生产速度,老年代就满了。

任何“用完就不管”的代码,都是在给下一场故障埋单。尤其当你把“用完”的边界定义在请求生命周期上,却忘了线程的寿命比请求长得多。

修复,只差一个finally

定位到这一步,修复方案已经清晰。第一件事,在拦截器的finally块里加上UserContextHolder.remove(),确保每个请求无论成功失败,都不留下任何线程局部残留。第二件事,在批处理的任务入口处做一个防御性清理:先clear(),再执行业务,执行完毕后再clear()一次。这两行代码,成本极低,却直接消掉了整条泄漏路径。

还有一个细节:那个老接口为什么会在内部set?因为它是历史接口,为了让上层代码能够访问当前用户上下文,它在实现里自行做了set。但兼容不能以泄漏为代价。我给这个接口内部也套上了try/finally,让set显式配对。三处改动,部署后观察了三个夜间周期,内存曲线平稳下来,老年代没有持续抬升,Full GC的频率从每分钟一次降到一天个位数。

我们没有给这条ThreadLocal套上finally的保险绳。任何人都知道finally是干什么的,但真正写代码时,总会有人忘记:我put进去的,凭什么还要我来remove?这个“凭什么”,就是灾难的起点。

这口锅到底谁来背

排查到这里,很多公司会选择写一篇复盘,把责任归给“没加remove的人”。但我不这么认为。那个写代码的人只是沿着现有的便利路径走了一步:看到别人都在用ThreadLocal传上下文,看到这个老接口有set方法,他自然会认为“框架会在合适的时候清理”。真正的问题在于,我们从来没有在代码库层面定义过ThreadLocal对象的生命周期规则。它是不是必须和请求同生共死?是不是线程池中的线程可以被视为安全的上下文载体?这些规则没有写下来,于是每个人都在做局部最优解,最后拼出一个全局失控。

内存泄漏很少是一个瞬间的错误,往往是一连串约定俗成的简化。为了省一次RPC而缓存大对象,为了方便取用而使用ThreadLocal,因为相信“下次会被覆盖”而不清理,每一个单独看都说得通,合在一起就是定时炸弹。

另外,我们的监控体系也有短板。如果当时有按线程维度展示ThreadLocal引用大小的能力,或者有自动堆转储的规则,不需要人到半夜爬起来抓dump,故障时间可以缩短三分之二。排查内存问题,最怕的就是盯着堆的大小。堆只是结果,真相在引用链里。定位内存问题,最忌讳只看堆的大小,要追的是谁还握着那把“不放手”的强引用。掌握引用链分析的能力,比记住任何JVM参数都重要。

反思:三个用事故换来的约定

这次事故之后,团队做了三件事。第一,给监控加了维度:除了内存使用率,还要导出老年代大小和GC耗时,并设定“老年代在非高峰期持续增长”的告警规则,而不是等到OOM才通知。第二,调整了压测场景,测试环境必须模拟生产环境的线程池复用和定时任务调度,不能只跑功能用例。第三,在代码规范里明确了一条死规矩:任何用到ThreadLocal的地方,必须在同一个逻辑单元的finally里调用remove,不允许有例外。代码评审时,这条作为必查项。

也许有人会觉得这是小题大做,一条ThreadLocal泄漏而已。但我知道,真正昂贵的不是事故本身,而是那些没有被写进复盘里的细节。比如那个base64转byte[]的“优化”,如果没有把这根线捋出来,下一次换一种写法,可能又是一场OOM。再比如,批处理任务与请求任务共享同一个线程池,这本来是为了提高利用率,但同时也让请求上下文泄漏到了批处理线程里。这些细节,事故后如果不沉淀,三个月后再犯一遍是迟早的事。

真正昂贵的不是事故本身,而是那些没有被写进复盘里的细节。写这篇文章时,我还会想起那个凌晨:为什么第一次OOM时我没有立刻去抓dump?因为我默认它像以前一样,重启就好了。事实上,每一次自动恢复,都在掩盖一个正在腐坏的结构。

修复上线后的某天下午,我打开那个老接口的代码,看到finally里那行UserContextHolder.clear(),忽然觉得安心了不少。这行代码不是锦上添花,而是底线。Java的GC帮我们解决了大部分内存回收,但它也关掉了一扇门,开了一扇窗:我们不再关心内存,于是更容易在不知不觉中留下强引用。对象是谁创建的,谁负责销毁——这句老话,至今仍然有效。

内存是有限的,线程是复用的,生命周期是需要管理的。别再相信“以后会清理”这种鬼话。用代码去兜底,而不是用人脑去记。如果你也遇到线上反复OOM,记住第一件事:停掉自动重启,把现场保住。因为每一次重启,都是给下一次故障留的引子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/13 8:07:27

Meta开源大模型本地部署实战:从Llama 3选型到API工程化

这次我们来看一个 Meta 开放模型的项目。这不是一个具体的工具或软件,而是一个关于 Meta 公司开放其人工智能模型,推动“个人超级智能”普及的趋势性话题。对于开发者、研究者和技术爱好者来说,这意味着我们有机会在本地或云端部署、微调和集…

作者头像 李华
网站建设 2026/8/13 8:07:13

机器学习入门:核心概念与实践指南

1. 机器学习初探:从零开始的认知之旅第一次接触"机器学习"这个概念时,我正坐在大学计算机实验室里,盯着屏幕上那些看似毫无规律的代码发呆。那是在2012年,AlphaGo还没战胜李世石,但机器学习已经悄悄改变着我…

作者头像 李华
网站建设 2026/8/13 8:06:22

Kratos Blades:用Go语言和微服务框架工程化构建AI Agent

1. 从微服务到AI Agent:Kratos与Blades的“跨界”登场 最近在AI Agent的圈子里,一个新名字开始被频繁提及:Kratos。如果你是一个Go语言的开发者,或者对微服务架构有所涉猎,听到这个名字的第一反应可能是:“…

作者头像 李华
网站建设 2026/8/13 8:03:19

Presto/Trino查询Hive数据仓库时的谓词下推与列裁剪优化深度实践

1. 引言:大数据查询的瓶颈与破局之道 在数据驱动决策的时代,企业的数据仓库规模正以指数级增长。当Hive数据仓库中的表达到PB级别,分区数超过数万个,单表列数超过数百列时,查询性能往往成为数据工程师和分析师的头疼问题。你是否遇到过这样的场景:一个简单的SELECT COUN…

作者头像 李华
网站建设 2026/8/13 8:01:43

TqSdk 新手最常踩哪些坑?按现象定位问题的排查顺序

TqSdk 新手遇到“行情不动、信号重复、订单没成交、持仓不对、程序关不掉”时,最容易同时修改很多代码。更有效的排查顺序是先确认环境与事件循环,再检查数据变化和规则触发,最后核对订单、成交与持仓。一次只定位一层,现象才不会…

作者头像 李华