文章目录
- 概述
- 故障场景
- 尝试改进
- 问题分析
- 铺垫: Daemon线程
- Netty服务端启动源码分析
- 逻辑分析
- 如何避免Netty服务端意外退出
- 最佳实践
概述
在使用Netty进行服务端程序开发时,初学者可能会遇到各种问题,其中之一就是服务端意外退出的问题。这种问题可能会出现在程序启动后,没有发生任何异常的情况下,突然退出。导致这种情况发生的原因可能是代码中存在一些隐含的问题 。
接下来我们通过一个案例来演示一下这个问题
故障场景
package com.artisan.nettycase.a01exist;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
/**
* @author 小工匠
* @version 1.0
* @mark: show me the code , change the world
*/
public class ServerAbnormalExitExample {
public static void main(String[] args) throws InterruptedException {
// 创建两个事件循环组,bossGroup 用于接收客户端连接,workerGroup 用于处理客户端连接的读写事件
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 用一个线程处理接收连接的事件
EventLoopGroup workerGroup = new NioEventLoopGroup(4); // 用四个线程处理处理客户端连接的读写事件
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // 设置服务端 Channel 的类型为 NIO,这里使用 NioServerSocketChannel
.option(ChannelOption.SO_BACKLOG, 1024) // 设置一些 TCP 的参数,这里设置了连接缓冲区大小
.handler(new LoggingHandler(LogLevel.INFO)) // 添加一个日志处理器,用于打印一些调试日志
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new LoggingHandler(LogLevel.INFO)); // 添加一个日志处理器,用于打印客户端的请求日志
}
});
// 同步的方式绑定服务端监听端口
ChannelFuture future = serverBootstrap.bind(9000).sync(); // 绑定端口并启动服务端
// 等待服务端监听端口关闭
future.channel().closeFuture().sync();
} finally {
// 优雅地关闭事件循环组
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
运行程序,结果如下:
尝试改进
发现没有监听CloseFuture,于是对代码进行修改,
// 同步的方式绑定服务端监听端口
ChannelFuture channelFuture = serverBootstrap.bind(9000).sync();
channelFuture.channel().closeFuture().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
// 模拟业务代码
System.out.println(Thread.currentThread().getName() + " --- " + channelFuture.channel().toString() + "链路关闭");
}
});
还会发生服务器套接字直接关闭、进程退出的问题 。
问题分析
铺垫: Daemon线程
Java中的"Daemon"线程(守护线程)是一种特殊类型的线程,其特点是当所有的非守护线程都结束时,它会自动退出。相对于普通线程(非守护线程),守护线程更像是一种服务提供者,它们在后台默默地执行一些任务,而不会阻止JVM的正常关闭。
守护线程的特点如下:
-
在创建线程时指定为守护线程: 可以通过
Thread
类的setDaemon(boolean on)
方法将线程设置为守护线程,其中on
参数为true
表示将线程设置为守护线程,为false
表示设置为普通线程。 -
守护线程的生命周期受主线程的影响: 当所有的非守护线程结束时,守护线程会自动退出。这意味着,如果所有的非守护线程都结束了,即使守护线程还有未完成的任务,JVM也会立即退出。
-
通常用于执行后台任务: 由于守护线程的特性,通常用于执行一些后台任务,比如垃圾回收器、JVM监控等。
-
不能持有关键资源: 由于守护线程会在JVM退出时自动终止,因此不适合持有关键资源,比如文件或者数据库连接等。因为它们可能会在守护线程尚未执行完毕时被关闭,从而导致程序出现异常。
-
守护线程与非守护线程的区别: 主要区别在于JVM的退出条件,非守护线程结束时不会影响JVM的退出,而守护线程结束时可能会导致JVM立即退出。
来看个代码:
public class DaemonThreadExample {
public static void main(String[] args) {
Thread daemonThread = new Thread(() -> {
while (true) {
System.out.println("Daemon Thread is running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 将线程设置为守护线程
daemonThread.setDaemon(true);
// 启动守护线程
daemonThread.start();
// 主线程休眠一段时间
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main thread is exiting...");
}
}
我们可以知道: 守护线程是在所有非守护线程结束时自动退出的。因此,如果主线程退出,而守护线程是唯一剩下的线程,那么守护线程也会立即退出。所以,即使是守护线程,当所有非守护线程都退出时,它也会终止。
故结论如下:
-
在Java虚拟机中,即使主线程(通常是main线程)结束,只要还有活跃的非守护线程(用户线程)在运行,虚拟机进程仍然会保持活跃状态。只有当所有的非守护线程都结束时,虚拟机的进程才会结束。
-
当主线程(main线程)结束时,如果此时运行的其他线程全部是守护线程(Daemon线程),那么虚拟机会停止这些守护线程并退出。但是,如果此时正在运行的其他线程中有非守护线程,那么虚拟机将等待所有的非守护线程结束后才会退出。这意味着虚拟机会等待所有的非守护线程退出,不会因为主线程结束而立即退出。
Netty服务端启动源码分析
Netty Review - 服务端channel注册流程源码解析
通过分析源码我们可以知道: 在Netty中,当调用bootstrap.bind(port).sync().channel()
方法时,确实不是在调用方的线程(比如main线程)中执行,而是通过Netty的NioEventLoop
线程执行。这是因为Netty采用了异步的事件驱动模型,在调用bind
方法时,实际上是注册了一个事件监听器,在后续端口绑定完成时会通过NioEventLoop
线程执行相应的逻辑。
最终的执行结果其实就是调用了Java NIOSocket的端口绑定操作:
javaChannel().socket().bind(localAddress, config.getBacklog());
在Netty中,NioEventLoop
是一个事件循环,负责处理网络事件,包括接受连接、读写数据等。每个NioEventLoop都绑定了一个线程,它会不断地从事件队列中取出事件,并处理这些事件。因此,当调用bootstrap.bind(port).sync().channel()
方法时,实际上是将端口绑定操作放入了NioEventLoop
的事件队列中,由NioEventLoop
线程来执行。这样做的好处是可以避免阻塞调用方的线程,提高了程序的并发性能。
逻辑分析
我们知道: 端口绑定操作执行完成之后,main函数就不会阻塞,如果后续没有同步代码,main线程就会退出。
那我们思考一个问题: main线程退出是否意味着JVM进程一定退出吗?
并非如此,只有所有非守护线程全部执行完成,进程才会退出。
我们通过打印线程名称来看一下
System.out.println(Thread.currentThread().getName() + " --- " + channelFuture.channel().toString() + "链路关闭");
当然了,也可以通过Jconsole、jvisualvm、jmc等工具来观察 。
通过对 NioEventLoop源码进行分析,可以明确如下几点。
- NioEventLoop是非守护线程
- NioEventLoop运行之后,不会主动退出
- 只有调用shutdown系列方法,NioEventLoop才会退出
我们写的程序在调用Netty的shutdownGracefully()
方法后,导致NioEventLoop线程退出,从而整个系统的非守护线程都执行完成,而主线程也早已执行完毕,因此JVM进程退出。
主要的原因有两点:
-
端口绑定操作执行非常快:尽管调用
bootstrap.bind(PORT).sync()
会同步阻塞主线程,等待端口绑定的结果,但是由于端口绑定操作执行非常快速,一旦完成,程序就会继续向下执行。 -
调用
shutdownGracefully()
方法:在finally
块中调用了bossGroup.shutdownGracefully()
和workerGroup.shutdownGracefully()
,这两个方法会关闭服务端的TCP连接接入线程池和处理客户端网络I/O读写的工作线程池。当这两个线程池都关闭后,NioEventLoop线程也会退出,整个系统的非守护线程执行完成。因为主线程也早已执行完毕,所以JVM进程会退出。
当我们尝试
channelFuture.channel().closeFuture().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
// 模拟业务代码
System.out.println(Thread.currentThread().getName() + " --- " + channelFuture.channel().toString() + "链路关闭");
}
});
也依然无法阻值JVM退出,虽然增加了服务端连接关闭的监听事件之后,不会阻塞mainO)线程的执行,端口绑定成功之后,main
线程继续向下执行,由于在finally
中增加了线程池关闭代码,NioEventoop
线程主动退出,系统中没有正在运行的非守护线程了,所以JVM 进程退出。
Netty是一个异步非阻塞的通信框架,所有的IO操作都是异步的,但是为了方便使用,例如在有些场景下应用需要同步阻塞等待一些I/O操作的结果,所以提供了ChannelFuture
,它主要提供以下两种能力。
- 通过注册监听器
GenericFutureListener
,可以异步等待 I/O执行结果 - 通过sync或者await,主动阻塞当前调用方的线程,等待操作结果,也就是通常
说的异步转同步。
针对这个问题,重点在于理解Netty的异步非阻塞通信机制和ChannelFuture
机制。Netty提供了ChannelFuture
机制,通过注册监听器或者阻塞等待操作结果,可以实现异步转同步的操作。
因此,在使用Netty时,需要合理地处理异步操作,以充分利用Netty的优势,并避免出现意外退出的情况。
如何避免Netty服务端意外退出
通过对Netty服务端意外退出问题的分析,我们可以采取不同的修改策略来防止这种情况的发生。
- 监听
NioServerSocketChannel
的关闭事件并同步阻塞main函数:
// 监听NioServerSocketChannel的关闭事件并同步阻塞main函数
channelFuture.channel().closeFuture().sync();
这种方法会在NioServerSocketChannel
关闭时阻塞主线程,直到关闭事件发生。这样可以保证主线程在服务端关闭之前不会退出,从而确保服务端的正常运行。
启动服务后,再次观察线程dump
搞个线程DUMP看一下
- 在链路关闭时再释放线程池和连接句柄:
channelFuture.channel().closeFuture().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
// 模拟业务代码
System.out.println(Thread.currentThread().getName() + " --- " + channelFuture.channel().toString() + "链路关闭");
boss.shutdownGracefully();
worker.shutdownGracefully();
}
});
这种方法会在链路关闭时异步执行释放线程池和连接句柄的操作。通过添加监听器,可以在关闭事件发生时执行相应的操作,从而避免在主线程中主动调用shutdownGracefully()
方法导致的意外退出问题。
最佳实践
在实际项目中这些错误可能会导致服务端意外退出或者线程阻塞等问题。 建议如下
错误用法:这种用法会导致调用方的线程一直被阻塞,直到服务端监听句柄关闭。
- 初始化 Netty 服务端。
- 同步阻塞等待服务端端口关闭
- 释放 I/0 线程资源和句柄等
- 调用方线程被释放。
正确用法:服务端启动之后注册监听器监听服务端句柄关闭事件,待服务端关闭之后
异步调用 shutdownGracefull
释放资源,这样调用方线程就可以快速返回,不会被阻塞。
- 初始化 Netty 服务端。
- 绑定监听端口。
- 向CloseFuture注册监听器,在监听器中释放资源
- 调用方线程返回。
推荐通过调用EventLoopGroup
的shutdownGracefully
方法来优雅地关闭服务端,以完成内存队列中积压消息的处理、链路的关闭和EventLoop
线程的退出。这样可以实现停机不中断业务。 (单靠Netty框架可能无法完全保证服务的可靠性,需要应用程序的其他配合来实现。)
总的来说,正确理解和使用Netty的异步特性是非常重要的。合理地利用Netty的异步非阻塞模型可以提高系统的性能和并发能力,同时避免出现意外退出和性能问题。