最近遇到个事,值得拿出来聊聊。
我们有个服务,功能特别简单:用户上传视频,后台截一帧生成封面。上线好几年,稳得像块砖头,监控常年全绿。
直到有一天,团队决定把 Java 从 8 升到 11,先灰度放了一部分流量。
升完没两天,运维告警就开始响:某几个容器隔一两天就被 OOM 干掉一次,自动重启。
第一反应肯定是查内存。诡异的地方来了——JVM 堆内存监控一切正常,堆用量平稳,GC 曲线健康,没有任何一条线告诉你"我要爆了"。可容器就是被系统以内存超限为由杀了。
堆没事,进程却因为内存被杀。
这两句话放一起,本身就是答案的一半。
接下来就是标准的排错流程,查了一圈全是死胡同:流量没变,代码没人碰过,堆调大调小也没用——因为问题根本不在堆里。
唯一确定的线索只有一条:问题是从切到 Java 11 那天开始的。一个没被改过的功能,仅仅因为运行环境换了个版本就开始漏内存,矛头只能指向那些看不见的地方。
后来终于找到了真凶。
缩略图功能底层用的是 JavaCV,封装了 OpenCV 和 FFmpeg。这类库有个特点:真正干活的是 C/C++ 写的原生代码,申请的内存是堆外内存,不归 JVM 堆管,堆监控自然看不见。
问题出在释放上。代码抓完视频帧、转完图,只调了 stop(),没调 release()。这俩不是一回事,stop() 停的是采集,release() 还的是原生内存。内存申请了,却没被显式还回去。
那为什么用了三年都没事?
![]()
这才是最精彩的地方。这个库当年靠 GC 兜底释放原生内存:给每个原生对象挂了个"清道夫",等 JVM 垃圾回收的时候,顺手把对应的堆外内存也释放掉。
Java 8 默认的 Parallel GC,年轻代回收非常频繁,每次回收都顺手触发这些清道夫,把忘记释放的堆外内存悄悄清掉了。泄漏一直存在,只是被高频 GC 盖住了。
换到 Java 11 默认的 G1 之后,情况反过来了。包装原生内存的 Java 对象本身极小,G1 不着急回收,它们能存活很久,清道夫迟迟不触发。堆外内存就一次上传、一次上传地往上堆,直到把整个容器的内存吃穿,被系统 OOM 掉。
所以那次升级本身没有错,它只是把盖子掀开了。
这个 bug 折腾了我们好几天,最后就带走三条:
第一,堆外内存泄漏,你的 JVM 监控是瞎的。所有盯着堆用量、GC 次数的仪表盘,对堆外内存一无所知。看到"进程被 OOM 杀了,但堆一切正常",第一时间就该往堆外想:native 库、DirectByteBuffer、线程栈、内存映射文件,全在监控视野之外。
第二,"一直没出事"不等于"没有 bug",可能只是有人替你扛着。这个泄漏三年没爆,只是旧 GC 的高频回收在替我们收尾。很多系统的稳定,都建立在某个你没意识到的隐性兜底上。哪天这个兜底被升级掉、被优化掉,账就一次性还给你。所以升级、重构、换框架之前,最该问一句:现在这套东西为什么能正常跑,靠的是不是一个我不知道的前提。
第三,native 资源,谁申请谁释放,写进 finally,别赌 GC。只要一个库的资源是堆外的——视频、图像、加解密、序列化——就把它当成文件句柄和数据库连接来对待,用完立刻显式关。指望 GC 帮你还堆外的债,迟早出事,而且是在你最想不到的时候,比如一次"无害"的升级。
工程里最危险的状态,不是报错了,而是看起来一切正常。报错至少会喊你,一个被兜底盖住的 bug,会安安静静待到某一天,等那个替你扛着的东西一撤,再一次性还给你。
你有没有遇到过那种"一直没事,一升级就炸"的诡异 bug?评论区聊聊,我看看谁的经历更离谱。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.