那个内存泄漏一直都在,只是旧 GC 悄悄替我兜了三年 - 编程一生

那个内存泄漏一直都在,只是旧 GC 悄悄替我兜了三年 - 编程一生

💡 原文中文,约1800字,阅读约需5分钟。
📝

内容提要

该文讲述了一个Java应用从8升级到11后,因堆外内存泄漏导致容器OOM的问题。根因是JavaCV库未显式释放原生内存,依赖GC兜底,而旧GC高频回收掩盖了泄漏,新G1回收不频繁导致内存累积。作者总结教训:堆外泄漏监控难察觉,隐性兜底可能掩盖问题,native资源应显式释放。

🔎

延伸解读

堆外内存泄漏为何难发现

JVM堆监控只覆盖堆内内存,对堆外内存(如native库、DirectByteBuffer、线程栈、内存映射文件)完全无感。当容器因内存超限被杀而堆指标正常时,应优先排查堆外内存。JavaCV等库通过JNI调用C/C++代码,其申请的内存不在堆内,因此常规监控无法察觉泄漏。

GC行为差异如何掩盖问题

Java 8默认的Parallel GC年轻代回收频繁,每次回收都会触发JavaCV的Cleaner,顺带释放堆外内存,掩盖了泄漏。而Java 11默认的G1 GC对存活对象回收不频繁,导致Cleaner迟迟不触发,堆外内存持续累积直至OOM。这解释了为何升级后问题才暴露。

升级前的隐性依赖检查

系统稳定运行可能依赖某些隐性兜底机制,如GC的清理行为。升级、重构或换框架前,应思考当前系统为何正常,是否存在未意识到的前提。本例中,旧GC的高频回收掩盖了泄漏,升级后兜底消失,问题才显现。因此,变更前应评估对隐性依赖的影响。

native资源管理的正确姿势

对于堆外资源(视频、图像、加解密、序列化等),应像对待文件句柄和数据库连接一样,用完立即显式释放,并放入finally块。不要依赖GC兜底,因为GC的回收时机不可控,且可能因版本变化而改变。显式释放能避免泄漏累积,防止在意外时刻爆发。

Q&A

Java 8 升级到 Java 11 后,为什么会出现容器 OOM 但 JVM 堆内存监控正常的情况?

因为问题出在堆外内存。JavaCV 库申请的原生内存属于堆外内存,不归 JVM 堆管理,所以堆监控看不到。升级到 Java 11 后,默认 GC 从 Parallel 变为 G1,G1 回收不频繁,导致堆外内存累积,最终容器被系统 OOM 杀掉。

JavaCV 库的内存泄漏是如何被旧 GC 掩盖的?

JavaCV 库依赖 GC 兜底释放原生内存,它给每个原生对象挂了个清道夫,等 JVM 垃圾回收时释放堆外内存。Java 8 默认的 Parallel GC 年轻代回收非常频繁,每次回收都触发清道夫,把未显式释放的堆外内存悄悄清掉,所以泄漏一直存在但没爆发。

为什么 G1 垃圾回收器会导致堆外内存泄漏问题暴露?

因为 G1 回收不频繁,那些包装原生内存的 Java 对象本身极小,G1 不着急回收它们,它们能存活很久,导致清道夫迟迟不触发,堆外内存就一次上传、一次上传地累积,直到把容器内存吃穿。

排查堆外内存泄漏时,应该关注哪些方面?

当看到进程被 OOM 杀了但堆一切正常时,应该往堆外想,包括 native 库、DirectByteBuffer、线程栈、内存映射文件等,这些都在堆监控的视野之外。

如何正确释放 JavaCV 等 native 库的资源?

对于 native 资源,应该谁申请谁释放,写进 finally 块,用完立刻显式关闭,不要依赖 GC 兜底。例如 JavaCV 中,stop() 只是停止采集,release() 才是释放原生内存,两者都要调用。

从这次内存泄漏事件中,作者总结了哪些教训?

作者总结了三条教训:1. 堆外内存泄漏,JVM 监控是瞎的,要关注堆外内存;2. “一直没出事”不等于“没有 bug”,可能只是有人替你扛着,升级或重构前要问清楚系统正常运行的隐性前提;3. native 资源要显式释放,写进 finally,别赌 GC。

🏷️

标签

➡️

继续阅读