堆外内存泄漏排查:Netty DirectByteBuffer 导致的服务宕机

本文是线上问题实战录系列的第 10 篇 叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

某周三上午 10:17,告警群弹出网关服务 gateway-service 进程退出的告警。这已经是本月第三次——每次重启后撑 2-3 天就会 OOM,加堆内存也只是从 3 天延长到 5 天。

告警群消息

更让人不安的是堆内存只用了 40% 多,但 RES 远超堆大小。

排查过程

第一步:确认异常——RES 远大于堆

登录 gw-prod-02 查看进程状态:

应用启动与参数

堆设了 256MB,MaxDirectMemorySize 也是 256MB。但 top 看到 RES 已经 1.2GB:

top 显示 RES 1.2G 但堆仅 42%

jstat 更清晰:老年代 O 区 42.17%,FullGC 才 3 次——堆内根本没问题。内存肯定在堆外。

第二步:pmap 查内存映射——64MB 匿名块成片

$ pmap -x 28765 | head -40

pmap 显示 15 块 64MB 匿名内存

15 块 64MB 匿名映射,每块都是 rw--- [anon] 且 RSS ≈ 65MB,加起来 960MB。典型 DirectByteBuffer 特征——JVM 每次分配 DirectBuffer 都会 mmap 一段地址空间。

第三步:NMT 锁定 DirectBuffer

$ jcmd 28765 VM.native_memory summary scale=MB

NMT 确认 DirectBuffer 占用 890MB

  • Java Heap:256MB committed(正常)
  • DirectBuffer:890MB committed(远超 256MB 的 MaxDirectMemorySize)

加上 summary.diff 观察增长趋势——比上次采样多了 267MB,还在涨。

第四步:Netty LeakDetector 定位到 Handler

$ grep -c 'LEAK' /opt/gateway/logs/app.log
2847

28 小时内 2847 次泄漏。每条泄漏日志都指向同一个 Handler:

LEAK: ByteBuf.release() was not called before it's garbage-collected.
  Recent access records:
  #2: com.opencao.gateway.handler.RequestTransformHandler.channelRead(...)
  Created at:
  io.netty.buffer.AbstractByteBufAllocator.ioBuffer(...)
  com.opencao.gateway.handler.RequestTransformHandler.channelRead(...)

Netty LeakDetector 日志

根因分析

为什么泄漏

public class RequestTransformHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;             // ← Netty 传入的 ByteBuf
        ByteBuf transformed = transform(buf);
        ctx.fireChannelRead(transformed);         // ← 继续传播
    }                                             // ← buf 从未 release()!
}

问题代码

Netty 的 ByteBuf 使用引用计数管理生命周期(类似 C++ shared_ptr)。每 channelRead 收到的 ByteBuf 引用计数为 1,Handler 消费完后必须 release() 归还给内存池。

这个 Handler 只调用了 input.readBytes(bytes) 读取数据,然后创建了一个新的 DirectByteBuf result 传下去——但原始的 buf 引用计数从未减 1

每次请求泄漏一个 DirectByteBuf(平均 4KB-32KB),28 小时累积 2847 次,890MB 堆外内存就这么被吃光了。

为什么堆内存监控没报警

因为泄漏在内存在堆外。Heap 监控显示老年代 42%、FullGC 正常、YGC 平稳——所有指标都是绿色的。但 RES 在悄悄爬升,等你发现时已经 1.2GB 了。

为什么加堆内存没用

因为泄漏在 Direct Memory,不在堆内。加 -Xmx 只是让堆内回收变慢,堆外的 DirectByteBuffer 该漏还是漏。你从 256M 加到 512M 甚至 1G,只是推迟了堆外打满的时间。

为什么测试环境没发现

测试流量小,DirectByteBuffer 即使泄漏也能被 GC 连带回收——JVM 在 GC 时会顺便回收不再引用的 DirectByteBuffer。但生产流量大,泄漏速度远超 GC 回收速度,积少成多。

修复方案

核心修复只有两行:在 finally 中调用 buf.release()

public class RequestTransformHandlerFixed extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        try {
            ByteBuf transformed = transform(buf);
            ctx.fireChannelRead(transformed);
        } finally {
            buf.release();   // ← 关键:确保 release()
        }
    }
}

修复代码

验证结果

修复后开启 Netty LeakDetector:

-Dio.netty.leakDetectionLevel=advanced

观察 24 小时,grep -c 'LEAK' 结果为 0。RES 稳定在 380MB 左右(堆 256MB + 正常 DirectBuffer 开销)。

避坑建议

1. 所有 ChannelHandler 必须遵循 Release 规则

场景 操作 示例
消费 msg 后不继续传播 ReferenceCountUtil.release(msg) 终端 Handler
消费 msg 后继续传播 msg.release() + 创建新 msg 或 ctx.fireChannelRead(msg) 转换 Handler
已通过 pipeline.addLast() 注册 检查每个 channelRead 是否有 release() 代码审查

2. 生产环境开启 LeakDetector

-Dio.netty.leakDetectionLevel=advanced

默认是 disabled,生产上至少开到 simple(1% 采样),advanced 会记录完整调用栈用于定位。

3. 堆外内存也要监控

指标 命令 告警阈值
RES / 堆比值 top -p <pid> RES > 堆 × 2
DirectBuffer jcmd <pid> VM.native_memory summary DirectBuffer > MaxDirectMemorySize × 70%
匿名内存 pmap -x <pid> \| grep anon \| awk '{s+=$2} END {print s}' 单进程 > 1GB

4. 代码审查清单

凡自定义 ChannelInboundHandlerAdapter / ChannelOutboundHandlerAdapter,必须检查: - channelRead 中的 msg 是否在某个路径上 release 了 - writewriteAndFlush 之后 ByteBuf 是否不需要再 release(Netty 会帮你 release) - 异常路径是否也 release 了(用 try-finally 兜底)

附:完整命令清单

进程内存检查

top -p <pid> -b -n 1                           # 查 RES / 堆内存比值
pmap -x <pid> | head -40                       # 查看匿名内存块(64MB → DirectBuffer)
pmap -x <pid> | grep anon | awk '{s+=$2} END {print s/1024 " MB"}'  # 匿名内存总量
cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable'          # 系统内存
free -m                                        # 系统内存概览

Native Memory Tracking

jcmd <pid> VM.native_memory summary scale=MB              # NMT 总览
jcmd <pid> VM.native_memory summary.diff scale=MB          # 对比上次变化

Netty LeakDetector

# 启动参数
-Dio.netty.leakDetectionLevel=advanced

# 查询泄漏次数
grep -c 'LEAK' /opt/gateway/logs/app.log

# 查看泄漏详情
grep -i 'LEAK\|ByteBuf.release' /opt/gateway/logs/app.log | head -20

GC 检查

jstat -gcutil <pid> 1000 5                    # GC 统计(确认堆内是否正常)

📖 完整版带可复现 Demo → opencao.cn 📺 公众号「Ai拆代码的曹操」 🌟 知识星球「源阅会」(82877104)

复现环境:JDK 17 / Spring Boot 3.2 / Netty (via WebFlux) 复现时间:2025-08-13 截图生成工具:tools/server-mockup.html + tools/chat-mockup.html + tools/code-mockup.html