工作三年,为什么你还不会排查堆外内存泄漏?(下)

👈👈👈 欢迎点赞收藏关注哟

本文是Java故障案例分析的第三篇, 上一篇中我们分析了一个堆外内存泄漏问题: 工作三年,为什么你还不会排查堆外内存泄漏?(上)(文末附赠一个泄漏排查工具) - 掘金 (juejin.cn)(这篇文章里是使用NMT+PMAP解决的非Netty造成的内存泄漏), 而今天我们要聊的是另外一个非常令人头疼的问题------Netty堆外内存泄漏 。本文将带领大家跟我一起排查线上一个真实案例, 并且深入了解Netty堆外内存泄漏的根本原因,最后提供了解决这个问题的三个关键步骤。通过这篇文章,你将获得对堆外内存泄漏排查的深刻理解,并学会如何有效地预防和解决可能的泄漏问题.

结合上一篇文章, 如果面试官再问你堆外内存泄漏排查思路你应该知道怎么回答了吧😌。

如果不想看全文也没关系哦, 可以直接跳到结尾部分, 我帮你总结好了🕶.

一. 排查过程

起因是半夜接到机器内存不足告警电话, 机器宕机😣,于是爬起来解决问题,下面是排查过程.

1.1 初步定位

初步发现是我们监控系统所在机器内存不足,上机器排查,发现日志中有大量Netty内存泄漏日志:

vbnet 复制代码
LEAK: ByteBuf.release() was not called before it's garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option '-Dio.netty.leakDetectionLevel=advanced' or call ResourceLeakDetector.setLevel()

通过查阅相关文档和源码,当Netty分配的ByteBuf在GC前没有调用release方法就会记录这条泄漏日志,说明是在哪里没有释放,但是单凭这条记录我们无法判断是哪里出了问题,我们要确定两个问题🤔:

1、这些泄漏的Buffer是什么,是在哪里分配的?

2、这个Buffer最后是谁持有的?

按照上面Netty给我们的提示,我们可以设置-Dio.netty.leakDetectionLevel=advanced参数,Netty默认的io.netty.leakDetectionLevel参数是simple,只能输出一条简单的泄漏记录,而要输出更加详细的,需要调整level参数,因此这里我们按照提示设置为advanced上线后同时配置告警继续观察。

1.2 泄漏堆栈分析

果不其然,过了一段时间后,日志里又出现了泄漏日志,而且这次更加详细,包含了堆栈:

less 复制代码
[ERROR] epollEventLoopGroup-3-20 - 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:
#1:
        io.netty.buffer.AdvancedLeakAwareByteBuf.readByte(AdvancedLeakAwareByteBuf.java:401)
        com.dianping.cat.message.spi.codec.PlainTextMessageCodec$BufferHelper.read(PlainTextMessageCodec.java:485)
        com.dianping.cat.message.spi.codec.PlainTextMessageCodec.decodeLine(PlainTextMessageCodec.java:184)
        // ..省略部分输出
        io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:480)
        io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:378)
        io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986)
        io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        java.lang.Thread.run(Thread.java:745)
#2:
        io.netty.buffer.AdvancedLeakAwareByteBuf.readByte(AdvancedLeakAwareByteBuf.java:401)
        // ..省略部分输出
Created at:
        io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:402)
        io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:188)
        io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:124)
        io.netty.buffer.AbstractByteBuf.readBytes(AbstractByteBuf.java:871)
        com.dianping.cat.analysis.TcpSocketReceiver$MessageDecoder.decode(TcpSocketReceiver.java:155)
        io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:507)
        io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:446)
        io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:276)
		// ..省略部分输出
        io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:480)
        io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:378)
        io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986)
        io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        java.lang.Thread.run(Thread.java:745)
: 3 leak records were discarded because they were duplicates
: 337 leak records were discarded because the leak record count is targeted to 4. Use system property io.netty.leakDetection.targetRecords to increase the limit.

可以看到这些堆栈除了最近的访问记录,还有在哪里创建的,通过分析这下我们知道了:

1、这些Buffer是用户Java应用上报的埋点消息。

2、最后一次访问Buffer是在反序列化Buffer数据的时候。

这里简单介绍下我们的监控系统处理用户埋点的大致流程:

Netty记录的"泄漏记录"是我们通过ByteBuf的read/write等操作记录的, 比如:

java 复制代码
@Override
public ByteBuf readBytes(ByteBuf dst, int dstIndex, int length) {
    recordLeakNonRefCountingOperation(leak); // 这个方法记录当前堆栈
    return super.readBytes(dst, dstIndex, length);
}

@Override
public ByteBuf readBytes(byte[] dst) {
    recordLeakNonRefCountingOperation(leak);
    return super.readBytes(dst);
}

@Override
public ByteBuf readBytes(byte[] dst, int dstIndex, int length) {
    recordLeakNonRefCountingOperation(leak);
    return super.readBytes(dst, dstIndex, length);
}

@Override
public ByteBuf readBytes(ByteBuffer dst) {
    recordLeakNonRefCountingOperation(leak);
    return super.readBytes(dst);
}

但是根据我们的上述的流程,解析ByteBuf之后我们会送到另外一个线程异步存储,但是Netty告诉我们最后一次访问还是在反序列化的时候!所以泄漏一定发生在存储之前,反序列化之后 !(可能是在异步线程处理发生了某些异常没有catch 导致ByteBuf没有被释放).

好,缩小范围之后该怎么确定精确位置进行排查呢?

1.3 精准定位技巧:ByteBuf.touch()

这就有请ByteBuf提供的另外一个方法:touch()了:

java 复制代码
public abstract ByteBuf touch();

touch中Netty也会和read/write一样记录当前的堆栈,我们只需要在可能发生泄漏的地方加入touch就能在Netty的泄漏日志中看到这个ByteBuf最后是在哪里引用的, 就能解决了!问题解决了吗? 如解!

实际分析代码路径后,我们发现一个方法很可疑,但是这个方法(方法名就叫method 吧)比较复杂,我们需要加入多个touch,但这样就出现一个问题:最终在Netty日志里显示的堆栈顶层都是这个方法,我们无法知道是在这个方法的哪一个touch输出的,该怎么区分呢?

发现Netty 还提供了一个可以带参数的API ,这个API是什么呢?

java 复制代码
public abstract ByteBuf touch(Object hint);

通过查阅文档和源码:如果我们使用这个方法,就能在Netty输出的日志中带上这个hint.toString()返回的字符串,比如我们调用buffer.touch(-5), 那么Netty输出的日志:

我们在method方法里的关键地方都加上touch调用,并且每个地方的hint都不一样,最后终于定位到了原因!

下面是原因的大致描述:

监控系统每个小时会停止异步线程(通过设置线程相关状态为disable)和队列,创建新的异步线程和队列,但由于我们在放入队列时没有判断异步任务线程状态,每个小时初放入队列的ByteBuf没有被异步线程处理,最后造成内存泄漏!

这次给了我们一个不小的教训, 那么为了防止下次再出现内存泄漏, 我后面给出了一些建议和最佳实践.

二. 如何更好的避免Netty内存泄漏?

这一节我们主要讲如何通过正确的编码避免Netty内存泄漏, 但我们首先要弄清楚Netty堆外内存为什么会泄漏, 了解它的底层原理, 然后通过一些工程中的最佳实践来降低泄漏的可能性.

2.1 Netty堆外内存为什么会泄漏?

Netty创建的堆外内存基于JDK原生的DirectByteBuffer实现,在创建DirectByteBuffer时能够指定是否使用带Cleaner的构造器,指定Cleaner的话能在自身被回收时释放内存,因为其在创建过程中通过创建Cleaner对象------一个虚引用对象PhantomReference,在DirectByteBuffer被GC时,ReferenceHandler线程会对jdk.internal.ref.Cleaner对象专门处理,调用它的clean方法释放内存。

但是Netty创建的堆外内存使用的是noCleaner策略,即创建DirectByteBuffer通过反射调用不含Cleaner的构造器,这么做的主要原因是:

  • 要触发Cleaner的触发必须要等Buffer被GC,这个是不可控的.
  • 在使用Cleaner的构造器里,如果内存不足,会调用System.gc()方法主动触发GC,这种方式会造成性能问题.

因此在大部分情况下(至少在OpenJDK里)Netty会使用noCleaner策略创建DirectByteBuffer, 所以Netty回收Buffer的方式是通过主动release释放的, 而如果没有正确release就会造成内存泄漏 . 但并不是每次release都会真正释放, 具体释放的时机和引用它的计数有关, 因为Netty是使用对象的引用计数来管理对象的生命周期, 而下面我们就来探讨引用计数.

2.2 ByteBuf的生命周期管理--引用计数

Netty使用对象的引用计数来管理对象的生命周期,当一个对象使用完成了之后要把它归还给池子(或者allocator)。ByteBuf就是使用引用计数最好的例子。

初始分配的对象引用计数为1,当调用release方法时,计数减一。

java 复制代码
// 初始分配的对象引用计数为1
ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;

// 当调用release方法时,计数减一
boolean destroyed = buf.release();
assert destroyed;
assert buf.refCnt() == 0;

当计数到0时,release方法会返回这个对象会被释放

如果尝试读取计数为0的对象, 会报出IllegalReferenceCountExeception 异常, 必须注意:

java 复制代码
assert buf.refCnt() == 0;
try {
  buf.writeLong(0xdeadbeef);
  throw new Error("should not reach here");
} catch (IllegalReferenceCountExeception e) {
  // Expected
}

也能通过retain增加计数

java 复制代码
ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;

buf.retain();
assert buf.refCnt() == 2;

boolean destroyed = buf.release();
assert !destroyed;
assert buf.refCnt() == 1;

看起来很简单, 但是一旦ByteBuf线程间传递时就不是那么简单了, 因为无法很好确定哪个线程负责释放, 下面我们给出一些可以遵循的规范, 能在最大程度上避免内存泄漏.

2.3 最佳实践: 释放的时机

了解了Netty的引用计数机制后, 下面我们给出一些最佳实践, 在工程中合理运用可以避免内存泄漏的发生:

  • 如果一个组件("组件"可以是一个函数或线程)A传递给组件B一个引用计数对象, 通常组件A不需要释放, 而是由B决定要不要释放.
  • 如果一个组件消费了这个引用计数对象而且它不会再传递给其他组件那么由这个组件来释放.

下面是一个例子:

java 复制代码
public ByteBuf a(ByteBuf input) {
    input.writeByte(42);
    return input;
}

public ByteBuf b(ByteBuf input) {
    try {
        output = input.alloc().directBuffer(input.readableBytes() + 1);
        output.writeBytes(input);
        output.writeByte(42);
        return output;
    } finally {
        input.release();
    }
}

public void c(ByteBuf input) {
    System.out.println(input);
    input.release();
}

public void main() {
    ...
    ByteBuf buf = ...;
    // This will print buf to System.out and destroy it.
    c(b(a(buf)));
    assert buf.refCnt() == 0;
}
Action 谁应该释放? 谁真正释放了?
main() 创建了 buf bufmain()
main() 调用了 a() 传入了 buf bufa()
a() 返回了 buf . bufmain()
main() 调用了b() 传入了buf bufb()
b() 返回了一个 buf的copy bufb(), copymain() b() 释放了buf
main() 调用了c() 传入了copy copyc()
c() 吃掉了 copy copyc() c() 释放了copy

参考: netty.io/wiki/refere...


四. Tips

1、可以通过"map -histo:live pid"手动触发FullGC,可以让泄漏日志更快打印出来否则要等到对应region回收的时候才能感知到。(Netty的泄漏日志是用WeakReference的原理触发的)

2、simpleadvanced级别下的泄漏日志都是采样触发的,采样频率是1/128,但是advanced级别的最近一条是一定记录的。


五. Netty相关参数说明

参数名称 含义 备注
io.netty.leakDetectionLevelio.netty.leakDetection.level 配置内存泄漏检测级别的参数, 默认simple io.netty.leakDetectionLevel是老版本的,推荐用io.netty.leakDetection.level
io.netty.leakDetection.targetRecords 如果当前保存的调用轨迹记录数Record大于参数io.netty.leakDetection.targetRecords配置的值,那么会以一定的概率(1/2^n)删除头结点之后再加入新的记录,当然也有可能不删除头结点直接新增新的, 默认4
io.netty.leakDetection.acquireAndReleaseOnly 控制是否只是在调用增加或减少引用计数器的方法时才调用record方法记录调用轨迹, 默认false
io.netty.leakDetection.samplingInterval 级别simpleadvanced下采样频率, 默认128

六. 总结

在这篇文章中,我们一起深入探讨了Netty堆外内存泄漏的根本原因以及解决方法。通过了解Netty的引用计数机制,我们发现内存泄漏的发生并非神秘莫测,而是可以通过合理的管理和释放引用计数来避免。

然而,预防胜于治疗。在文章末尾,我们强调了一些最佳实践和预防措施,希望读者在实际开发中能够更加注重内存管理,避免潜在的问题。

最后我们来总结下Netty内存泄漏的的排查方法, 分为三步:

1、提升泄漏日志输出级别为advancedjava -Dio.netty.leakDetection.level=advanced ...

2、如果还不能定位,在关键位置加入hint的调用

3、如果一个方法内需要定位的地方有多个,加入hint(任意字符串)


🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦

相关推荐
普通网友4 分钟前
KUD#73019
java·php·程序优化
IT_陈寒7 分钟前
Redis 性能翻倍的 5 个隐藏技巧,99% 的开发者都不知道第3点!
前端·人工智能·后端
JaguarJack8 分钟前
PHP 桌面端框架NativePHP for Desktop v2 发布!
后端·php·laravel
番茄Salad8 分钟前
自定义Spring Boot Starter项目并且在其他项目中通过pom引入使用
java·spring boot
程序员三明治21 分钟前
详解Redis锁误删、原子性难题及Redisson加锁底层原理、WatchDog续约机制
java·数据库·redis·分布式锁·redisson·watchdog·看门狗
自由的疯30 分钟前
Java 怎么学习Kubernetes
java·后端·架构
自由的疯30 分钟前
Java kubernetes
java·后端·架构
普通网友1 小时前
IZT#73193
java·php·程序优化
rechol1 小时前
C++ 继承笔记
java·c++·笔记
Han.miracle4 小时前
数据结构——二叉树的从前序与中序遍历序列构造二叉树
java·数据结构·学习·算法·leetcode