Administrator
发布于 2021-10-30 / 13241 阅读
165

堆外内存泄漏排查:一次 Direct buffer memory OOM

现象:堆才用了一半,容器就被 OOMKilled

10 月 28 号晚上,网关服务的一个 Pod 被 K8s 杀了。

$ kubectl describe pod api-gateway-5f8b7c9d4-xt2n9
    Last State:   Terminated
    Reason:       OOMKilled
    Exit Code:    137

奇怪的是 JVM 里的堆非常空闲:

$ jcmd 1 GC.heap_info
 garbage-first heap   total 4194304K, used 1823412K [0x00000006c0000000, ...)
 region size 4096K, 1 young region, 452 used regions

4 GB 堆只用了 1.78 GB,而容器 limit 是 7 GB。看容器的实际内存:

$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes
7131242496        # 6.64 GB,逼近 7 GB 上限

差值 6.64 - 1.78 = 4.86 GB,这些内存不在堆里。典型的堆外内存泄漏。再看应用日志,在被杀之前确实有异常:

java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(java.base@11.0.12/Bits.java:178)
	at java.nio.DirectByteBuffer.<init>(java.base@11.0.12/DirectByteBuffer.java:119)
	at java.nio.ByteBuffer.allocateDirect(java.base@11.0.12/ByteBuffer.java:317)
	at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:699)
	at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:675)
	at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:237)
	at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:362)
	at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
	at com.xxx.gateway.codec.BodyDecoder.decode(BodyDecoder.java:73)

栈顶在 BodyDecoder.java:73。但先别急着下结论,得先确认堆外的构成。

用 NMT 把堆外内存摊开

Native Memory Tracking 是 JDK 自带的堆外内存分析工具,需要在启动时打开(有 5% 到 10% 的性能开销,我们只在预发和灰度实例上开):

-XX:NativeMemoryTracking=detail
-XX:+UnlockDiagnosticVMOptions
$ jcmd 1 VM.native_memory summary scale=MB

Native Memory Tracking:
Total: reserved=7248MB, committed=6812MB

-                 Java Heap (reserved=4096MB, committed=4096MB)
                            (mmap: reserved=4096MB, committed=4096MB)

-                     Class (reserved=1124MB, committed=142MB)
                            (classes #28412)
                            (malloc=12MB #48211)
                            (mmap: reserved=1112MB, committed=130MB)

-                    Thread (reserved=298MB, committed=298MB)
                            (thread #287)
                            (stack: reserved=296MB, committed=296MB)

-                      Code (reserved=261MB, committed=78MB)

-                        GC (reserved=312MB, committed=312MB)

-                  Internal (reserved=218MB, committed=218MB)
                            (malloc=218MB #102482)

-                    Direct (reserved=4218MB, committed=4218MB)    ← 就是这个
                            (malloc=4218MB #1241)

-                     Other (reserved=41MB, committed=41MB)

Direct 占了 4218 MB,占了整个堆外的绝大部分。剩下的 Class(元空间)、Thread(287 个线程的栈)、Code(CodeCache)、GC 都在正常范围。

NMT 还有个增量对比功能,适合确认"内存在持续涨"这个事实:

$ jcmd 1 VM.native_memory baseline
Baseline succeeded
# 等 10 分钟
$ jcmd 1 VM.native_memory summary.diff scale=MB

-                    Direct (reserved=4218MB, committed=4218MB)
                                        +1184MB

10 分钟涨了 1.18 GB,确认是持续泄漏,不是一次性分配。

Direct 内存的上限是多少

$ jcmd 1 VM.system_properties | grep -i direct
    io.netty.maxDirectMemory=4294967296
    sun.nio.MaxDirectMemorySize=4294967296

默认情况下 MaxDirectMemorySize 等于 -Xmx(如果不显式指定的话)。我们堆设了 4 GB,所以直接内存也有 4 GB 额度,加上堆的 4 GB、元空间、线程栈、CodeCache,总需求轻松超过 7 GB 的容器 limit。

这是个很常见的配置错误:只按堆大小去定容器 limit。经验公式是 limit ≈ 堆 * 1.5 + 1 GB,我们这次是 4 GB 堆配 7 GB limit,看起来够,但直接内存能吃满 4 GB,加起来就爆了。

另外 Netty 自己也会读这个值。io.netty.maxDirectMemory 显示 4 GB,说明 Netty 认为它有 4 GB 额度,会放心地分配(Netty 的 PlatformDependent 会统计已用直接内存,超了就抛 OutOfDirectMemoryError)。

谁在分配:BufferPoolMXBean

NMT 只能看到总量,看不到是谁分配的。用 JMX 的 BufferPoolMXBean 能看到 direct buffer 的对象数和占用:

$ jcmd 1 ManagementAgent.start_local
$ jconsole   # 或者用下面的代码
List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean pool : pools) {
    System.out.printf("%s: count=%d, used=%d MB, capacity=%d MB%n",
            pool.getName(), pool.getCount(),
            pool.getMemoryUsed() >> 20, pool.getTotalCapacity() >> 20);
}

输出:

direct: count=1241, used=4218 MB, capacity=4218 MB
mapped: count=18, used=124 MB, capacity=124 MB

注意 count=1241:只有 1241 个 direct buffer 对象,却占了 4218 MB,平均每个 3.4 MB。这说明是少量大块内存没被释放,而不是大量小块泄漏。这个信息很关键——它指向"业务代码里的大块 ByteBuf 没 release",而不是 Netty 内部的小对象池问题。

顺便说一个机制:ByteBuffer.allocateDirect() 分配时如果额度不够,JDK 会先触发一次 System.gc(),等一会儿再重试,还是不够才抛 OOM。所以直接内存泄漏的现场往往伴随大量 Full GC。我们那台机器在被杀之前的 20 分钟里 Full GC 了 340 次,但堆回收不了多少东西(堆才用了 43%),这个现象本身就是重要线索。

根因:ByteBuf 转 InputStream 之后忘了释放

BodyDecoder 第 73 行附近的代码:

@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    if (in.readableBytes() < 4) {
        return;
    }
    in.markReaderIndex();
    int length = in.readInt();
    if (in.readableBytes() < length) {
        in.resetReaderIndex();
        return;
    }
    // 第 73 行:分配一块直接内存,把 body 拷进去
    ByteBuf body = ctx.alloc().directBuffer(length);
    in.readBytes(body);

    // 转成 InputStream 交给 Jackson 解析
    try (InputStream is = new ByteBufInputStream(body)) {
        out.add(objectMapper.readValue(is, GatewayRequest.class));
    } catch (Exception e) {
        throw new CodecException(e);
    }
    // ← 没有 body.release()
}

Netty 的 ByteBuf 用的是引用计数(ReferenceCounted 接口),分配时 refCnt = 1,必须显式 release() 才会归还给池。这个 body 是从 ctx.alloc() 分配出来的,Netty 不会自动管它的生命周期。

为什么不一定每次都泄漏?因为 Netty 有个兜底:如果 ByteBuf 变得不可达,它内部持有的 ByteBuffer 会被 GC 回收,GC 时 Cleaner 会释放堆外内存。但这条路有两个前提:

  • PooledByteBufAllocator 的池化内存块在被 GC 之前不会归还给池。我们用的是池化分配器(Netty 4.1 默认),分配出来的 4 MB chunk 会一直挂着,直到整个 chunk 里的所有 buffer 都被释放。
  • GC 什么时候发生不确定。我们堆很空闲,Young GC 频繁但 Full GC 要等老年代涨上来,堆外就这么一直攒着。

这正好解释了 count=1241, used=4218MB 的现象:1241 个 3.4 MB 的 buffer 挂在池里,等着被 GC 或者被显式释放。

修复:三处改动

1. 显式 release,用 try-finally 保证

ByteBuf body = null;
try {
    body = ctx.alloc().directBuffer(length);
    in.readBytes(body);
    try (InputStream is = new ByteBufInputStream(body)) {
        out.add(objectMapper.readValue(is, GatewayRequest.class));
    }
} catch (Exception e) {
    throw new CodecException(e);
} finally {
    if (body != null) {
        body.release();          // refCnt 减到 0 就归还给池
    }
}

更省事的写法是用 Netty 提供的工具类,它会判空并且吞掉异常:

ReferenceCountUtil.safeRelease(body);

顺带说一下哪些情况 Netty 自动释放,避免矫枉过正:

  • 继承 SimpleChannelInboundHandler 并重写 channelRead0,入站的 ByteBuf 在方法返回后会被自动释放。
  • 调用 ctx.fireChannelRead(msg) 把消息往后传,责任转给下一个 handler,最后一个 handler 负责释放。ByteToMessageDecoder 就是这么做的。
  • 出站写(ctx.write())的 buffer 由 Netty 写完自动释放。

凡是自己 alloc() 出来的,或者从入站 buffer 里 retainedSlice() / retainedDuplicate() 出来的,都必须自己释放。这是唯一需要记的规则。

2. 开启 Netty 的内存泄漏检测

-Dio.netty.leakDetection.level=advanced

四个级别:DISABLED(关)、SIMPLE(默认,采样 1% 并报告泄漏)、ADVANCED(采样 1%,报告泄漏并给出分配位置)、PARANOID(全量检测,性能损失大,只能调试时用)。

开着 ADVANCED 跑了一轮预发压测,日志里立刻抓到了泄漏点:

LEAK: ByteBuf.release() was not called before it's garbage-collected.
See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
Created at:
	io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:362)
	io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
	com.xxx.gateway.codec.BodyDecoder.decode(BodyDecoder.java:73)
	io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:502)

直接把行号都报出来了。这个功能应该常开在预发环境,采样开销很小。

3. 限制直接内存上限 + 加上监控

-Xms4g -Xmx4g
-XX:MaxDirectMemorySize=1g          # 显式限制,不要让它等于 Xmx
-XX:NativeMemoryTracking=summary    # 预发开 detail,生产开 summary
-Dio.netty.leakDetection.level=simple
-Dio.netty.allocator.numDirectArenas=4

MaxDirectMemorySize 显式设成 1 GB。这样万一再泄漏,会先抛 OOM 而不是等容器杀进程——抛 OOM 至少能被监控捕获、能拿到堆栈,被 OOMKilled 就什么都没有了。

监控上加了这几个指标到 Prometheus:

// 直接内存使用量
BufferPoolMXBean direct = ManagementFactory
        .getPlatformMXBeans(BufferPoolMXBean.class).stream()
        .filter(b -> "direct".equals(b.getName()))
        .findFirst().orElseThrow();

Gauge.builder("jvm.buffer.direct.used", direct, BufferPoolMXBean::getMemoryUsed)
        .baseUnit("bytes").register(registry);
Gauge.builder("jvm.buffer.direct.count", direct, BufferPoolMXBean::getCount)
        .register(registry);
- alert: DirectMemoryTooHigh
  expr: jvm_buffer_direct_used_bytes / 1073741824 > 0.8
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "{{ $labels.application }} 直接内存使用超过 0.8 GB,检查是否有 ByteBuf 未释放"

效果

指标修复前修复后
直接内存稳态占用4.2 GB 且持续增长96 MB 稳定
容器 RSS6.64 GB(limit 7 GB)2.41 GB
Full GC 频率(20 分钟内)340 次0 次
网关 P99412 ms38 ms
OOMKilled 次数(一周)7 次0

P99 从 412 ms 降到 38 ms 是个意外收获。原因就是前面说的:直接内存不够时 JDK 会触发 System.gc(),那些 Full GC 把整个应用拖慢了。泄漏修掉之后,Full GC 也跟着消失了。

另一个案例:MappedByteBuffer 不释放

网关修完之后,我在另一个服务(一个做本地索引加载的服务)上又遇到一次类似的堆外增长。这次 NMT 的 Distribution 不一样:

$ jcmd 1 VM.native_memory summary scale=MB
-                  Internal (reserved=318MB, committed=318MB)
-                    Direct (reserved=124MB, committed=124MB)
-                     Other (reserved=2841MB, committed=2841MB)    ← 这次在 Other 里

堆外的主体在 Other 而不是 DirectOther 里放的是 NMT 无法归类的区域,最常见的大户就是 MappedByteBuffer(mmap 出来的内存映射文件)。

看代码,这个服务在启动和每小时的热更新时都会重新加载索引文件:

private IndexBlock loadBlock(File file) throws IOException {
    try (FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.READ)) {
        MappedByteBuffer buffer =
                channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
        return IndexBlock.parse(buffer);        // 返回的对象持有 buffer 的引用
    }
}

MappedByteBuffer 的释放依赖 GC:它也是通过 Cleaner 在对象不可达时 munmap。问题是我们的老索引对象被一个静态的 Map 缓存着,热更新时虽然替换了引用,但旧对象在 Young GC 里活过来了(因为进了老年代),老年代的回收要等 Full GC 或者并发标记,而堆很空闲,一直没触发。

结果是每次热更新泄漏约 300 MB,8 次之后堆外就到了 2.8 GB。

处理办法是主动 unmap。JDK 没有公开 API,只能通过反射调 Cleaner

public static void unmap(MappedByteBuffer buffer) {
    try {
        Method cleanerMethod = buffer.getClass()
                .getMethod("cleaner");                     // JDK 9+ 在 Unsafe 里,要 add-opens
        cleanerMethod.setAccessible(true);
        Object cleaner = cleanerMethod.invoke(buffer);
        if (cleaner != null) {
            cleaner.getClass().getMethod("clean").invoke(cleaner);
        }
    } catch (Exception e) {
        log.warn("unmap failed", e);
    }
}

在 JDK 11 上这需要 --add-opens java.base/java.nio=ALL-UNNAMED。因为这个服务和索引文件的生命周期是完全可控的(热更新时明确知道旧块不用了),主动释放比等 GC 靠谱得多。改完之后堆外稳定在 340 MB。

判断是不是 mmap 泄漏,最直接的是看进程的 maps:

$ pmap -x 1 | sort -k3 -rn | head -10
Address           Kbytes     RSS   Dirty Mode   Mapping
00007f3a1c000000  307200  307200  307200 rw-s-   index_20211028_1400.bin   ← 已删除但还映射着
00007f3a2d000000  307200  307200  307200 rw-s-   index_20211028_1300.bin

$ ls -la /proc/1/fd | grep deleted | wc -l
9

看到一堆 deleted 状态的映射文件,就是典型的 mmap 没释放。

另一个常见坑:DisableExplicitGC

顺带提一个可能会让问题更严重的参数:

-XX:+DisableExplicitGC

有些团队为了禁止代码里乱调 System.gc() 会加这个。但它同时禁止了 JDK 内部回收 DirectByteBuffer 的 Cleaner(Cleaner 就是靠 System.gc() 触发的),直接内存会更容易堆积。如果加了,建议改用:

-XX:+ExplicitGCInvokesConcurrent      # 让 System.gc() 走并发 GC,而不是 Full GC

我们没加 DisableExplicitGC,但这台机器上的确出现过 System.gc() 风暴,最后靠修掉泄漏解决。

一个固定的排查顺序

事后我把整个流程整理成一份 checklist,贴在 wiki 上,后来两次堆外问题都是照着它十分钟定位到的:

1. 确认是不是堆外问题
   jcmd <pid> GC.heap_info                        # 堆用了多少
   cat /sys/fs/cgroup/memory/memory.usage_in_bytes # 容器总共用了多少
   差值大 → 堆外

2. 看堆外构成
   jcmd <pid> VM.native_memory summary scale=MB   # 需要启动参数 NativeMemoryTracking
   jcmd <pid> VM.native_memory baseline
   # 等几分钟
   jcmd <pid> VM.native_memory summary.diff       # 看哪一块在涨

3. 盯住 Direct 还是 Other
   Direct 大 → ByteBuffer / Netty ByteBuf,走第 4 步
   Other 大  → mmap,用 pmap -x <pid> | sort -k3 -rn 看映射文件
   Thread 大 → 线程泄漏,jstack 数一下同名线程

4. Direct 细化
   BufferPoolMXBean 看 count 和 memoryUsed
   count 少、单块大 → 业务代码没 release
   count 多、单块小 → 池化配置问题或者小块泄漏

5. 定位分配点
   -Dio.netty.leakDetection.level=advanced       # Netty 会直接打印泄漏栈
   或者 jmap -dump 后 MAT 看 DirectByteBuffer 的 GC Root

第 5 步里 leakDetection 是最省力的,可惜它只在预发开。生产环境出问题就只能靠堆转储,MAT 里搜 java.nio.DirectByteBuffer,看它的 att 字段(Netty 会在这里挂上 PooledByteBuf 的信息)。

小结

  • 判断堆外泄漏:堆用得少、容器 OOMKilled、日志里有 OutOfMemoryError: Direct buffer memory。用 jcmd GC.heap_info 对比 /sys/fs/cgroup/memory/memory.usage_in_bytes
  • jcmd VM.native_memory summary 把堆外摊开看,baseline + summary.diff 确认是否在持续增长。需要启动时加 -XX:NativeMemoryTracking
  • 默认 MaxDirectMemorySize 等于 -Xmx,一定要显式设小。容器 limit 按 堆 * 1.5 + 1 GB 估算。
  • BufferPoolMXBeancountmemoryUsed 能判断是"少量大块"(业务代码没 release)还是"大量小块"(对象池问题)。
  • Netty 的 ByteBuf 靠引用计数,自己 alloc() 的必须自己 release(),用 try-finally 或 ReferenceCountUtil.safeRelease()SimpleChannelInboundHandler 的入站 buffer 和出站 write 不用管。
  • 预发环境常开 -Dio.netty.leakDetection.level=advanced,它会在日志里直接给出泄漏点的堆栈。
  • 别忘了别配 -XX:+DisableExplicitGC,它会禁掉 DirectByteBuffer 的 Cleaner 回收路径。
  • 把直接内存使用量做成指标并告警,比等 OOM 强。

参考