Metaspace 被 XSD 塞满递归调用,参数传了个 -1无界线程池 + 磁盘写满 = 服务雪崩200MB 文件直接读到内存堆区参数配错,重启了也白搭10 万行数据没分页——前端定时器背刺ThreadLocal 忘记释放——慢刀子割肉总结

做后端开发的,谁没跟 OOM 打过照面?说白了,内存溢出这玩意儿就像程序员的噩梦,半夜三点被电话叫醒,"服务挂了",一看日志,又是 OutOfMemoryError。

最近看到一个技术博主总结的 8 个真实 OOM 案例,从 2018 年到 2024 年,横跨 Java heap、Metaspace、StackOverflow、线程爆满、GC 超限等各种类型。讲道理,每一个案例背后都是真实的生产事故,我看了好几遍,觉得特别有分享价值。

第一个案例发生在 2018 年。一个考务管理系统允许用户上传 ZIP 文件,后台直接在内存里解压。有人上传了一个看似很小的 ZIP,解压出来却是一个几十 GB 的纯文本文件——经典的 ZIP 炸弹攻击。程序直接在内存中读完了整个解压内容,堆区直接撑爆。

你可能会说,释放到磁盘临时文件不就行了?但压缩炸弹还有递归嵌套和目录穿越等变种,单靠换存储介质不够。正确的做法是限制解压层级、检查文件大小和路径合法性。

打开网易新闻 查看精彩图片

第二个案例挺有意思。一个报文处理服务用了 JAXB 做 XML 编解码,上线后频繁 Metaspace OOM。查了半天发现是应用启动时一次性加载了所有 XSD 文件,把 256MB 的 Metaspace 撑爆了。

解决方案其实不复杂:服务拆分,按需加载。但说白了,这就是设计时没考虑到规模增长的后果。

第三个案例让我笑出声。一个营销系统用递归给机构发通知,参数范围约定是 0~255。结果第三方接口传了个 -1 过来——直接无限递归,喜提 StackOverflowError。

我觉得这事儿最大的教训不是递归该不该用,而是永远不要相信外部输入。加个参数校验,把递归改循环,一劳永逸。

第四个案例很典型。一个 Metrics 收集服务用了无界线程池,日志用了 logback 的 AsyncAppender,并且把 discardingThreshold 设为了 0(不丢日志)。一切看起来很完美,直到磁盘写满——日志写不进去,线程池阻塞,新线程疯狂创建,最终 unable to create new native thread。

这个案例我特别想强调的是:配置一定要给自己留退路。防丢日志固然重要,但系统挂了日志再好也没用。

第五个案例更简单粗暴。一个文件下载接口直接用把文件一次性读到 byte[] 里。有个控件安装包 200MB,几个用户同时下载,堆区直接爆了。

FileInputStream.read()

我个人觉得这属于基本功问题。文件流就该用缓冲读写,大文件就该走流式下载。把文件挂到 FTP 用外链,服务端压根不碰文件内容,这才是正确姿势。

第六个案例中,业务方发现接口服务重启后十分钟又挂了,而且每次 OOM 的堆栈都不一样,查了半天才找到原因:启动脚本里把 -Xmn(年轻代大小)设得跟 -Xmx 一样大。这意味着年轻代占满了整个堆,对象晋升到老年代时直接无处可去。

配 JVM 参数这事儿,真的每次都要仔细核对。我见过太多人因为复制粘贴搞错参数,最后酿成事故。

第七个案例是一个报表系统。前端定时器每隔 20 秒调用一次后端接口扫描异常数据,但没有做分页。运营上传了一个超大 Excel,数据全被标记异常,结果每次查询返回 10 万行记录,多个运营同时登录就把堆撑爆了。

这个案例告诉我们:分页不是可选项,是必选项。任何查询接口,只要数据量不可控,就必须限制返回行数。

打开网易新闻 查看精彩图片

最后一个案例更有隐蔽性。一个查询接口上线后稳定运行了三个月,然后突然变慢。堆栈分析发现大量 ThreadLocal 数据没有释放,内存泄漏慢慢撑满了堆。

解决方案大家都知道:用完 ThreadLocal 要调用 remove()。但真正到了编码的时候,这事儿太容易被忽略。建议团队在 code review 时把 ThreadLocal 作为必查项。

回顾这 8 个案例,你会发现大部分 OOM 不是因为代码有多复杂,反而是些不起眼的小问题:没做参数校验、没限制查询行数、忘记释放资源、JVM 参数配错。

说白了,写代码的时候多想一步"这里会不会出问题",生产事故就能少一半。你在项目里遇到过哪些让人头疼的 OOM?评论区聊聊吧。