BIO模型和NIO模型

两个模型都是很常见的Java网络模型

BIO模型:同步阻塞模型,一个线程专门负责一个连接,数据以流的形式传输

NIO模型:同步非阻塞模型,一个线程负责多个连接,其功能被多个组件分摊,Channel为双方提供双向的通信通道,读写数据都要通过Buffer,Selector可以实现高效的多路复用器

两个模型就好比不同餐厅的招待方式,BIO的餐厅就像一位服务员专门负责一桌客人,而且会一直等待客人的命令;NIO餐厅则是一位服务员负责多桌客人,而且只要你不点餐(数据没准备好),我就不会在你的桌前停留

BIO策略只适合连接数量不多,且连接时间较长的情况,过多的连接将导致线程数量过多导致OOM

NIO策略下,一个线程被赋予处理多个请求的能力,并发量大大提高

NIO性能很高,为什么我们不直接使用它呢?

答:开发效率不高,使用NIO直接进行开发,开发者需要应付这些问题:

  • Selector 的复杂性Selector 是 NIO 的核心,但其使用方式反直觉。你需要在一个循环中轮询(select())事件,然后遍历 selected keys,并针对每个 key 判断其是什么事件(可连接、可读、可写),再进行相应的处理。这个循环的编写、超时处理、以及性能优化(如避免空轮询bug)都非常棘手。

  • Channel 和 Buffer 的繁琐管理ChannelBuffer 需要紧密配合。从 Channel 读取数据到 Buffer,或从 Buffer 写入数据到 Channel 后,你必须记得调用 Buffer.flip()Buffer.clear()Buffer.compact() 等方法来正确切换缓冲区的读写模式。忘记调用或调用错误会导致数据错乱,且这类错误很难调试。

  • 非阻塞语义:所有 I/O 操作都是非阻塞的,立即返回。这意味着写入操作可能无法一次性写完所有数据,你需要自己维护一个队列,在 OP_WRITE 事件触发时继续发送,这大大增加了状态管理的复杂度。

  • 需要自行设计多线程方案:原生 NIO 只提供了基础的 I/O 多路复用,但它没有规定如何组织线程。你是用一个线程处理所有连接?还是用一个 Selector 负责接收,再用一个线程池处理业务逻辑?或者为每个 Channel 分配一个线程?这就是 Reactor 单线程、多线程、主从多线程等模型,但 NIO 本身不提供这些,需要开发者自己从头实现,并处理随之而来的线程安全问题。

  • CPU 密集型任务会阻塞 I/O 线程:如果你在 Selector 的同一线程中处理业务逻辑,一个耗时操作会阻塞整个 Selector,导致所有其他连接的响应延迟。你必须自己小心地将耗时任务提交到额外的业务线程池中,并处理好线程间的任务交接和数据同步。

其实还有很多问题需要解决,这里列出了一些常见问题,我们只需要知道开发者在使用NIO开发时极度麻烦的,我们需要一个统一的框架将其进行封装,这就是Netty,它的底层基于NIO,但是对其进行了再封装与优化

对NIO模型的一些补充:

NIO模型的优点在于它的多路复用机制,我们只需要一个线程就能实现对多个socket的监听,这里有三个比较关键的函数,分别对应操作系统的三个内核函数。

1
2
3
Selector.open() 调用 epoll_create
channel.register(selector, ...) 调用 epoll_ctl
selector.select() 调用 epoll_wait

讲一下三个内核函数的作用。

  1. epoll_create:创建一个红黑树,以每个fd为节点,每个fd都配置一个回调函数,当有数据到达fd时,就会触发回调函数,将这个fd放入就绪列表中。
  2. epoll_ctl:将所有fd插入红黑树。
  3. epoll_wait:遍历就绪列表,获取就绪的fd。

Netty

这是一个标准的Netty的服务端程序:

1
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
32
33
34
public class NettyServer {
public static void main(String[] args) {
// 创建只处理连接请求的线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(10);
// 创建处理客户端读写业务的线程组
EventLoopGroup workerGroup = new NioEventLoopGroup(10);
try {
// 创建服务端启动对象
ServerBootstrap bootstrap = new ServerBootstrap();
//配置参数
bootstrap.group(bossGroup, workerGroup)
// 使用NioServerSocketChannel作为服务器的通道实现
.channel(NioServerSocketChannel.class)
// 配置用于存放因没有空闲线程导致连接请求被暂存到队列中的队列长度
.option(ChannelOption.SO_BACKLOG, 1024)
// 创建通道初始化的对象并配置该对象,向该对象中添加处理器实现业务处理
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
// 添加处理器
socketChannel.pipeline().addLast(new NettyServerHandler());
}
});
System.out.println("Netty服务器启动");
ChannelFuture channelFuture = bootstrap.bind().sync();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 断开所有连接并清理内存
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

我们就借助它来分析以下为什么Netty能如此高效的处理连接

Netty的高效源自于它各司其职却紧密配合的各个组件

  • EventLoop:事件循环,他与线程是一对一的关系,与Channel是一对多的关系,用于循环处理一个连接的生命周期内所发生的事件(接收数据、异常处理等)。
  • EventLoopGroup:事件循环组,就是一个EventLoop的池子,可以近似理解为一个线程池,一般创建一个bossGroup专门负责连接请求,一个workerGroup专门负责读写请求。
  • ChannelHandler:通道处理器,用来处理出站和入站的数据,如ChannelInboundHandler:处理入站事件和数据(如连接已建立、接收到消息、异常发生、连接关闭等)。ChannelOutboundHandler:处理出站操作(如打开连接、关闭连接、写入数据、刷新数据等)。
  • ChannelPipeline:通道管道,一个存放了ChannelHandler的容器,是一个双向链表,每一个节点都是一个ChannelHandler。
  • ChannelFuture:通道异步结果,Netty的所有IO操作都是异步完成的,也就是说类似connect,write等方法都会调用另一个线程去执行,我们可以通过ChannelFuture回调执行结果等信息。

如果说我们将这些组件模拟成一个邮局系统EventLoop就是邮政员,负责不断的收信,发信等;EventLoopGroup就是邮局,里面有许多邮政员;ChannelHandler模拟寄信的业务流程(贴邮票、封装、发送);ChannelPipeline规定了这些业务流程的顺序;ChannelFuture当你将一封信交给邮政员的时候,他会给你一张回执,让你等待消息,这张回执就是ChannelFuture。

下面是一个标准Netty客户端代码,我们可以对比一下他和服务端的区别。

1
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
public class NettyClient {
public static void main(String[] args) {
// 创建一个线程组用于事件循环
EventLoopGroup eventLoopGroup = new NioEventLoopGroup();

try {
// 创建客户端启动对象
Bootstrap bootstrap = new Bootstrap();

// 设置参数
bootstrap.group(eventLoopGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new NettyClientHandler());
}
});
System.out.println("Netty客户端启动了");
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9090).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
eventLoopGroup.shutdownGracefully();
}
}
}

Client只创建一个线程循环组,而Server一般创建两个,bossGroup处理连接,workerGroup处理读写。

Client和Server的Channel是不同的,拥有各自的pipeline和handler,他们的结构大致如下。

image-20250911231754600

下面对handler的具体功能展开讨论。

  • 编码器与解码器

我们知道,数据在网络中以二进制的形式传播,客户端想要向服务端发送文本数据或者其他形式的数据,就需要编码器,客户端想要识别出数据代表的含义,就需要解码器。

我们可以在配置的时候为服务端添加编解码器,channel会根据进站和出站来调用handler。

以服务端为例,入站就是数据进入服务端,一般是读操作;出站就是数据离开客户端,一般是写操作。客户端同理。

1
2
3
4
5
6
7
8
9
10
11
12
    @Override
protected void initChannel(SocketChannel ch) throws Exception {
// 获得pipeline
ChannelPipeline pipeline = ch.pipeline();
// 解码器
pipeline.addLast(new StringDecoder());
// 编码器
pipeline.addLast(new StringEncoder());
// 添加处理器
pipeline.addLast(new ChatServerHandler());
}
});

NIO与Netty

我从两方面进行一个大致的对比

  1. 使用体验

    Netty的使用体验远比NIO要好。体现在NIO需要手动调用selector,进行创建,监听,轮询;还需要使用非常反人类的bytebuffer,不断切换读写指针;NIO还需要自己编写译码解码的处理器。这些操作都可以使用Netty很快解决。

  2. 性能

    Netty使用Reactor模型分别处理连接请求和读写请求,效率很高;Netty的零拷贝优化了数据流转的效率;面对需要频繁创建buffer的情况,Netty采用内存池化技术,提前将内存分为多个小块方便使用与回收

Netty的零拷贝包括:可以直接为对象分配堆外内存,可以在操作系统层面实现零拷贝,直接将byte数组包装成bytebuffer对象不需要复制

基于Netty如何实现聊天系统

系统架构图

image-20260328163516992

实现群聊系统:

image-20260328163843544

实现已读系统:

image-20260328163905769