内容提要
一个存在三年的内存泄漏问题,在Java 8升级到11后暴露。原因是JavaCV库的堆外内存未显式释放,旧GC高频回收掩盖了泄漏,新G1 GC不频繁触发清理导致OOM。修复方法是显式调用release()。教训:堆外内存监控盲区,隐性兜底可能掩盖bug,native资源需显式释放。
延伸解读
堆外内存:JVM 监控的盲区
JVM 堆内存监控正常,但容器仍因内存超限被杀,这通常指向堆外内存问题。JavaCV 等库通过 native 代码申请的内存不在堆内,常规监控无法察觉。排查时,若堆指标健康而进程 OOM,应优先考虑堆外内存,如 DirectByteBuffer、native 库、线程栈等。
GC 行为差异如何掩盖泄漏
Java 8 的 Parallel GC 年轻代回收频繁,能及时触发 JavaCV 的 Cleaner 释放堆外内存,掩盖了未调用 release() 的泄漏。而 Java 11 默认的 G1 GC 对存活对象回收不频繁,导致 Cleaner 迟迟不执行,堆外内存持续累积直至 OOM。升级 JDK 可能改变 GC 行为,暴露潜在问题。
升级前应审视隐性依赖
系统长期稳定可能依赖某些隐性机制,如旧 GC 的高频回收。升级、重构或换框架前,应思考当前系统为何正常,是否存在未被察觉的前提条件。本例中,升级 Java 版本撤掉了兜底,使三年潜伏的 bug 暴露。对关键依赖,应主动检查资源释放逻辑,而非依赖 GC 兜底。
native 资源管理:显式释放是唯一可靠方式
对于堆外资源(视频、图像、加解密等),应像管理文件句柄和数据库连接一样,在 finally 块中显式释放。JavaCV 的 release() 方法可安全重复调用,底层有保护机制。不要依赖 GC 或 Cleaner 来释放 native 内存,否则可能在意外时刻(如升级)引发 OOM。
Q&A
Java 8升级到Java 11后,为什么会出现内存溢出(OOM)?
因为JavaCV库的堆外内存没有被显式释放,Java 8的Parallel GC年轻代回收频繁,会触发清道夫释放堆外内存,掩盖了泄漏;而Java 11默认的G1 GC不频繁回收那些包装原生内存的小对象,导致堆外内存不断累积,最终撑爆容器内存。
为什么JVM堆内存监控正常,但容器还是被OOM杀掉?
因为泄漏发生在堆外内存,JVM堆监控看不到。JavaCV等库使用native代码申请堆外内存,这些内存不归JVM堆管,所以堆监控正常,但进程总内存超限被系统杀掉。
JavaCV库的内存泄漏是如何被掩盖的?
JavaCV库通过GC的finalizer机制(清道夫)在垃圾回收时释放堆外内存。Java 8的Parallel GC年轻代回收频繁,每次回收都会触发清道夫,所以泄漏被掩盖;而Java 11的G1 GC不频繁回收小对象,清道夫不触发,泄漏就暴露了。
如何修复JavaCV库的堆外内存泄漏?
修复方法是显式释放资源:在finally块中调用release()方法,并将grabber的stop()替换为release(),确保原生内存被及时释放。重复调用release()是安全的,因为底层有保护。
从这次内存泄漏事件中,可以吸取哪些教训?
教训有三点:1. 堆外内存泄漏在JVM监控中不可见,遇到进程OOM但堆正常时,应检查native库、DirectByteBuffer等;2. “一直没出事”不等于没有bug,可能只是有隐性兜底在掩盖,升级或重构前要思考系统正常运行的隐性前提;3. native资源必须谁申请谁释放,写进finally,不能依赖GC。
为什么说Java 11不是导致OOM的罪魁祸首?
因为Java 11只是改变了默认GC策略,从Parallel GC变为G1 GC,撤掉了原来高频回收对堆外内存泄漏的掩盖,让潜伏三年的bug暴露出来。真正的根因是代码没有显式释放JavaCV的堆外内存。