先说结论:一个我们三年都没发现的内存泄漏,被一次"应该毫无影响"的 Java 版本升级给炸了出来。而在这之前,它一直安安静静地待着------因为有个我们从没意识到的机制,在默默替我们收拾烂摊子。
这事的整个过程,比结论有意思。
症状:监控全绿,容器却在自杀
我们有个很普通的功能:用户上传视频,后台截一帧生成封面缩略图。上线好几年,稳得像块砖。
直到我们开始灰度升级 Java 版本------从 8 升到 11,先拿一部分流量试。升上去没多久,运维开始收到告警:某几个容器隔一两天就被 OOM 杀掉、自动重启一次。
第一反应是查内存。但诡异的地方来了:JVM 堆内存监控一切正常。 堆用量平稳,GC 曲线健康,没有任何一条线告诉你"我要爆了"。可容器就是被系统以"内存超限"为由干掉了。
堆没事,进程却因为内存被杀。这两句话放一起,本身就是答案的一半。
排错:不是流量,不是代码
顺着最容易想到的方向查了一圈,全是死胡同:
- 是升级后流量涨了?不是,流量没变。
- 是我们动了缩略图的代码?翻 git,那块几个月没人碰。
- 是新版本 JVM 更吃内存?堆调大调小都试了,没用------因为问题根本不在堆里。
唯一确定的线索只有一条:它是从我们切到 Java 11 那天开始的。 可我们又没改那块代码。一个没被改动的功能,仅仅因为运行环境换了个版本就开始漏内存------这就把矛头指向了那些"看不见"的地方。
真因:堆外内存 + 一个靠 GC 兜底的库
那个缩略图功能,底层用的是一个处理视频和图像的库(JavaCV,封装了 OpenCV 和 FFmpeg)。这类库有个特点:真正干活的是 C/C++ 写的原生代码,它申请的内存是堆外内存------不归 JVM 堆管,你的堆监控自然也看不见。
问题出在释放上。我们的代码抓完视频帧、转完图,只调了 stop(),没调 release()。也就是说,那些原生内存申请了,却没被显式还回去。
那为什么用了三年都没事?
这才是最精彩的地方:这个库当年是靠 GC 来兜底释放原生内存的。 它给每个原生对象挂了个"清道夫",等 JVM 垃圾回收时,顺手把对应的堆外内存也释放掉。
- Java 8 默认的 Parallel GC ,年轻代回收非常频繁。每次回收都顺手触发了这些清道夫,把我们忘记释放的堆外内存悄悄清掉了。泄漏一直存在,只是被高频 GC 盖住了。
- 换到 Java 11 默认的 G1 之后,情况反过来了。那些包装原生内存的 Java 对象本身极小,G1 不着急回收它们,它们能存活很久。清道夫迟迟不触发,堆外内存就一次上传、一次上传地往上堆------直到把整个容器的内存吃穿,被系统 OOM 掉。
所以那次"升级导致变慢、重启"的锅,根本不是 Java 11 的错。Java 11 只是撤掉了那个我们从没意识到的兜底,让一个潜伏了三年的 bug 现了原形。
修复:别指望 GC 替你还债
修法其实很朴素:谁申请,谁释放,别把还内存这件事赌在 GC 的心情上。
- 把抓帧用的原生图像对象提到方法级别追踪,在
finally里显式release(); - 把 grabber 的
stop()换成release(),做一次完整的资源销毁。
(顺一句:这里重复 release 是安全的------底层做了空指针保护、释放后会把地址清零,再被 GC 扫到也是空操作。)
改完,堆外内存曲线立刻平了,容器不再自杀。
我从这个 bug 里拿走的三条
1. 堆外内存泄漏,你的 JVM 监控是瞎的。
所有盯着堆用量、GC 次数的仪表盘,对堆外内存一无所知。当你看到"进程被 OOM 杀了,但堆一切正常",第一时间就该往堆外想:native 库、DirectByteBuffer、线程栈、内存映射文件。
2. "一直没出事"不等于"没有 bug",可能只是有人替你扛着。
这个泄漏三年没爆,不是因为它不存在,是因为旧 GC 的高频回收在替我们收尾。很多系统的"稳定",都建立在某个你没意识到的隐性兜底上。哪天这个兜底被换掉、被优化掉、被升级掉,账就一次性还给你。升级、重构、换框架之前,最该问的一句是:现在这套东西为什么能正常跑?靠的是不是一个我不知道的前提?
3. native 资源,谁申请谁释放,写进 finally,别赌 GC。
只要一个库的资源是堆外的(视频、图像、加解密、序列化......),就把它当成文件句柄和数据库连接来对待:用完立刻显式关,放进 finally。指望 GC 帮你还堆外的债,迟早出事------而且是在你最想不到的时候,比如一次"无害"的升级。
最后
我越来越觉得,工程里最危险的状态,不是"报错了",而是"看起来一切正常"。报错至少会喊你;而一个被兜底盖住的 bug、一个监控看不见的泄漏,会安安静静地待到某一天,等那个替你扛着的东西一撤,再一次性还给你。
这也是为什么,每次系统"莫名其妙变好了"或者"莫名其妙没事了",我都会多问一句:到底是真好了,还是有什么东西,在替我兜着。