前言
在上一篇文章来聊聊Netty消息发送的那些事我们了解了Netty消息发送时对于并发场景消息未做流控导致的OOM问题,这一篇文章我们不妨来聊一下消息处理不当导致的OOM问题。
问题复现
我们先来说一下需求吧,客户端发送消息给服务端,我们的netty服务端会将按照路由地址转发到后台中,而转发这一步是交给另一个线程处理的。
如下图,可以看到服务端会原原本本将消息通过另一个线程池将消息发送给后台某个处理器,然后服务端收到处理器的结果后,直接将处理器的响应发送给客户端。
由此我们开始编写我们的代码,先来看看服务端启动类,可以看到就是一套标准的模板,启动后直接监听9999端口。
public class ApiGatewayServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
// 业务处理器
p.addLast(new ApiGatewayServerHandler());
}
});
//服务端阻塞监听9999端口
ChannelFuture f = b.bind(9999).sync();
f.channel().closeFuture().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
});
}
}
来看看业务处理器,首先我们通过创建一个和消息大小一样的字节数组模拟消息处理,然后提交到异步线程池中模拟根据路由转发到后台处理器。
public class ApiGatewayServerHandler extends ChannelInboundHandlerAdapter {
ExecutorService executorService = Executors.newFixedThreadPool(8);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg);
//模拟获取并解析消息,这里仅仅只创建一个字节数组
ByteBuf buf = (ByteBuf) msg;
char[] req = new char[buf.readableBytes()];
//休眠1s模拟提交到异步线程交予后台处理器处理
executorService.execute(() -> {
char[] dispatchReq = req;
try {
//简单处理后转发给后端服务
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
});
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
再来看看客户端的代码,启动类的逻辑也很简单,直接连接9999端口。
public class ApiGatewayClient {
static final int MSG_SIZE = 256;
public void connect() throws Exception {
EventLoopGroup group = new NioEventLoopGroup(1);
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
// 业务处理器
ch.pipeline().addLast(new ApiGatewayClientHandler());
}
});
//异步连接9999端口
ChannelFuture f = b.connect("127.0.0.1", 9999).sync();
f.channel().closeFuture().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
group.shutdownGracefully();
}
});
}
public static void main(String[] args) throws Exception {
new ApiGatewayClient().connect();
}
}
而业务处理器的逻辑也很简单:
- 建立连接就发一初始化就创建好的消息。
- 收到服务端的消息则直接将消息转发回去。
public class ApiGatewayClientHandler extends ChannelInboundHandlerAdapter {
private final ByteBuf firstMessage;
public ApiGatewayClientHandler() {
//初始化往firstMessage塞256字节的数据
firstMessage = Unpooled.buffer(ApiGatewayClient.MSG_SIZE);
for (int i = 0; i < firstMessage.capacity(); i ++) {
firstMessage.writeByte((byte) i);
}
}
/**
* 建立连接后直接将firstMessage发出去
* @param ctx
*/
@Override
public void channelActive(ChannelHandlerContext ctx) {
ctx.writeAndFlush(firstMessage);
}
//收到服务端的消息会将消息原路返回去
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
了解整个需求和编码之后,我们将服务端客户端分别启动,我们通过jvisualvm监控服务端发现,老年代GC十分频繁。
同时我们也会看到CPU使用率直接飙升再下降,这就意味着垃圾收集跟不上对象创建速度导致OOM了。
如下图,我们的服务端代码确实出现了内存溢出问题。
排查思路
对此我们不妨把服务端进程的内存快照导出。
jmap -dump:format=b,file=e:/oom.hprof 1328
用mat打开时发现,问题发生在我们的模拟路由转发处理消息的线程池里面。
继续查看domain tree,有大量的数据阻塞在线程池的队列中,队列中的数据是一串lambda也就是我们的提交时的任务,而任务里面是一个char类型的对象,很明显就是我们的消息。
解决方案
定位到了问题,我们不妨看看代码,这个channelRead咋一看没有什么问题,但是我们把高并发场景带入思考一下就会发现端倪。
高并发情况下,线程池中势必会堆积任务,堆积任务中最占内存的无非就是dispatchReq。而dispatchReq 来自于Netty线程池中的req。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg);
ByteBuf buf = (ByteBuf) msg;
char[] req = new char[buf.readableBytes()];
executorService.execute(() -> {
char[] dispatchReq = req;
try {
//简单处理后转发给后端服务
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
});
}
所以我们可以得出什么结论呢?如下图,我们的代码使得NioEventLoop的线程指向一个数组,按照原有逻辑这个数组会在方法结束后立刻从引用就会立刻断开。
但是,业务线程通过NioEventLoop的线程的引用找到的char数组,在高并发场景下,业务线程执行是非常耗时的,所以NioEventLoop中的线程因为char数组被业务线程池指向,而业务线程池处理非常耗时,导致当前空间迟迟无法释放,最终消息不断堆积造成OOM。
而解决方式也很简单,业务线程的结果直接根据nio线程的读写进行的信息进行消息提取,这样一来,只有业务线程池开始执行任务的时候才会创建数组,随着任务的消亡,数组的内存也就释放了。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg);
//nio线程负责读写
int len = ((ByteBuf) msg).readableBytes();
//线程池根据读写结果创建一个任务,这样一来char数组就跟随任务创建而诞生,根据任务结束而消亡
executorService.execute(() -> {
char[] dispatchReq = new char[len];
try {
//简单处理后转发给后端服务
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
});
}
我们再次启动项目,然后使用jvisualvm进行监控,查看CPU使用率还算正常。
查看gc情况,可以发现业务线程池创建的堆内存数据由于随着任务出现和出现,随着任务消失而可以被销毁,单位时间内也只会出现业务线程数个数组,且数组基本只会存在1s左右,不存在什么堆积,基本可以在年轻代完成gc。
小结
面对此类问题,我们尽可能遵守以下几个原则:
- 尽量不要跨线程引用。
- 根据实际情况分配内存空间。
- 遇到oom问题尽可能调小内存尽快重现问题。
- 多次导出内存快照参考多方面数据推测原因,找到引用关系,再进行调试解决。