标签: G1 GC

  • 深入 JVM 陷阱排查:DirectByteBuffer 堆外泄漏引发的 OOM Killer 与 G1 停顿实战

    堆外内存(DirectByteBuffer)泄漏是高并发网关的隐形杀手。现象通常是 JVM 堆内存充裕,但进程却被 Linux OOM Killer 无情强杀。本质原因是 NIO 产生的 ByteBuffer 壳对象晋升至 G1 老年代,未达到 Mixed GC 阈值,导致底层 Cleaner 无法执行回收。解决之道:必须显式限制 -XX:MaxDirectMemorySize 触发 Jvm 内部 OOM 实施自保,下调 G1 的 InitiatingHeapOccupancyPercent 提前回收,并配合 pmap 与 JMX 精准追踪堆外分配。

    凶案现场:安静死去的网关节点

    排查过程中,某高吞吐 API 网关集群(基于 Spring Cloud Gateway / Netty,JDK 11.0.17)频繁出现节点无响应。监控面板显示,节点在崩溃前 CPU 和 Load Average 均无异常,YGC 频率平稳,堆内存使用率仅徘徊在 40% 左右。但容器却频繁发生重启。

    登录宿主机查阅系统日志,直接抓到了真凶:

    $ dmesg -T | grep -i oom
    [xxx] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [xxx] Out of memory: Killed process 13452 (java) total-vm:14853240kB, anon-rss:8192340kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:21340kB oom_score_adj:0
    

    anon-rss(匿名常驻内存)高达 8GB,而该容器的物理内存上限即为 8GB。JVM 的启动参数配置了 -Xms4G -Xmx4G,按理说加上 JVM 自身的 MetaSpace、CodeCache 和线程栈,怎么也不该吃掉额外的 4GB 内存。

    追踪隐形内存:NMT 的盲区

    遇到堆外内存泄漏,标准动作是开启 Native Memory Tracking(NMT):

    -XX:NativeMemoryTracking=detail
    

    随后使用 jcmd 查看:

    $ jcmd 13452 VM.native_memory summary
    Total: reserved=5342MB, committed=4842MB
    -                 Java Heap (reserved=4096MB, committed=4096MB)
    -                     Class (reserved=256MB, committed=128MB)
    -                    Thread (reserved=120MB, committed=120MB)
    -                      Code (reserved=250MB, committed=64MB)
    ...
    

    陷阱出现:NMT 统计的 committed 总内存仅不到 5GB,与 OS 层面 pmap 或 top 看到的 8GB 存在巨大缺口。

    这是 JVM 调优中极其容易踩坑的地方:NMT 无法监控由 Unsafe.allocateMemory() 直接分配的内存,而 java.nio.DirectByteBuffer 的底层恰恰就是基于 Unsafe 实现的。NMT 只能管 JVM 内部的 C++ 代码(如 JIT 编译器、GC 结构等)分配的内存。

    我们改用 OS 层面的 pmap 对照,抓出大块内存地址:

    $ pmap -x 13452 | sort -k 3 -n -r | head -n 10
    Address           Kbytes     RSS   Dirty Mode  Mapping
    00007f8a10000000 4194304 4194304 4194304 rw---   [ anon ]  <-- 堆内存 4G
    00007f8840000000   65536   65536   65536 rw---   [ anon ]  <-- 巨大的 64M 连续匿名内存块
    00007f8844000000   65536   65536   65536 rw---   [ anon ]
    ...
    

    配合 Arthas 读取 JMX 的 java.nio.BufferPool.direct 属性,确认了 Direct Memory 的实际使用量已经飙升到了 3.5GB。

    为什么堆内壳对象存活会导致物理内存被掏空?

    这里必须深挖 DirectByteBuffer 的生命周期与 G1 垃圾回收机制之间的致命耦合。

    DirectByteBuffer 在 Java 堆内只是一个几十字节的“壳对象”,它内部包含一个 address 指针,指向真正在 OS 层面申请的数百兆甚至上 G 的物理内存。 当这段物理内存需要被释放时,必须依赖 JVM 的 ReferenceHandler 线程处理挂在壳对象上的 Cleaner(继承自 PhantomReference)。

    死亡链路如下:

    1. 网关瞬间承接突发流量(如大文件下载、大 Payload 报文)。Netty 分配了大量堆外 ByteBuffer。

    2. 突发流量导致 YGC 频繁,部分 DirectByteBuffer 壳对象未被及时回收,熬过了 -XX:MaxTenuringThreshold,晋升到了 G1 老年代。

    3. 壳对象非常小,即使有上万个晋升到老年代,也只占了几 MB 的堆内存。

    4. JDK 11 中 G1 触发并发标记(Concurrent Mark,随后才是 Mixed GC 回收老年代)的默认条件是自适应的,通常堆使用率达到 45%(InitiatingHeapOccupancyPercent)才会介入。

    5. 堆内 4G 空间极其宽裕,老年代迟迟达不到触发线。

    6. 堆内的壳对象不死,挂在它身上的 Cleaner 就不触发,堆外几十 G 的物理内存就一直被占着不放。

    7. 最终,OS 物理内存耗尽,OOM Killer 直接介入,一刀斩断 Java 进程。

    源码层面,在 java.nio.Bits.reserveMemory() 中,如果堆外内存不足,JVM 会调用 System.gc() 尝试触发 Full GC 救场。但很多运维规范中为了防止停顿,习惯性配置了 -XX:+DisableExplicitGC,这直接屏蔽了这道最后的防线,加速了进程的死亡。

    防御性调优实战

    针对这种场景,不能靠盲目扩大内存,必须通过严苛的参数限制和 GC 调优,让问题在 JVM 内部以异常形式抛出,而不是被 OS 直接 Kill。

    1. 显式限制 Direct Memory

    这是最重要的防御性配置。如果不配,最大堆外内存默认等同于最大堆内存(-Xmx)。

    -XX:MaxDirectMemorySize=2G
    

    强制限制后,当堆外超限时,JVM 会抛出 java.lang.OutOfMemoryError: Direct buffer memory。这不仅能保护宿主机,还能让业务代码触发重试或熔断降级。

    2. G1 提前介入清理老年代

    打破 G1 老年代因堆内内存充足而“躺平”的僵局。关闭自适应 IHOP,强制调低并发标记的触发阈值,让 G1 尽早清理掉老年代的 ByteBuffer 壳对象。

    -XX:-G1UseAdaptiveIHOP
    -XX:InitiatingHeapOccupancyPercent=35
    

    这样当整个堆内存使用到达 35% 时,G1 就会开始并发标记并执行 Mixed GC,及时释放堆外的内存孤岛。

    3. 保留 System.gc(),但使其并发执行

    绝不要粗暴地使用 -XX:+DisableExplicitGC。对于依赖堆外内存的 Netty/NIO 架构,显式的 System.gc() 是 Bits.reserveMemory() 触发内存回收的关键机制。 建议改为:

    -XX:+ExplicitGCInvokesConcurrent
    

    当触发 System.gc() 时,不再进行会导致整个应用停顿(STW)的 Full GC,而是启动一次并行的 Concurrent GC 周期。

    常见问题 (FAQ)

    Q1:为什么 NMT 没发现泄漏,但 top 命令里的 RES 却居高不下,且 JMX 里的 DirectMemory 并不高? 这种情况通常不是 DirectByteBuffer 导致的,而是 glibc 的 malloc 内存碎片和内存池机制机制导致。JVM 中的 JIT 编译器、JNI 调用等在频繁申请/释放小块内存时,glibc 为了性能不会立即返还给 OS,而是缓存在 Arena 中。 解法:在启动脚本中加入环境变量限制 Arena 数量:export MALLOC_ARENA_MAX=4。

    Q2:JIT 编译(CodeCache)是否也会导致堆外内存 OOM? 会。在 JDK 11 及以后,分层编译(Tiered Compilation)极其激进。对于复杂的微服务应用,默认的 240M CodeCache 可能被打满,或者在未打满时由于碎片导致 JIT 停止工作,引发 QPS 断崖式下跌。 排查:可以通过 jcmd Compiler.codecache 查看。若使用率超 80%,需增加 -XX:ReservedCodeCacheSize=512M。CodeCache 是受 NMT 监控的,可以在 NMT Compiler 分类中明确看到。

    Q3:Netty 自身有对象池机制,还会引发 DirectMemory 泄漏吗? 会。Netty 的 PooledByteBufAllocator 自己维护了一套内存池结构(Chunk/Page)。如果是业务代码忘记调用 ReferenceCountUtil.release(msg),导致的属于内存池泄漏,Netty 提供了 ResourceLeakDetector(配置 -Dio.netty.leakDetection.level=ADVANCED)会在日志中打印泄漏轨迹。 但如果是网关积压(消费过慢,生产过快),Netty 不断向底层申请新的 Chunk,哪怕没有忘记 release,也会打爆物理内存,这时依然需要依靠 -XX:MaxDirectMemorySize 进行兜底。