堆外内存(DirectMemory)泄露定位与排查技巧

封面信息图

在云原生与容器化部署(Kubernetes)广泛普及的今天,Java 研发团队面临着一种极其棘手的生产故障:微服务的容器进程频繁被操作系统内核直接杀死,Pod 退出码显示为 Exit Code 137(OOMKilled)

然而,当工程师紧急调出监控大盘查看 JVM 堆内存(Heap Memory)时,却发现堆内存曲线非常健康,使用率仅维持在 30%~40%,甚至刚刚经历过一次垃圾回收,老年代空间极其充裕。

这正是典型的堆外内存(Direct Memory / Native Memory)泄露。与堆内内存有垃圾收集器自动回收不同,堆外内存由直接内存缓冲区(DirectByteBuffer)、Netty 的池化内存分配器(PooledByteBufAllocator)、JNI 原生 C/C++ 库或 JIT 编译代码缓存所占用。由于堆外内存不受 JVM 堆上限(-Xmx)的直接约束,一旦发生引用未释放或资源泄露,它将持续吞噬容器的物理内存(RSS),直到触发宿主机 cgroup 的硬限制被瞬间掐死。

本文将系统拆解堆外内存的构成,并介绍利用 JVM 原生 NMT(Native Memory Tracking)、jcmd 以及 Netty 内存泄露探测器进行精准定位的实战方法。

JVM 进程的真实物理内存构成

一个 Java 进程所消耗的物理内存(Resident Set Size, RSS)远不止通过 -Xmx 指定的堆内存大小,其完整全景如下:

[JVM 进程总物理内存 (RSS)]
   │
   ├─ 1. JVM 堆内存 (Heap: Young Gen + Old Gen,受 -Xms / -Xmx 约束)
   │
   ├─ 2. 元空间与类加载器 (Metaspace + Compressed Class Space)
   │
   ├─ 3. 线程栈空间 (Thread Stacks: 线程数 × -Xss, 每个默认 1MB)
   │
   ├─ 4. JIT 编译代码缓存 (CodeCache)
   │
   ├─ 5. 直接内存 (DirectByteBuffer: NIO / Netty 零拷贝缓冲区)
   │
   ├─ 6. GC 内部数据结构与元数据 (Card Table, Mark Queue)
   │
   └─ 7. JNI / C 堆分配内存 (glibc malloc / jemalloc / 解压缩原生库)

当发生堆外内存泄露时,最常见的元凶通常是:

  1. Netty ByteBuf 引用计数未正确释放:自定义 ChannelInboundHandler 消费了 ByteBuf,却未调用 ReferenceCountUtil.release(msg),导致底层堆外 Slab 内存池永久无法归还。
  2. JNI / Zip 库解压缩泄露:频繁使用 Deflater / Inflater 或第三方图片处理库(如 ImageIO),未显式调用 close() / end(),导致底层 C 语言 malloc 分配的指针泄露。
  3. -XX:MaxDirectMemorySize 未做限制:未显式约束直接内存上限,导致 DirectByteBuffer 无休止分配。

生产排查三步法实战

第一步:开启 JVM 本地内存跟踪(NMT)

NMT(Native Memory Tracking)是 HotSpot JVM 内置的高性能内存诊断工具。在服务启动参数中增加以下配置(生产环境建议使用 summary,排查环境使用 detail):

-XX:NativeMemoryTracking=detail
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintNMTStatistics

注意:开启 NMT 会带来 5%~10% 的轻微性能开销,建议在排查期间或单台灰度节点上启用。

第二步:利用 jcmd 制作基线并比对增量差异(Diff)

当服务刚启动并完成业务预热后,建立一个内存基线(Baseline):

# 1. 查看 Java 进程 PID
jps -v

# 2. 建立当前内存快照基线
jcmd <PID> VM.native_memory baseline

让服务在压测或正常生产流量下运行数小时。当发现容器 RSS 物理内存持续攀升时,执行比对命令:

# 3. 对比当前内存与基线的增量差异
jcmd <PID> VM.native_memory detail.diff

关键输出切片分析

Total: reserved=5432MB (+520MB), committed=4120MB (+480MB)

-                 Java Heap (reserved=2048MB, committed=2048MB)
                            (mprotect: reserved=2048MB, committed=2048MB)
 
-                    Class (reserved=1085MB (+12MB), committed=128MB (+8MB))
                            (classes #18420 (+412))
 
-                   Thread (reserved=312MB (+45MB), committed=312MB (+45MB))
                            (thread #312 (+45))
 
-            Internal/Other (reserved=850MB (+380MB), committed=820MB (+375MB))
                            [0x00007f9a88000000 - 0x00007f9a9c000000] committed 375MB from
                            [Unsafe_AllocateMemory0 + 0x8a]
                            [DirectByteBuffer.<init> + 0x45]
                            [com.example.transport.NettyMessageDecoder.decode + 0x120]

从上述 Diff 报告中可以一眼看出:

  • Thread 区域增加了 45MB,说明线程数增加了 45 个;
  • Internal / DirectBuffer 区域激增了 +375MB,调用栈直接指向了 NettyMessageDecoder.decode 中的 DirectByteBuffer 分配!

第三步:开启 Netty 资源泄露高级探测器

如果确认泄露与 Netty 相关,可以通过 JVM 参数调高 Netty 的泄露检测级别:

-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=20

Netty 探测级别说明:

  • SIMPLE(默认):采样 1% 的 ByteBuf,仅提示是否发生泄露。
  • ADVANCED:采样 1% 的 ByteBuf,打印发生泄露时的完整分配调用栈(Allocated at)
  • PARANOID(偏执模式):采样 100% 的 ByteBuf。开销较大,专用于排查阶段定位具体的业务类和代码行号。

Netty 泄露日志示例

[ERROR] io.netty.util.ResourceLeakDetector - LEAK: ByteBuf.release() was not called before it's garbage-collected. 
Recent access records: 
#1:
	io.netty.buffer.AdvancedLeakAwareByteBuf.readBytes(AdvancedLeakAwareByteBuf.java:482)
	com.example.gateway.handler.CustomSecurityFilter.decodePayload(CustomSecurityFilter.java:65)
Created at:
	io.netty.buffer.PooledByteBufAllocator.directBuffer(PooledByteBufAllocator.java:380)
	com.example.gateway.codec.FastHttpDecoder.channelRead(FastHttpDecoder.java:42)

定位到 CustomSecurityFilter.java:65 读取了字节后未执行 ReferenceCountUtil.release(byteBuf),修正代码即可彻底根治。

生产规范与防护军规

// 正确释放 Netty ByteBuf 的代码模板
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    try {
        if (msg instanceof ByteBuf byteBuf) {
            // 处理业务逻辑
            processPayload(byteBuf);
        }
    } finally {
        // 显式释放引用计数,防止堆外内存泄露
        ReferenceCountUtil.release(msg);
    }
}
  1. 必须显式设置 -XX:MaxDirectMemorySize
    默认情况下,JVM 的 MaxDirectMemorySize-Xmx 相同。若不加限制,容器极易因堆内加堆外总和超标而被 K8s 杀死。生产应明确设置(例如 -XX:MaxDirectMemorySize=1g),使得直接内存超限时能够主动抛出 java.lang.OutOfMemoryError: Direct buffer memory,留下 JVM 堆栈供排查,而不是被操作系统静默掐死。
  2. 正确配置容器 Limit 与 JVM 参数的比例(75% 黄金法则)
    若 Pod 的 K8s limits.memory 分配了 4GB,JVM 的堆内存(-Xmx)建议最多分配 2.5GB~3GB,必须为 Metaspace(256MB)、线程栈(200MB)、DirectMemory(512MB)以及操作系统 Native C 堆预留出至少 1GB 的安全缓冲空间。
  3. 避免频繁调用 System.gc()
    DirectByteBuffer 的堆外内存清理依赖 Cleaner 虚引用(PhantomReference)。在老年代未发生 GC 时,堆外内存可能无法及时被回收。严禁随意配置 -XX:+DisableExplicitGC,防止依赖 NIO 显式触发 GC 清理堆外的机制彻底失效。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐