Netty内存池泄漏问题

本文涉及的产品
性能测试 PTS,5000VUM额度
简介:
Netty是Java高性能网络编程的明星框架,本文选自 《Netty进阶之路:跟着案例学Netty》 一书,书中内容精选自1000多个一线业务实际案例,真正从原理到实践全景式讲解Netty项目实践。
为了提升消息接收和发送性能,Netty针对ByteBuf的申请和释放采用池化技术,通过PooledByteBufAllocator可以创建基于内存池分配的ByteBuf对象,这样就避免了每次消息读写都申请和释放ByteBuf。由于ByteBuf涉及byte[]数组的创建和销毁,对于性能要求苛刻的系统而言,重用ByteBuf带来的性能收益是非常可观的。

内存池是一把双刃剑,如果使用不当,很容易带来内存泄漏和内存非法引用等问题,另外,除了内存池,Netty同时也支持非池化的ByteBuf,多种类型的ByteBuf功能存在一些差异,使用不当很容易带来各种问题。

业务路由分发模块使用Netty作为通信框架,负责协议消息的接入和路由转发,在功能测试时没有发现问题,转性能测试之后,运行一段时间就发现内存分配异常,服务端无法接收请求消息,系统吞吐量降为0。

1 路由转发服务代码

作为案例示例,对业务服务路由转发代码进行简化,以方便分析:

public class RouterServerHandler extends ChannelInboundHandlerAdapter {
    static ExecutorService executorService = Executors.newSingleThreadExecutor();
    PooledByteBufAllocator allocator = new PooledByteBufAllocator(false);
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf reqMsg = (ByteBuf)msg;
        byte [] body = new byte[reqMsg.readableBytes()];
        executorService.execute(()->
        {
            //解析请求消息,做路由转发,代码省略
            //转发成功,返回响应给客户端
            ByteBuf respMsg = allocator.heapBuffer(body.length);
            respMsg.writeBytes(body);//作为示例,简化处理,将请求返回
            ctx.writeAndFlush(respMsg);
        });
    }
  //后续代码省略
}

进行一段时间的性能测试之后,日志中出现异常,进程内存不断飙升,怀疑存在内存泄漏问题,如图1所示。

b70baedf4aace72862edb5f0dc89c1eebc2d02a2
图1 性能测试异常日志

2 响应消息内存释放玄机

对业务ByteBuf申请相关代码进行排查,发现响应消息由业务线程创建,但是却没有主动释放,因此怀疑是响应消息没有释放导致的内存泄漏。因为响应消息使用的是PooledHeapByteBuf,如果发生内存泄漏,利用堆内存监控就可以找到泄漏点,通过Java VisualVM工具观察堆内存占用趋势,并没有发现堆内存发生泄漏,如图2所示。

dfa074c007bf3d050ac40e1a4bb6a6381c6bb4c5
图2 业务堆内存监控数据

对内存做快照,查看在性能压测过程中响应消息PooledUnsafeHeapByteBuf的实例个数,如图3所示,响应消息对象个数和内存占用都很少,排除内存泄漏嫌疑。

c72e6618873655504d7dbfdcffeddccff4acfb7e
图3 业务堆内存快照

业务从内存池中申请了ByteBuf,但是却没有主动释放它,最后也没有发生内存泄漏,这究竟是什么原因呢?通过对Netty源码的分析,我们破解了其中的玄机。原来调用ctx.writeAndFlush(respMsg)方法时,当消息发送完成,Netty框架会主动帮助应用释放内存,内存的释放分为如下两种场景。

(1)如果是堆内存(PooledHeapByteBuf),则将HeapByteBuffer转换成DirectByteBuffer,并释放PooledHeapByteBuf到内存池,代码如下(AbstractNioChannel类):

protected final ByteBuf newDirectBuffer(ByteBuf buf) { 
    final int readableBytes = buf.readableBytes(); 
    if (readableBytes == 0) { 
        ReferenceCountUtil.safeRelease(buf); 
        return Unpooled.EMPTY_BUFFER; 
    } 
    final ByteBufAllocator alloc = alloc(); 
    if (alloc.isDirectBufferPooled()) { 
        ByteBuf directBuf = alloc.directBuffer(readableBytes); 
        directBuf.writeBytes(buf, buf.readerIndex(), readableBytes); 
        ReferenceCountUtil.safeRelease(buf); 
        return directBuf; 
    }    } 
 //后续代码省略 
} 

如果消息完整地被写到SocketChannel中,则释放DirectByteBuffer,代码如下(ChannelOutboundBuffer):

public boolean remove() { 
    Entry e = flushedEntry; 
    if (e == null) { 
        clearNioBuffers(); 
        return false; 
    } 
    Object msg = e.msg; 
    ChannelPromise promise = e.promise; 
    int size = e.pendingSize; 
    removeEntry(e); 
    if (!e.cancelled) { 
        ReferenceCountUtil.safeRelease(msg); 
        safeSuccess(promise); 
        decrementPendingOutboundBytes(size, false, true); 
    } 
 //后续代码省略 
} 

对Netty源码进行断点调试,验证上述分析。

断点1:在响应消息发送处设置断点,获取到的PooledUnsafeHeapByteBuf实例的ID为1506,如图4所示。

c4f4f4f6b7cd924e9424f9d80e8622fa22b2f1fd
图4 在响应消息发送处设置断点

断点2:在HeapByteBuffer转换成DirectByteBuffer处设置断点,发现实例ID为1506的PooledUnsafeHeapByteBuf被释放,如图5所示。

ceac649f8b4e64df1f61b5079e2259b711c7e715
图5 在响应消息释放处设置断点

断点3:转换之后待发送的响应消息PooledUnsafeDirectByteBuf实例的ID为1527,如图6所示。

e89dd1b5a7ec4c887708db5245b4bc16aec6aded
图6 在响应消息转换处设置断点

断点4:在响应消息发送完成后,实例ID为1527的PooledUnsafeDirectByteBuf被释放到内存池中,如图7所示。

1d38ce4f3683a298cc43cb9b026a13d8f56f43ac
图7 在转换之后的响应消息释放处设置断点

(2)如果是DirectByteBuffer,则不需要转换,在消息发送完成后,由ChannelOutboundBuffer的remove()负责释放。

通过源码解读、调试及堆内存的监控分析,可以确认不是响应消息没有主动释放导致的内存泄漏,需要Dump内存做进一步定位。

3 采集堆内存快照分析

执行jmap命令,Dump应用内存堆栈,如图8所示。

42992e82f00806d50010574a5fa59f2fe299cc7f
图8 Dump应用内存堆栈的命令

通过MemoryAnalyzer工具对内存堆栈进行分析,寻找内存泄漏点,如图9所示。

从图9可以看出,内存泄漏点是Netty内存池对象PoolChunk,由于请求和响应消息内存分配都来自PoolChunk,暂时还不确认是请求还是响应消息导致的问题。进一步对代码进行分析,发现响应消息使用的是堆内存HeapByteBuffer,请求消息使用的是DirectByteBuffer,由于Dump出来的是堆内存,如果是堆内存泄漏,Dump出来的内存文件应该包含大量的PooledHeapByteBuf,实际上并没有,因此可以确认系统发生了堆外内存泄漏,即请求消息没有被释放或者没有被及时释放导致的内存泄漏。
b798d3aa9df79c1ca7b2dcc2d49f0820d01747e7
图9 寻找内存泄漏点

对请求消息的内存分配进行分析,发现在NioByteUnsafe的read方法中申请了内存,代码如下(NioByteUnsafe):

public final void read() { 
    final ChannelConfig config = config(); 
    if (shouldBreakReadReady(config)) { 
        clearReadPending(); 
        return; 
    } 
    final ChannelPipeline pipeline = pipeline(); 
    final ByteBufAllocator allocator = config.getAllocator(); 
    final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle(); 
    allocHandle.reset(config); 
    ByteBuf byteBuf = null; 
    boolean close = false; 
    try { 
        do { 
            byteBuf = allocHandle.allocate(allocator); 
//代码省略 

继续对allocate方法进行分析,发现调用的是DefaultMaxMessagesRecvByteBuf- Allocator$MaxMessageHandle的allocate方法,代码如下(DefaultMaxMessagesRecvByteBuf- Allocator):

public ByteBuf allocate(ByteBufAllocator alloc) { 
    return alloc.ioBuffer(guess()); 
} 

alloc.ioBuffer方法最终会调用PooledByteBufAllocator的newDirectBuffer方法创建PooledDirectByteBuf对象。

请求ByteBuf的创建分析完,继续分析它的释放操作,由于业务的RouterServerHandler继承自ChannelInboundHandlerAdapter,它的channelRead(ChannelHandlerContext ctx, Object msg)方法执行完成,ChannelHandler的执行就结束了,代码示例如下:

@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) { 
    ByteBuf reqMsg = (ByteBuf)msg; 
    byte [] body = new byte[reqMsg.readableBytes()]; 
    executorService.execute(()-> 
    { 
        //解析请求消息,做路由转发,代码省略 
        //转发成功,返回响应给客户端 
        ByteBuf respMsg = allocator.heapBuffer(body.length); 
        respMsg.writeBytes(body);//作为示例,简化处理,将请求返回 
        ctx.writeAndFlush(respMsg); 
    }); 
//后续代码省略 

通过代码分析发现,请求ByteBuf被Netty框架申请后竟然没有被释放,为了验证分析,在业务代码中调用ReferenceCountUtil的release方法进行内存释放操作,代码修改如下:

@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) { 
    ByteBuf reqMsg = (ByteBuf)msg;byte [] body = new byte[reqMsg.readableBytes()]; 
    ReferenceCountUtil.release(reqMsg); 
//后续代码省略 

修改之后继续进行压测,发现系统运行平稳,没有发生OOM异常。对内存活动对象进行排序,没有再发现大量的PoolChunk对象,内存泄漏问题解决,问题修复之后的内存快照如图10所示。
455c094ad09af9be568ba1412dd4af53c34fd231
图10 问题修复之后的内存快照

4 ByteBuf申请和释放的理解误区

有一种说法认为Netty框架分配的ByteBuf框架会自动释放,业务不需要释放;业务创建的ByteBuf则需要自己释放,Netty框架不会释放。

通过前面的案例分析和验证,我们可以看出这个观点是错误的。为了在实际项目中更好地管理ByteBuf,下面我们分4种场景进行说明。

1.基于内存池的请求ByteBuf

这类ByteBuf主要包括PooledDirectByteBuf和PooledHeapByteBuf,它由Netty的NioEventLoop线程在处理Channel的读操作时分配,需要在业务ChannelInboundHandler处理完请求消息之后释放(通常在解码之后),它的释放有两种策略。

策略1 业务ChannelInboundHandler继承自SimpleChannelInboundHandler,实现它的抽象方法channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf的释放业务不用关心,由SimpleChannelInboundHandler负责释放,相关代码如下(SimpleChannelInboundHandler):

@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) throws
Exception { 
    boolean release = true; 
    try { 
        if (acceptInboundMessage(msg)) { 
            I imsg = (I) msg; 
            channelRead0(ctx, imsg); 
        } else { 
            release = false; 
            ctx.fireChannelRead(msg); 
        } 
    } finally { 
        if (autoRelease && release) { 
            ReferenceCountUtil.release(msg); 
        } 
    } 
} 

如果当前业务ChannelInboundHandler需要执行,则调用channelRead0之后执行ReferenceCountUtil.release(msg)释放当前请求消息。如果没有匹配上需要继续执行后续的ChannelInboundHandler,则不释放当前请求消息,调用ctx.fireChannelRead(msg)驱动ChannelPipeline继续执行。

对案例中的问题代码进行修改,继承自SimpleChannelInboundHandler,即便业务不释放请求的ByteBuf对象,依然不会发生内存泄漏,修改之后的代码如下(RouterServerHandlerV2):

public class RouterServerHandlerV2 extends SimpleChannelInboundHandler <ByteBuf> { 
    //代码省略 
    @Override 
    public void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { 
        byte [] body = new byte[msg.readableBytes()]; 
        executorService.execute(()-> 
        { 
            //解析请求消息,做路由转发,代码省略
            //转发成功,返回响应给客户端 
            ByteBuf respMsg = allocator.heapBuffer(body.length); 
            respMsg.writeBytes(body);//作为示例,简化处理,将请求返回 
            ctx.writeAndFlush(respMsg); 
        }); 
   } 

对修改之后的代码做性能测试,发现内存占用平稳,无内存泄漏问题,验证了之前的分析结论。

策略2 在业务ChannelInboundHandler中调用ctx.fireChannelRead(msg)方法,让请求消息继续向后执行,直到调用DefaultChannelPipeline的内部类TailContext,由它来负责释放请求消息,代码如下(TailContext):

protected void onUnhandledInboundMessage(Object msg) { 
    try { 
        logger.debug( 
        "Discarded inbound message {} that reached at the tail of the pipeline. " + 
        "Please check your pipeline configuration.", msg); 
    } finally { 
        ReferenceCountUtil.release(msg); 
    } 
} 

2.基于非内存池的请求ByteBuf

如果业务使用非内存池模式覆盖Netty默认的内存池模式创建请求ByteBuf,例如通过如下代码修改内存申请策略为Unpooled:

//代码省略 
.childHandler(new ChannelInitializer<SocketChannel>() { 
    @Override 
    public void initChannel(SocketChannel ch) throws Exception { 
        ChannelPipeline p = ch.pipeline(); 
        ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);
        p.addLast(new RouterServerHandler()); 
    } 
 }); 
} 

也需要按照内存池的方式释放内存。

3.基于内存池的响应ByteBuf

根据之前的分析,只要调用了writeAndFlush或者flush方法,在消息发送完成后都会由Netty框架进行内存释放,业务不需要主动释放内存。

4.基于非内存池的响应ByteBuf

无论是基于内存池还是非内存池分配的ByteBuf,如果是堆内存,则将堆内存转换成堆外内存,然后释放HeapByteBuffer,待消息发送完成,再释放转换后的DirectByteBuf;如果是DirectByteBuffer,则不需要转换,待消息发送完成之后释放。因此对于需要发送的响应ByteBuf,由业务创建,但是不需要由业务来释放。


本文选自 Netty进阶之路:跟着案例学Netty 一书,作者李林锋 ,在书中“Netty内存池泄漏疑云案例”分析中,更详细介绍了ByteBuf的申请和释放策略,以及Netty 内存池的工作原理。
8746554052f437517ec749ccfc2bc71322f0d718
相关实践学习
通过性能测试PTS对云服务器ECS进行规格选择与性能压测
本文为您介绍如何利用性能测试PTS对云服务器ECS进行规格选择与性能压测。
相关文章
|
4月前
|
消息中间件 缓存 算法
基于Netty的自研流系统缓存实现挑战: 内存碎片与OOM困境
基于Netty的自研流系统缓存实现挑战: 内存碎片与OOM困境
53 1
基于Netty的自研流系统缓存实现挑战: 内存碎片与OOM困境
|
运维 Java
高并发下Netty4底层bug导致直接内存溢出分析
高并发下Netty4底层bug导致直接内存溢出分析
189 0
|
机器学习/深度学习 Java Apache
深入Netty源码解析内存优化技巧
深入Netty源码解析内存优化技巧
133 0
深入Netty源码解析内存优化技巧
|
缓存 Java
感悟优化——Netty对JDK缓冲区内存池零拷贝的改造
NIO中缓冲区是数据传输的基础,JDK通过ByteBuffer实现,Netty框架中并未采用JDK原生的ByteBuffer,而是构造了ByteBuf。 ByteBuf对ByteBuffer做了大量的优化,比如说内存池,零拷贝,引用计数(不依赖GC),本文主要是分析这些优化,学习这些优化思想,学以致用,在实际工程中,借鉴这些优化方案和思想。
3314 0
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
13497 1
|
6月前
|
消息中间件 Oracle Dubbo
Netty 源码共读(一)如何阅读JDK下sun包的源码
Netty 源码共读(一)如何阅读JDK下sun包的源码
127 1
|
11月前
|
NoSQL Java Redis
跟着源码学IM(十二):基于Netty打造一款高性能的IM即时通讯程序
关于Netty网络框架的内容,前面已经讲了两个章节,但总归来说难以真正掌握,毕竟只是对其中一个个组件进行讲解,很难让诸位将其串起来形成一条线,所以本章中则会结合实战案例,对Netty进行更深层次的学习与掌握,实战案例也并不难,一个非常朴素的IM聊天程序。 原本打算做个多人斗地主练习程序,但那需要织入过多的业务逻辑,因此一方面会带来不必要的理解难度,让案例更为复杂化,另一方面代码量也会偏多,所以最终依旧选择实现基本的IM聊天程序,既简单,又能加深对Netty的理解。
160 1
|
6月前
|
编解码 前端开发 网络协议
Netty Review - ObjectEncoder对象和ObjectDecoder对象解码器的使用与源码解读
Netty Review - ObjectEncoder对象和ObjectDecoder对象解码器的使用与源码解读
154 0
|
6月前
|
编解码 安全 前端开发
Netty Review - StringEncoder字符串编码器和StringDecoder 解码器的使用与源码解读
Netty Review - StringEncoder字符串编码器和StringDecoder 解码器的使用与源码解读
247 0