博文视点 2018-11-27 10:25:54 浏览 3 评论 0
内存泄漏
性能测试
void
netty
摘要: Netty 是 Java 高性能网络编程的明星框架, 本文选自《Netty 进阶之路: 跟着案例学 Netty》一书, 书中内容精选自 1000 多个一线业务实际案例, 真正从原理到实践全景式讲解 Netty 项目实践. 为了提升消息接收和发送性能, Netty 针对 ByteBuf 的申请和释放采用池化技术, 通过 PooledByteBufAllocator 可以创建基于内存池分配的 ByteBuf 对象, 这样就避免了每次消息读写都申请和释放 ByteBuf.
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 所示.
图 1 性能测试异常日志
2 响应消息内存释放玄机
对业务 ByteBuf 申请相关代码进行排查, 发现响应消息由业务线程创建, 但是却没有主动释放, 因此怀疑是响应消息没有释放导致的内存泄漏. 因为响应消息使用的是 PooledHeapByteBuf, 如果发生内存泄漏, 利用堆内存监控就可以找到泄漏点, 通过 Java VisualVM 工具观察堆内存占用趋势, 并没有发现堆内存发生泄漏, 如图 2 所示.
图 2 业务堆内存监控数据
对内存做快照, 查看在性能压测过程中响应消息 PooledUnsafeHeapByteBuf 的实例个数, 如图 3 所示, 响应消息对象个数和内存占用都很少, 排除内存泄漏嫌疑.
图 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 所示.
图 4 在响应消息发送处设置断点
断点 2: 在 HeapByteBuffer 转换成 DirectByteBuffer 处设置断点, 发现实例 ID 为 1506 的 PooledUnsafeHeapByteBuf 被释放, 如图 5 所示.
图 5 在响应消息释放处设置断点
断点 3: 转换之后待发送的响应消息 PooledUnsafeDirectByteBuf 实例的 ID 为 1527, 如图 6 所示.
图 6 在响应消息转换处设置断点
断点 4: 在响应消息发送完成后, 实例 ID 为 1527 的 PooledUnsafeDirectByteBuf 被释放到内存池中, 如图 7 所示.
图 7 在转换之后的响应消息释放处设置断点
(2) 如果是 DirectByteBuffer, 则不需要转换, 在消息发送完成后, 由 ChannelOutboundBuffer 的 remove() 负责释放.
通过源码解读, 调试及堆内存的监控分析, 可以确认不是响应消息没有主动释放导致的内存泄漏, 需要 Dump 内存做进一步定位.
3 采集堆内存快照分析
执行 jmap 命令, Dump 应用内存堆栈, 如图 8 所示.
图 8 Dump 应用内存堆栈的命令
通过 MemoryAnalyzer 工具对内存堆栈进行分析, 寻找内存泄漏点, 如图 9 所示.
从图 9 可以看出, 内存泄漏点是 Netty 内存池对象 PoolChunk, 由于请求和响应消息内存分配都来自 PoolChunk, 暂时还不确认是请求还是响应消息导致的问题. 进一步对代码进行分析, 发现响应消息使用的是堆内存 HeapByteBuffer, 请求消息使用的是 DirectByteBuffer, 由于 Dump 出来的是堆内存, 如果是堆内存泄漏, Dump 出来的内存文件应该包含大量的 PooledHeapByteBuf, 实际上并没有, 因此可以确认系统发生了堆外内存泄漏, 即请求消息没有被释放或者没有被及时释放导致的内存泄漏.
图 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 所示.
图 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》 https://item.jd.com/12458713.html
一书, 作者李林锋 , 在书中 "Netty 内存池泄漏疑云案例" 分析中, 更详细介绍了 ByteBuf 的申请和释放策略, 以及 Netty 内存池的工作原理.
来源: https://yq.aliyun.com/articles/673359