上篇文章介绍了 Netty 内存模型原理, 由于 Netty 在使用不当会导致堆外内存泄漏, 网上关于这方面的资料比较少, 所以写下这篇文章, 专门介绍排查 Netty 堆外内存相关的知识点, 诊断工具, 以及排查思路提供参考
现象
堆外内存泄漏的现象主要是, 进程占用的内存较高(Linux 下可以用 top 命令查看), 但 Java 堆内存占用并不高(jmap 命令查看), 常见的使用堆外内存除了 Netty, 还有基于 java.nio 下相关接口申请堆外内存, JNI 调用等, 下面侧重介绍 Netty 堆外内存泄漏问题排查
堆外内存释放底层实现
1 java.nio 堆外内存释放
Netty 堆外内存是基于原生 java.nio 的 DirectByteBuffer 对象的基础上实现的, 所以有必要先了解下它的释放原理
java.nio 提供的 DirectByteBuffer 提供了 sun.misc.Cleaner 类的 clean()方法, 进行系统调用释放堆外内存, 触发 clean()方法的情况有 2 种
(1) 应用程序主动调用
- ByteBuffer buf = ByteBuffer.allocateDirect(1);
- ((DirectBuffer) byteBuffer).cleaner().clean();
(2) 基于 GC 回收
Cleaner 类继承了 java.lang.ref.Reference,GC 线程会通过设置 Reference 的内部变量(pending 变量为链表头部节点, discovered 变量为下一个链表节点), 将可被回收的不可达的 Reference 对象以链表的方式组织起来
Reference 的内部守护线程从链表的头部 (head) 消费数据, 如果消费到的 Reference 对象同时也是 Cleaner 类型, 线程会调用 clean()方法(Reference#tryHandlePending())
2 Netty noClaner 策略
介绍 noClaner 策略之前, 需要先理解带有 Cleaner 对象的 DirectByteBuffer 在初始化时做了哪些事情:
只有在 DirectByteBuffer(int cap)构造方法中才会初始化 Cleaner 对象, 方法中检查当前内存是否超过允许的最大堆外内存(可由 - XX:MaxDirectMemorySize 配置)
如果超出, 则会先尝试将不可达的 Reference 对象加入 Reference 链表中, 依赖 Reference 的内部守护线程触发可以被回收 DirectByteBuffer 关联的 Cleaner 的 run()方法
如果内存还是不足, 则执行 System.gc(), 触发 full gc, 来回收堆内存中的 DirectByteBuffer 对象来触发堆外内存回收, 如果还是超过限制, 则抛出 java.lang.OutOfMemoryError(代码位于 java.nio.Bits#reserveMemory()方法)
而 Netty 在 4.1 引入可以 noCleaner 策略: 创建不带 Cleaner 的 DirectByteBuffer 对象, 这样做的好处是绕开带 Cleaner 的 DirectByteBuffer 执行构造方法和执行 Cleaner 的 clean()方法中一些额外开销, 当堆外内存不够的时候, 不会触发 System.gc(), 提高性能
hasCleaner 的 DirectByteBuffer 和 noCleaner 的 DirectByteBuffer 主要区别如下:
构造器方式不同:
noCleaner 对象: 由反射调用 private DirectByteBuffer(long addr, int cap)创建
hasCleaner 对象: 由 new DirectByteBuffer(int cap)创建
释放内存的方式不同
noCleaner 对象: 使用 UnSafe.freeMemory(address);
hasCleaner 对象: 使用 DirectByteBuffer 的 Cleaner 的 clean() 方法
note:Unsafe 是位于 sun.misc 包下的一个类, 可以提供内存操作, 对象操作, 线程调度等本地方法, 这些方法在提升 Java 运行效率, 增强 Java 语言底层资源操作能力方面起到了很大的作用, 但不正确使用 Unsafe 类会使得程序出错的概率变大, 程序不再 "安全", 因此官方不推荐使用, 并可能在未来的 jdk 版本移除
Netty 在启动时需要判断检查当前环境, 环境配置参数是否允许 noCleaner 策略(具体逻辑位于 PlatformDependent 的 static 代码块), 例如运行在 Android 下时, 是没有 Unsafe 类的, 不允许使用 noCleaner 策略, 如果不允许, 则使用 hasCleaner 策略
note: 可以调用 PlatformDependent.useDirectBufferNoCleaner()方法查看当前 Netty 程序是否使用 noClaner 策略
ByteBuf.release()触发机制
业界有一种误解认为 Netty 框架分配的 ByteBuf, 框架会自动释放, 业务不需要释放; 业务创建的 ByteBuf 则需要自己释放, Netty 框架不会释放
产生这种误解是有原因的, Netty 框架是会在一些场景调用 ByteBuf.release()方法:
1 入站消息处理
当处理入站消息时, Netty 会创建 ByteBuf 读取 channel 上的消息, 并触发调用 pipeline 上的 ChannelHandler 处理, 应用程序定义的使用 ByteBuf 的 ChannelHandler 需要负责 release()
- public void channelRead(ChannelHandlerContext ctx, Object msg) {
- ByteBuf buf = (ByteBuf) msg;
- try {
- ...
- } finally {
- buf.release();
- }
- }
如果该 ByteBuf 不由当前 ChannelHandler 处理, 则传递给 pipeline 上下一个 handler:
- public void channelRead(ChannelHandlerContext ctx, Object msg) {
- ByteBuf buf = (ByteBuf) msg;
- ...
- ctx.fireChannelRead(buf);
- }
常用的我们会通过继承 ChannelInboundHandlerAdapter 定义入站消息处理的 handler, 这种情况下如果所有程序的 hanler 都没有调用 release()方法, 该入站消息 Netty 最后并不会 release(), 会导致内存泄漏;
当在 pipeline 的 handler 处理中抛出异常之后, 最后 Netty 框架是会捕捉该异常进行 ByteBuf.release()的;
完整流程位于 AbstractNioByteChannel.NioByteUnsafe#read(), 下面抽取关键片段:
- try {
- do {
- byteBuf = allocHandle.allocate(allocator);
- allocHandle.lastBytesRead(doReadBytes(byteBuf));
- // 入站消息已读完
- if (allocHandle.lastBytesRead() <= 0) {
- // ...
- break;
- }
- // 触发 pipline 上 handler 进行处理
- pipeline.fireChannelRead(byteBuf);
- byteBuf = null;
- } while (allocHandle.continueReading());
- // ...
- } catch (Throwable t) {
- // 异常处理中包括调用 byteBuf.release()
- handleReadException(pipeline, byteBuf, t, close, allocHandle);
- }
不过, 常用的还有通过继承 SimpleChannelInboundHandler 定义入站消息处理, 在该类会保证消息最终被 release:
- @Override
- public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
- boolean release = true;
- try {
- // 该消息由当前 handler 处理
- if (acceptInboundMessage(msg)) {
- I imsg = (I) msg;
- channelRead0(ctx, imsg);
- } else {
- // 不由当前 handler 处理, 传递给 pipeline 上下一个 handler
- release = false;
- ctx.fireChannelRead(msg);
- }
- } finally {
- // 触发 release
- if (autoRelease && release) {
- ReferenceCountUtil.release(msg);
- }
- }
- }
2 出站消息处理
不同于入站消息是由 Netty 框架自动创建的, 出站消息通常由应用程序创建, 然后调用基于 channel 的 write()方法或 writeAndFlush()方法, 这些方法内部会负责调用传入的 byteBuf 的 release()方法
note: write()方法在 netty-4.0.0.CR2 前的版本存在问题, 不会调用 ByteBuf.release()
3 release()注意事项
(1) 引用计数
还有一种常见的误解就是, 只要调用了 ByteBuf 的 release()方法, 或者 ReferenceCountUtil.release()方法, 对象的内存就保证释放了, 其实不是
因为 Netty 的 ByteBuf 引用计数来管理 ByteBuf 对象的生命周期, ByteBuf 继承了 ReferenceCounted 接口, 对外提供 retain()和 release()方法, 用于增加或减少引用计数值, 当调用 release()方法时, 内部计数值被减为 0 才会触发内存回收动作
(2) derived ByteBuf
derived, 派生的意思, 在 ByteBuf.duplicate(), ByteBuf.slice() 和 ByteBuf.order(ByteOrder) 等方法会创建出 derived ByteBuf, 创建出来的 ByteBuf 与原有 ByteBuf 是共享引用计数的, 原有 ByteBuf 的 release()方法调用, 也会导致这些对象内存回收
相反 ByteBuf.copy() 和 ByteBuf.readBytes(int)方法创建出来的对象并不是 derived ByteBuf, 这些对象与原有 ByteBuf 不是共享引用计数的, 原有 ByteBuf 的 release()方法调用不会导致这些对象内存回收
堆外内存大小控制参数
配置堆外内存大小的参数有 - XX:MaxDirectMemorySize 和 - Dio.netty.maxDirectMemory, 这 2 个参数有什么区别?
-XX:MaxDirectMemorySize
用于限制 Netty 中 hasCleaner 策略的 DirectByteBuffer 堆外内存的大小, 默认值是 JVM 能从操作系统申请的最大内存, 如果内存本身没现在, 则值为 Long.MAX_VALUE 个字节 (默认值由 Runtime.getRuntime().maxMemory() 返回), 代码位于 java.nio.Bits#reserveMemory()方法中
note:-XX:MaxDirectMemorySize 无法限制 Netty 中 noCleaner 策略的 DirectByteBuffer 堆外内存的大小
-Dio.netty.maxDirectMemory
用于限制 noCleaner 策略下 Netty 的 DirectByteBuffer 分配的最大堆外内存的大小, 如果该值为 0, 则使用 hasCleaner 策略, 代码位于 PlatformDependent#incrementMemoryCounter()方法中
堆外内存监控
如何获取堆外内存的使用情况?
1 代码工具
(1) hasCleaner 的 DirectByteBuffer 监控
对于 hasCleaner 策略的 DirectByteBuffer,java.nio.Bits 类是有记录堆外内存的使用情况, 但是该类是包级别的访问权限, 不能直接获取, 可以通过 MXBean 来获取
note:MXBean,Java 提供的一系列用于监控统计的特殊 Bean, 通过不同类型的 MXBean 可以获取 JVM 进程的内存, 线程, 类加载信息等监控指标
- List<BufferPoolMXBean> bufferPoolMXBeans = ManagementFactoryHelper.getBufferPoolMXBeans();
- BufferPoolMXBean directBufferMXBean = bufferPoolMXBeans.get(0);
- // hasCleaner 的 DirectBuffer 的数量
- long count = directBufferMXBean.getCount();
- // hasCleaner 的 DirectBuffer 的堆外内存占用大小, 单位字节
- long memoryUsed = directBufferMXBean.getMemoryUsed();
note: MappedByteBuffer: 是基于 FileChannelImpl.map 进行进行 mmap 内存映射 (零拷贝的一种实现) 得到的另外一种堆外内存的 ByteBuffer, 可以通过 ManagementFactoryHelper.getBufferPoolMXBeans().get(1)获取到该堆外内存的监控指标
(2) noCleaner 的 DirectByteBuffer 监控
Netty 中 noCleaner 的 DirectByteBuffer 的监控比较简单, 直接通过 PlatformDependent.usedDirectMemory()访问即可
2 Netty 自带内存泄漏检测工具
Netty 也自带了内存泄漏检测工具, 可用于检测出 ByteBuf 对象被 GC 回收, 但 ByteBuf 管理的内存没有释放的情况, 但不适用 ByteBuf 对象还没被 GC 回收内存泄漏的情况, 例如任务队列积压
为了便于用户发现内存泄露, Netty 提供 4 个检测级别:
disabled 完全关闭内存泄露检测
simple 以约 1% 的抽样率检测是否泄露, 默认级别
advanced 抽样率同 simple, 但显示详细的泄露报告
paranoid 抽样率为 100%, 显示报告信息同 advanced
使用方法是在命令行参数设置:
-Dio.netty.leakDetectionLevel=[检测级别]
示例程序如下, 设置检测级别为 paranoid :
- // -Dio.netty.leakDetectionLevel=paranoid
- public static void main(String[] args) {
- for (int i = 0; i <500000; ++i) {
- ByteBuf byteBuf = UnpooledByteBufAllocator.DEFAULT.buffer(1024);
- byteBuf = null;
- }
- System.gc();
- }
可以看到控制台输出泄漏报告:
十二月 27, 2019 8:37:04 上午 io.netty.util.ResourceLeakDetector reportTracedLeak
严重: 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.UnpooledByteBufAllocator.newDirectBuffer(UnpooledByteBufAllocator.java:96)
- io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
- io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
- io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:115)
- org.caison.netty.demo.memory.BufferLeaksDemo.main(BufferLeaksDemo.java:15)
内存泄漏的原理是利用弱引用, 弱引用 (WeakReference) 创建时需要指定引用队列 (refQueue), 通过将 ByteBuf 对象用弱引用包装起来(代码入口位于 AbstractByteBufAllocator#toLeakAwareBuffer() 方法)
当发生 GC 时, 如果 GC 线程检测到 ByteBuf 对象只被弱引用对象关联, 会将该 WeakReference 加入 refQueue;
当 ByteBuf 内存被正常释放, 会调用 WeakReference 的 clear()方法解除对 ByteBuf 的引用, 后续 GC 线程不会再将该 WeakReference 加入 refQueue;
Netty 在每次创建 ByteBuf 时, 基于抽样率, 抽样命中时会轮询 (poll)refQueue 中的 WeakReference 对象, 轮询返回的非 null 的 WeakReference 关联的 ByteBuf 即为泄漏的堆外内存(代码入口位于 ResourceLeakDetector#track() 方法)
3 图形化工具
在代码获取堆外内存的基础上, 通过自定义接入一些监控工具定时检测获取, 绘制图形即可, 例如比较流行的 Prometheus 或者 Zabbix
也可以通过 jdk 自带的 Visualvm 获取, 需要安装 Buffer Pools 插件, 底层原理是访问 MXBean 中的监控指标, 只能获取 hasCleaner 的 DirectByteBuffer 的使用情况
此外, 对于 JNI 调用产生的堆外内存分配, 可以使用 google-perftools 进行监控
堆外内存泄漏诊断
堆外内存泄漏的具体原因比较多, 先介绍任务队列堆积的监控, 再介绍通用堆外内存泄漏诊断思路
1 任务队列堆积
这里的任务队列是值 NioEventLoop 中的 QueuetaskQueue, 提交到该任务队列的场景有:
(1) 用户自定义普通任务
ctx.channel().eventLoop().execute(runnable);
(2) 对 channel 进行写入
- channel.write(...)
- channel.writeAndFlush(...)
(3) 用户自定义定时任务
ctx.channel().eventLoop().schedule(runnable, 60, TimeUnit.SECONDS);
当队列中积压任务过多, 导致消息不能对对 channel 进行写入然后进行释放, 会导致内存泄漏
诊断思路是对任务队列中的任务数, 积压的 ByteBuf 大小, 任务类信息进行监控, 具体监控程序如下(代码地址 https://github.com/caison/caison-blog-demo/tree/master/netty-demo):
- public void channelActive(ChannelHandlerContext ctx) throws NoSuchFieldException, IllegalAccessException {
- monitorPendingTaskCount(ctx);
- monitorQueueFirstTask(ctx);
- monitorOutboundBufSize(ctx);
- }
- /** 监控任务队列堆积任务数, 任务队列中的任务包括 io 读写任务, 业务程序提交任务 */
- public void monitorPendingTaskCount(ChannelHandlerContext ctx) {
- int totalPendingSize = 0;
- for (EventExecutor eventExecutor : ctx.executor().parent()) {
- SingleThreadEventExecutor executor = (SingleThreadEventExecutor) eventExecutor;
- // 注意, Netty4.1.29 以下版本本 pendingTasks()方法存在 bug, 导致线程阻塞问题
- // 参考 https://github.com/netty/netty/issues/8196
- totalPendingSize += executor.pendingTasks();
- }
- System.out.println("任务队列中总任务数 =" + totalPendingSize);
- }
- /** 监控各个堆积的任务队列中第一个任务的类信息 */
- public void monitorQueueFirstTask(ChannelHandlerContext ctx) throws NoSuchFieldException, IllegalAccessException {
- Field singleThreadField = SingleThreadEventExecutor.class.getDeclaredField("taskQueue");
- singleThreadField.setAccessible(true);
- for (EventExecutor eventExecutor : ctx.executor().parent()) {
- SingleThreadEventExecutor executor = (SingleThreadEventExecutor) eventExecutor;
- Runnable task = ((Queue<Runnable>) singleThreadField.get(executor)).peek();
- if (null != task) {
- System.out.println("任务队列中第一个任务信息:" + task.getClass().getName());
- }
- }
- }
- /** 监控出站消息的队列积压的 byteBuf 大小 */
- public void monitorOutboundBufSize(ChannelHandlerContext ctx) {
- long outBoundBufSize = ((NioSocketChannel) ctx.channel()).unsafe().outboundBuffer().totalPendingWriteBytes();
- System.out.println("出站消息队列中积压的 buf 大小" + outBoundBufSize);
- }
note: 上面程序至少需要基于 Netty4.1.29 版本才能使用, 否则有性能问题
实际基于 Netty 进行业务开发, 耗时的业务逻辑代码应该如何处理?
先说结论, 建议自定义一组新的业务线程池, 将耗时业务提交业务线程池
Netty 的 worker 线程(NioEventLoop), 除了作为 NIO 线程处理连接数据读取, 执行 pipeline 上 channelHandler 逻辑, 另外还有消费 taskQueue 中提交的任务, 包括 channel 的 write 操作.
如果将耗时任务提交到 taskQueue, 也会影响 NIO 线程的处理还有 taskQueue 中的任务, 因此建议在单独的业务线程池进行隔离处理
2 通用诊断思路
Netty 堆外内存泄漏的原因多种多样, 例如代码漏了写调用 release(); 通过 retain()增加了 ByteBuf 的引用计数值而在调用 release()时引用计数值未清空; 因为 Exception 导致未能 release();ByteBuf 引用对象提前被 GC, 而关联的堆外内存未能回收等等, 这里无法全部列举, 所以尝试提供一套通用的诊断思路提供参考
首先, 需要能复现问题, 为了不影响线上服务的运行, 尽量在测试环境或者本地环境进行模拟. 但这些环境通常没有线上那么大的并发量, 可以通过压测工具来模拟请求
对于有些无法模拟的场景, 可以通过 Linux 流量复制工具将线上真实的流量复制到到测试环境, 同时不影响线上的业务, 类似工具有 Gor,tcpreplay,tcpcopy 等
能复现之后, 接下来就要定位问题所在, 先通过前面介绍的监控手段, 日志信息试试能不能直接找到问题所在;
如果找不到, 就需要定位出堆外内存泄漏的触发条件, 但有时应用程序比较庞大, 对外提供的流量入口很多, 无法逐一排查.
在非线上环境的话, 可以将流量入口注释掉, 每次注释掉一半, 然后再运行检查问题还是否还存在, 如果存在, 继续再注释掉剩下的一半, 通过这种二分法的策略通过几次尝试可以很快定位出触发问题触发条件
定位出触发条件之后, 再检查程序中在该触发条件处理逻辑, 如果该处理程序很复杂, 无法直接看出来, 还可以继续注释掉部分代码, 二分法排查, 直到最后找出具体的问题代码块
整套思路的核心在于, 问题复现, 监控, 排除法, 也可以用于排查其他问题, 例如堆内内存泄漏, CPU 100%, 服务进程挂掉等
总结
整篇文章侧重于介绍知识点和理论, 缺少实战环节, 这里分享一些优质博客文章:
《netty 堆外内存泄露排查盛宴》 闪电侠手把手带如何 debug 堆外内存泄漏
https://www.jianshu.com/p/4e96beb37935
《Netty 防止内存泄漏措施》,Netty 权威指南作者, 华为李林峰内存泄漏知识分享
https://mp.weixin.qq.com/s/IusIvjrth_bzvodhOMfMPQ
《疑案追踪: Spring Boot 内存泄露排查记》, 美团技术团队纪兵的案例分享
https://mp.weixin.qq.com/s/aYwIH0TN3nSzNaMR2FN0AA
《Netty 入门与实战: 仿写微信 IM 即时通讯系统》, 闪电侠的掘金小册(付费), 个人就是学这个专栏入门 Netty 的
https://juejin.im/book/5b4bc28bf265da0f60130116?referrer=598ff735f265da3e1c0f9643
来源: https://www.cnblogs.com/caison/p/12134285.html