一、五种 IO 模型
在理解 Netty 的底层原理之前,有必要先了解 Java 网络通信中支持的多种 I/O 模型。Netty 的高性能本质上正是得益于其对 I/O 多路复用的封装与优化。了解下面五种常见 I/O 模型的工作机制与差异后,你应该就能理解 Netty 为什么采用当前的架构。
1.1 阻塞 I/O(BIO)
在 BIO(Blocking I/O)模型中,应用进程向内核发起 I/O 请求时,会调用阻塞式系统调用。也就是说,发起 I/O 调用的线程会一直阻塞在调用方法处,直到内核数据准备完成,并将数据拷贝到用户空间缓冲区后才返回结果。这种方式严重浪费了线程资源,因为在等待 I/O 的过程中线程不能做其他任务。
在传统的 BIO 模式下,实现异步只能通过多线程模型。即每一个请求需要创建一个独立线程来处理读写操作。如果并发连接数非常多,则会导致线程资源耗尽,系统吞吐能力急剧下降。
1.2 同步非阻塞 I/O(NIO)
NIO引入了 Channel、Selector 和 Buffer 等概念。在这种模式下,线程向内核发起 I/O 调用后会立即返回,不会阻塞等待结果。线程可以通过轮询 Selector 判断是否有数据准备好,从而进行后续处理。
尽管线程可以避免阻塞,但轮询的方式会造成大量的系统调用,尤其在连接数很多但活跃连接很少的场景下,轮询会占用大量 CPU 资源,导致效率低下。此外,轮询 Selector 获取就绪通道时还涉及用户态与内核态频繁切换。
1.3 I/O 多路复用
多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。,一旦某个通道就绪,线程便可进行处理,从而实现一个线程处理多个连接。
- select/poll 是最早的实现,存在最大文件描述符限制,并且每次调用都需遍历整个文件描述符列表。
- epoll 是 Linux 提供的优化版本,不再有 FD 数量限制,且在就绪事件处理上更加高效。
Netty 基于 NIO + epoll 实现的高性能 I/O。
1.4 信号驱动 I/O(SIGIO)
通过内核向应用程序发送 SIGIO 信号来通知 I/O 就绪状态。应用程序首先通过 fcntl 启用文件描述符的信号驱动模式,然后注册信号处理函数,当内核准备好数据后通过信号触发处理函数,从而进行数据读取。
1.5 异步 I/O(AIO)
应用程序调用 aio_read()时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。内核在完成数据准备并将其复制到用户缓冲区后,返回aio_read中定义好的函数处理程序。
关于NIO和AIO的区别:NIO使用多路复用机制,通过少量线程轮询处理大量请求;AIO采用异步回调机制,仅在IO操作完成时分配线程处理有效请求。
二、Reactor 线程模型
在Java中,有专门的NIO包封装了IO多路复用相关的方法。Netty可以看做是对Java-NIO包使用细节做了许多优化的进一步封装(类比Redisson和Redis)。所以通常使用Netty而不是直接使用Java NIO包,下面的Reactor线程模型就是一个典型例子。
2.1 简介
Reactor模式是一种基于事件驱动的模式。Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 NIO 框架的多路复用器 Selector。采用 epoll 模式后,只需要一个线程负责 Selector 的轮询。当有数据处于就绪状态后,需要一个事件分发器 (Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。
事件分发器有两种设计模式:Reactor 和 Proactor。Reactor 采用同步 I/O, Proactor 采用异步 I/O。根据Reactor的数量和线程池的数量,又可以将Reactor分为三种模型
① 单 Reactor 单线程模型
在该模型中,只有一个线程负责所有操作,包括连接接收、读写事件的监听和业务处理等。所有 I/O 操作都由一个线程串行处理,设计简单,但是缺点也很明显:
- 所有 I/O 操作阻塞或耗时处理都会影响其他连接
- 单线程 CPU 无法充分利用多核资源
- 一旦某个连接的业务处理逻辑阻塞,会影响整个系统的响应能力
适用于连接数少、逻辑简单的场景。
② 单 Reactor 多线程模型
该模型仍然只有一个 Reactor 线程负责监听 accept 和读写事件,但将耗时的业务处理交给线程池(通常称为 Worker 线程池)异步处理。这样可以避免业务处理阻塞 Reactor 线程。
这种方式提高了系统吞吐能力,但连接数和事件数量激增后,单线程处理事件的能力将成为性能瓶颈。
③ 多 Reactor 多线程模型(主从 Reactor 模型)
在该模型中,系统通常分为两个 Reactor:主 Reactor 和从 Reactor。
- 主 Reactor 负责监听连接请求(accept),并将建立的连接注册到从 Reactor。
- 从 Reactor 负责处理具体连接的读写事件及派发业务逻辑到线程池处理。
每个从 Reactor 通常配备多个线程组成的 EventLoopGroup,可充分利用多核 CPU,实现高并发下的高性能。
Netty 采用的就是改进的多 Reactor 多线程模型。
2.2 EventLoop 与 EventLoopGroup
Reactor 模型在 Netty 中的实现依赖于两个核心组件:EventLoop 与 EventLoopGroup。Reactor模型的大致运行模式如下:
bash
- 连接注册:建立连接后,将channel注册到selector上
- 事件轮询:selcetor上轮询(select()函数)获取已经注册的channel的所有I/O事件(多路复用)
- 事件分发:把准备就绪的I/O事件分配到对应线程进行处理
- 事件处理:每个worker线程执行事件任务

2.2.1 EventLoop
EventLoop是一个通用的事件等待和处理的程序模型,主要用来解决多线程资源消耗高的问题。Node.js 中也采用了 EventLoop 的运行机制。
bash
# Netty中的EventLoop
- 一个Reactor模型的事件处理器。
- 单独一个线程。
- 内部会维护一个selector(处理I/O事件)和一个taskQueue任务队列。
注:
- taskQueue任务队是多生产者单消费者队列,在多线程并发添加任务时,可以保证线程安全。
- I/O事件就是selectionKey中的事件,如accept、connect、read、write等。
- 任务主要分为普通任务和定时任务。
- 普通任务:通过 NioEventLoop 的 execute() 方法向任务队列 taskQueue 中添加任务。例如 Netty 在写数据时会封装 WriteAndFlushTask 提交给 taskQueue。
- 定时任务:通过调用 NioEventLoop 的 schedule() 方法向 定时任务队列 scheduledTaskQueue 添加一个定时任务,用于周期性执行该任务(如心跳消息发送等)。定时任务队列的任务 到了执行时间后,会合并到 普通任务 队列中进行真正执行。
每个 EventLoop 会不断执行事件循环(event loop):
- Selector事件轮询
- I/O事件处理
- 任务处理
2.2.2 EventLoopGroup
EventLoopGroup 是 EventLoop 的集合,类似线程池与线程的关系。
Netty 4 引入了无锁串行化设计:每个 Channel 在创建后绑定唯一的 EventLoop,保证所有 I/O 操作都由同一个线程处理,从而避免了并发问题,减少了上下文切换成本。
三、TCP 拆包粘包问题及解决方案
网络通信中,数据传输往往并非"发一个包,对方就收一个包"这么理想。在实际项目中我们常会遇到 TCP 拆包与粘包的问题,这对于数据完整性的保证提出了挑战。Netty 在设计上也必须应对这一问题,下面我们将解释其产生原因及常见的解决方法。
3.1 产生原因
由于 TCP 是面向字节流的传输协议,它本身并不具备消息边界的概念,因此在实际传输过程中会出现如下情况:
- 粘包:发送端连续发送多个小数据包,接收端一次性读取多个包内容,导致数据粘在一起。
- 拆包:一个大包被分为多个 TCP 数据段发送,接收端需要多次接收才能拼装完整消息。
拆包的主要原因:MSS(TCP 最大报文段长度) + TCP 首部 + IP 首部 > MTU(链路层最大传输单元)。
3.2 常见解决方案
为了解决拆包粘包问题,需要在应用层设计明确的消息边界标识或消息结构,常用的方式:
- 定长报文:每条消息固定字节长度,接收端按长度读取即可。
- 分隔符报文 :使用特殊字符作为每条消息的结束标志,例如
\r\n
或特殊字节序列。 - 长度字段报文(Netty) :报文头包含消息体的长度字段,接收端先读取长度字段再读取数据体。
Netty 中已经内置了多种拆包粘包处理器,开发者可以根据协议类型选择合适的 Decoder,例如:
java
// 固定长度解码器
FixedLengthFrameDecoder
// 基于分隔符的解码器
DelimiterBasedFrameDecoder
// 长度字段解码器
LengthFieldBasedFrameDecoder
四、Netty 编解码器机制与自定义协议设计
为了实现灵活的网络传输,Netty 提供了强大的编解码器机制,帮助开发者将自定义的业务对象转换为 ByteBuf(字节流),以支持序列化、协议兼容与数据完整性。
4.1 编码器(Encoder)
编码器用于将业务对象编码为字节流发送至对端,常用类型:
java
// 将 POJO 转为 ByteBuf
MessageToByteEncoder
//一种消息类型编码成另外一种消息类型
MessageToMessageEncoder
4.2 解码器(Decoder)
解码器用于从 ByteBuf 中读取字节数据,并转换为业务对象,常用类:
java
// 将字节流解码为消息对象
ByteToMessageDecoder
// 将字节流解码为消息对象,逻辑简化,自动校验可读性
ReplayingDecoder
//将一种消息类型解码为另外一种消息类型
MessageToMessageDecoder
注:解(编)码器可以分为一次解码器 和二次解码器 ,一次解码器用于解决 TCP 拆包/粘包问题 ,按协议解析后得到的字节数据。如果你需要对解析后的字节数据做对象模型的转换,需要用到二次解码器,。
4.3 自定义协议格式设计
为了支持高性能和可扩展性,自定义协议设计需明确以下字段(如魔数、版本、序列化方式、消息体长度等),确保数据传输的可靠性和可解析性。
注:如何判断 ByteBuf 是否存在完整的报文?
常用的做法是通过读取消息长度 dataLength 进行判断。如果 ByteBuf 的可读数据长度小于 dataLength,说明 ByteBuf 还不够获取一个完整的报文。
五、writeAndFlush 机制
Netty 如何将编解码后的结果发送出去呢?在 Netty 中实现数据发送非常简单,只需要调用 writeAndFlush 方法即可。writeAndFlush 的处理流程,可以总结以下三点:
- writeAndFlush 属于出站操作,它是从 Pipeline 的 Tail 节点开始进行事件传播,一直向前传播到 Head 节点。不管在 write 还是 flush 过程,Head 节点都中扮演着重要的角色。
- write 方法并没有将数据写入 Socket 缓冲区,只是将数据写入到 ChannelOutboundBuffer 缓存中,ChannelOutboundBuffer 缓存内部是由单向链表实现的。
- flush 方法才最终将数据写入到 Socket 缓冲区。
详细的分析可以阅读这篇文章。