基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

如何排查和检测 Netty 中的堆外内存泄漏问题?

在 Netty 中,为了提高 I/O 性能和实现零拷贝,通常会大量使用堆外内存(Direct Memory)来分配 ByteBuf。由于堆外内存不受 JVM 垃圾回收器(GC)的直接管理,Netty 引入了引用计数(Reference Counting)机制。如果在代码中忘记调用 release(),就会导致堆外内存泄漏。

排查和检测 Netty 堆外内存泄漏通常分为以下几个阶段:现象确认 -> 开启 Netty 内置检测 -> 使用工具深度排查 -> 代码审查


第一阶段:确认是否为堆外内存泄漏

在动手排查前,需要确认究竟是堆内泄漏、堆外泄漏,还是其他 Native 内存泄漏。

  1. 观察现象
    • 操作系统层面:通过 top 命令发现 Java 进程的 RES(物理内存)持续飙升
    • JVM 层面:通过 jstat -gc <pid> 或监控工具观察,Heap(堆内存)使用平稳,Full GC 后堆内存能正常回收,但进程总内存依然在涨。
  2. 报错特征
    • 最终系统可能会触发 Linux 的 OOM Killer 杀掉进程(dmesg -T | grep -i oom)。
    • 如果配置了 -XX:MaxDirectMemorySize,应用可能会抛出 java.lang.OutOfMemoryError: Direct buffer memory

第二阶段:使用 Netty 内置的泄漏检测器(最有效的方法)

Netty 提供了一个非常强大的内置工具:ResourceLeakDetector。这是排查 Netty 内存泄漏首选且最有效的手段。

1. 设置泄漏检测级别

通过启动参数 -Dio.netty.leakDetection.level 或者代码配置开启检测。Netty 提供了 4 个级别:

  • DISABLED:禁用检测。
  • SIMPLE(默认):抽样检测 1% 的 ByteBuf,只报告是否发生泄漏,不提供泄漏位置。
  • ADVANCED:抽样检测 1% 的 ByteBuf,报告泄漏并提供发生泄漏时的对象创建和访问的堆栈信息
  • PARANOID100% 检测所有的 ByteBuf,报告堆栈信息。对性能影响很大,仅限测试/排查环境使用

操作建议
在测试环境或复现环境,直接加上启动参数:

bash
-Dio.netty.leakDetection.level=PARANOID

如果在生产环境排查,且流量很大,可以使用:

bash
-Dio.netty.leakDetection.level=ADVANCED

2. 观察日志

开启后,运行业务触发内存分配。如果发生泄漏,Netty 会在日志中打印出 ERROR 级别的警告,关键字为 LEAK: ByteBuf.release() was not called

日志示例:

plaintext
[ERROR] io.netty.util.ResourceLeakDetector - 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: 
# 顺着堆栈往下看,能精准定位到是哪一行代码创建了 ByteBuf 或者最后操作了 ByteBuf 却没有 release
    at com.example.MyHandler.channelRead(MyHandler.java:35) 
    at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
    ...

根据这段日志提供的精确堆栈,90% 的 Netty 内存泄漏问题都可以直接定位并修复。


第三阶段:借助外部工具深度排查(当内置工具失效时)

如果 Netty 的检测器没有报警(可能是使用了底层的 JDK ByteBuffer 而非 Netty 的 ByteBuf,或者泄漏的对象一直被强引用着导致 GC 无法触发探测),需要使用 JVM 和系统工具。

1. NMT (Native Memory Tracking)

排查 JVM 进程所有堆外内存分配情况。

  • 启动参数加上:-XX:NativeMemoryTracking=detail
  • 运行一段时间后打基准:jcmd <pid> VM.native_memory baseline
  • 再运行一段时间后对比:jcmd <pid> VM.native_memory detail.diff
  • 注意:NMT 主要看 InternalDirect 区域是否有明显增长。

2. 使用 Arthas (阿里开源诊断工具)

Arthas 在生产环境极其好用。

  • 查看直接内存占用:
    bash
    [arthas@pid]$ memory
    查看 direct 区域的内存消耗。
  • 查看 JMX 中的 DirectBuffer MBean:
    bash
    [arthas@pid]$ mbean java.nio:type=BufferPool,name=direct
    关注 MemoryUsedCount(如果 Count 狂飙说明泄漏)。

3. Heap Dump (堆快照分析)

虽然是堆外内存泄漏,但堆外内存的引用对象(如 DirectByteBuffer 或 Netty 的 PooledUnsafeDirectByteBuf)是分配在堆内的。

  • 导出堆快照:jmap -dump:format=b,file=heap.hprof <pid>
  • 使用 MAT (Memory Analyzer Tool) 或 VisualVM 打开。
  • 搜索 io.netty.buffer.PooledUnsafeDirectByteBufjava.nio.DirectByteBuffer 类的实例。
  • 通过 GC Roots (Path to GC Roots) 查看这些对象被谁引用。如果发现大量 ByteBuf 被放入了某个全局 ListMap 或队列中忘记清理,这就找到了泄漏源。

第四阶段:常见代码层面的泄漏原因及排查点

在代码审查时,重点关注以下几个容易出错的场景:

  1. ChannelInboundHandlerAdapter 中忘记释放

    • 如果重写了 channelRead,并且没有将消息传递给下一个 Handler (ctx.fireChannelRead(msg)),必须手动释放:ReferenceCountUtil.release(msg);
    • 建议:如果仅仅是处理消息不再传递,继承 SimpleChannelInboundHandler 更好,它会在 channelRead0 执行完毕后自动帮你释放。
  2. 异常处理导致未释放

    • 代码在处理 ByteBuf 时抛出了异常,导致后面的 release() 没有执行。
    • 规范写法
      java
      ByteBuf buf = ...;
      try {
          // 处理业务
      } finally {
          ReferenceCountUtil.release(buf); // 务必在 finally 中释放
      }
  3. 异步线程/队列处理遗漏

    • 将 Netty 接收到的 ByteBuf 丢入自定义的业务线程池或 BlockingQueue 中异步处理。处理完成后忘记了 release()
  4. 重复的 retain() 没有配对的 release()

    • 每次调用 buf.retain() 会让引用计数 +1,必须确保有对应次数的 release() 让计数归零,否则底层堆外内存永远不会归还给 OS/内存池。
  5. ByteBuf 切片 (Slice/Duplicate) 误解

    • buf.slice()buf.duplicate() 返回的新 ByteBuf 与原 ByteBuf 共享底层内存,且不会改变引用计数。如果对派生的 buf 做了 retain(),必须要显式释放。

总结排查路径:

  1. 先看监控,确定是进程内存飙升而 Heap 正常(确诊堆外泄漏)。
  2. 立刻加上 -Dio.netty.leakDetection.level=PARANOID 重启测试应用。
  3. 压测/跑业务,死盯日志寻找 LEAK: 关键字及堆栈。
  4. 顺藤摸瓜修改代码(加上 try-finally release)。
  5. 如果没日志,用 jcmd NMTArthas 查看 DirectBuffer 指标,用 MAT 分析堆内存寻找 ByteBuf 堆内引用的聚集点。
00:00
00:00