解密Netty高性能之谜:NioEventLoop线程池阻塞分析与调优策略
# 写在文章开头
我们使用NioEventLoop常会出现一个奇怪的现象,在消息密集的情况下,服务端处理会断断续续的,偶发出现消息处理阻塞,经过不断的摸索排查发现是线程池使用不当导致的,遂此文简单介绍一下这个故障的现象和排查思路。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 详解NioEventLoop阻塞问题分析与解决过程
# 故障复现
在演示代码之前,我们不妨先来了解一下这个需求,客户端和服务端建立连接之后,会向该通道不断发送消息。然后服务端收到消息,会将消息提交到业务线程池中异步处理:

# 客户端代码实现分析
先来看看客户端的connect代码,就是一套标准的模板代码,设置好对应参数以及业务处理器之后,直接向服务端的9999端口发起连接:
public class NettyClient {
public void connect() throws Exception {
EventLoopGroup group = new NioEventLoopGroup(8);
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
//业务处理器
ch.pipeline().addLast(new NettyClientHandler());
}
});
ChannelFuture f = b.connect("127.0.0.1", 9999).sync();
//......
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
对应我们给出客户端处理器的代码,和服务端建立了连接之后,创建一个线程,无限循环,每次刷一个消息就休息1ms:
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
static final int MSG_SIZE = 256;
@Override
public void channelActive(ChannelHandlerContext ctx) {
new Thread(() -> {
//无限循环,每隔一毫秒发送一次消息
while (true) {
ByteBuf firstMessage = Unpooled.buffer(MSG_SIZE);
for (int i = 0; i < firstMessage.capacity(); i++) {
firstMessage.writeByte((byte) i);
}
//刷一次消息后休眠1ms
ctx.writeAndFlush(firstMessage);
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (Exception e) {
//......
}
}
}).start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 服务端处理逻辑分析
而服务端启动类也比较简单,就是一套比较经典的NIO模板:
public class NettyServer {
public static void main(String[] args) throws Exception {
//声明主从reactor
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 {
//追加业务处理器NettyServerHandler
ChannelPipeline p = ch.pipeline();
p.addLast(new NettyServerHandler());
}
});
//监听9999端口
ChannelFuture f = b.bind(9999).sync();
//......
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
NettyServerHandler 处理器的逻辑也比较简单,简单的将消息提交到业务线程池中执行即可,注意笔者代码中的一行代码Thread.currentThread() == ctx.channel().eventLoop()这就是后续问题引发的关键:
public class NettyServerHandler extends ChannelInboundHandlerAdapter {
private static AtomicInteger sum = new AtomicInteger(0);
//设置一个最大线程数为3的线程池,当线程处理不过来的时候采用CallerRunsPolicy策略
private static ExecutorService executorService = new ThreadPoolExecutor(1, 3, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
public void channelRead(ChannelHandlerContext ctx, Object msg) {
//原子类记录收到消息数以及打印消息时间
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
String date = simpleDateFormat.format(new Date());
System.out.println("--> Server receive client message : " + sum.incrementAndGet() + "time: " + date);
//将消息提交到业务线程池中处理
executorService.execute(() -> {
ByteBuf req = (ByteBuf) msg;
//如果当前执行线程是nio线程则休眠15s
if (Thread.currentThread() == ctx.channel().eventLoop())
try {
TimeUnit.SECONDS.sleep(15);
} catch (Exception e) {
e.printStackTrace();
}
//转发消息,此处代码省略,转发成功之后返回响应给终端
ctx.writeAndFlush(req);
});
}
//......
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
自此我们的代码都编写完成,我们不妨将服务端和客户端代码都启动。通过控制台可以发现,1毫秒发送的消息,会时不时的卡15s才能继续处理消息。

# 排查思路
这类问题我们用jvisualvm看看GC情况是否正常,看看是不是频繁的Full GC导致整个进程处于STW状态导致消息任务阻塞。
监控结果如下,很明显GC没有问题,我们只能看看CPU使用情况。

很明显的CPU使用情况也是正常,没有什么奇奇怪怪的任务导致使用率飙升。

所以我们只能看看线程使用情况了,果然,我们发现NioEventLoop居然长时间的处于休眠状态:

所以我们用jps定位Java进程id后键入jstack查看线程使用情况
jstack -l 17892
自此我们终于找到了线程长期休眠的原因,从下面的堆栈我们可以看出,正是任务量巨大,导致业务线程池无法及时处理消息,最终业务线程池走到了拒绝策略,这就使得业务线程池一直走到CallerRunsPolicy,也就是说业务线程池忙不过来的时候会将任务交由NioEventLoop执行。而一个连接只会有一个NioEventLoop的线程执行,使得原本非常忙碌的NioEventLoop还得分神处理一下我们业务线程池的任务。

为了验证这一点,我们不妨在业务线程池中打印线程名:
//将消息提交到业务线程池中处理
executorService.execute(() -> {
System.out.println(" executorService execute thread name: "+Thread.currentThread().getName());
ByteBuf req = (ByteBuf) msg;
//其它业务逻辑处理,访问数据库
if ((Thread.currentThread() == ctx.channel().eventLoop()))
try {
//访问数据库,模拟偶现的数据库慢,同步阻塞15秒
TimeUnit.SECONDS.sleep(15);
} catch (Exception e) {
e.printStackTrace();
}
//转发消息,此处代码省略,转发成功之后返回响应给终端
ctx.writeAndFlush(req);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
最终我们可以看到,线程池中的任务都被nioEventLoopGroup这个线程执行,所以这也是笔者为什么在模拟问题时在if中增加 (Thread.currentThread() == ctx.channel().eventLoop())的原因,就是为了模仿那些耗时的业务被nioEventLoopGroup的线程执行的情况,例如:一个耗时需要15s的任务刚刚好因为拒绝策略被nioEventLoopGroup执行,那么Netty服务端的消息处理自然就会阻塞,出现本文所说的问题。

# 解决方案
从上文的分析中我们可以得出下面这样一个结果,所以解决该问题的方式又两种:
- 调整业务线程池大小,提升线程池处理效率并适当增加队列长度。
- 调整拒绝策略,处理不过来时直接丢弃。
以笔者为例,结合各种耗时工具排查后发现夯住线程池的业务功能存在可以优化的空间,所以将功能优化后结合arthas等工具大体可以定位到阻塞队列稳定的消息数,最终给的策略就是优化功能代码+调大阻塞队列和最大线程数:

对应我们给出线程池优化后的参数,整体上又优化了任务处理速度避免了线程池夯住:
//调大阻塞队列
private static ExecutorService executorService = new ThreadPoolExecutor(1, 8, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10_0000), new ThreadPoolExecutor.CallerRunsPolicy());
2
3
自此之后我们再查看控制台输出和NioEventLoop线程状态,发现运行都没有阻塞,那些实在无法处理的消息都被丢弃了:

# 总结
自此我们对于本次的事件总结出以下几点要求和建议:
- 耗时操作不要用
NioEventLoop,尤其是本次这种高并发且拒绝策略配置为用执行线程接收忙碌任务的方式。 - 服务端收不到消息时,建议优先从CPU、GC、线程等角度分析问题。
- 服务端开发时建议使用两个
NioEventLoop构成主从Reactor模式,并结合业务场景压测出合适的线程数。
我是 sharkchili ,CSDN Java 领域博客专家,mini-redis的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 参考
Java性能调优 6步实现项目性能升级:https://coding.imooc.com/class/442.html (opens new window)