学习Netty之前,首先先掌握这些基础知识:阻塞(Block)与非阻塞(Non-Block),同步(Synchronous)与异步(Asynchronous),Java BIO与NIO对比。
基础概念
阻塞(Block)与非阻塞(Non-Block)
阻塞模式
- 调用I/O操作时,若数据未就绪,调用线程会被操作系统挂起(进入睡眠状态)
- 线程会一直等待,直到内核缓冲区数据就绪后被唤醒
- 典型例子:
read()
/write()
默认的套接字操作
非阻塞模式
- 调用I/O操作时,无论数据是否就绪都会立即返回
- 需要通过轮询(如
select
/epoll
)或回调机制主动检查状态 - 典型例子:设置
O_NONBLOCK
标志的套接字
同步(Synchronous)与异步(Asynchronous)
同步
- 应用程序主动参与 I/O 操作的执行过程,必须等待操作完成才能继续执行后续代码
- 调用 I/O 函数(如
read()
/write()/传统阻塞式 Socket
(默认模式
))时,线程会阻塞直到操作完成。
注意:同步 ≠ 阻塞(例如非阻塞轮询也是同步行为)
异步
- 应用程序发起 I/O 请求后立即返回,操作系统负责完成实际操作
- 等待通知:通过回调(Callback)、事件通知(如
epoll
)、信号(Signal)或 Future/Promise 机制获取结果
关键对比总结
特性 | 同步 | 异步 |
---|---|---|
控制流 | 应用程序主动等待结果 | 操作系统完成后通知应用程序 |
线程阻塞 | 可能阻塞(取决于实现) | 绝不阻塞调用线程 |
实现机制 | 阻塞调用/轮询 | 回调/事件循环/信号 |
性能影响 | 可能造成线程闲置 | 更高吞吐量 |
Java BIO与NIO
BIO(同步阻塞IO)
核心类库
- ServerSocket:监听TCP连接,阻塞式接受请求
- Socket:客户端通信通道,读写操作均阻塞
- BufferedReader/BufferedWriter:带缓冲的字符流,减少IO次数
基础特性
- 基于
java.io
包的流模型,采用同步阻塞方式操作数据流(如InputStream
/OutputStream
) - 线程在读写操作时会完全阻塞,直到数据就绪,导致资源闲置
- 基于
典型问题
- 单线程阻塞:处理大文件或网络延迟时,线程被长时间占用
- 扩展性差:高并发需为每个连接创建线程,易引发线程爆炸
适用场景
- 低并发、简单IO操作(如本地文件读写)
NIO(同步非阻塞IO)
核心组件
- Channel:双向通信管道(如
SocketChannel
),支持非阻塞模式 - Buffer:数据容器(如
ByteBuffer
),所有读写操作通过缓冲区完成 - Selector:多路复用器,单线程可监听多个通道事件(如读/写就绪)
- Channel:双向通信管道(如
性能优势
- 非阻塞:线程可轮询多个通道,避免无效等待
- 高并发:单线程管理多个连接,减少线程切换开销
适用场景
- 高负载网络应用(如Web服务器、即时通讯)
BIO的通信模型
ServerSocket和Socket是如何工作的(通信流程)
BIO单线程通信模型
- ServerSocket在服务器端绑定端口持续监听,等待客户端通信。
- 当客户端Socket通过IP+端口发起连接请求。
- ServerSocket触发accept()后生成专用的socket。
- 服务端和客户端通过socket的I/o流进行双向通信。
Acceptor线程模型(多线程BIO)
核心组件分工
Acceptor线程
独立运行于while(true)
循环中,通过ServerSocket.accept()
阻塞监听连接请求。一旦接收到新连接,生成对应的Socket
对象并将其封装为任务提交至Worker线程池12。Worker线程池
由固定数量的线程组成,负责从任务队列中获取Socket
任务,执行同步阻塞式的I/O读写(如InputStream.read()
/OutputStream.write()
)。线程池通过复用线程避免频繁创建销毁的开销34。
底层执行流程
初始化阶段
- 启动
ServerSocket
绑定端口,创建独立的Acceptor线程和Worker线程池。 - Acceptor线程进入监听状态,Worker线程池处于待命状态。
- 启动
连接处理阶段
- 步骤1:客户端发起连接请求,Acceptor线程的
accept()
返回新Socket
。 - 步骤2:Acceptor将
Socket
包装为Runnable
任务(如SocketHandler
),提交至线程池的任务队列。 - 步骤3:Worker线程从队列中获取任务,处理该连接的I/O操作(读写数据),期间线程被阻塞直至操作完成。
- 步骤1:客户端发起连接请求,Acceptor线程的
资源释放阶段
- I/O操作完成后,Worker线程关闭
Socket
并返回线程池,等待下一个任务。 - 若客户端断开连接,线程会主动释放相关资源
- I/O操作完成后,Worker线程关闭
对比
特性 | 单线程模型 | Acceptor线程模型 |
---|---|---|
线程数量 | 1个线程处理所有连接 | 1个Acceptor线程 + N个Worker工作线程 |
阻塞点 | accept() 和读写均阻塞 |
仅工作线程的读写阻塞 |
适用场景 | 单客户端测试 | 低并发生产环境 |
连接监听 | 主线程直接阻塞在accept() |
独立Acceptor线程专责监听 |
I/O处理 | 同一线程串行处理所有连接I/O | Worker线程池并行处理 |
资源消耗 | 线程少但完全阻塞 | 线程池复用,可控但仍有阻塞 |
连接建立与处理 | 串行执行,新连接需等待旧连接处理完毕 | 并行处理,连接建立与业务解耦 |
崩溃影响范围 | 线程崩溃导致整个服务不可用 | 各阶段隔离,局部崩溃不影响其他功能 |
Acceptor线程模型其实可以分为两个阶段
第一阶段(Acceptor线程)
- 专注连接建立:通过
while(true)
循环持续调用ServerSocket.accept()
来监听连接请求。- 高优先级:确保连接入口的高吞吐量,避免业务逻辑阻塞连接接收。
- 崩溃隔离:如果Acceptor线程崩溃(如端口被占用),不会影响已建立的Socket连接(Worker线程仍在运行)。
第二阶段(Worker线程池)
- 专注业务处理:处理Socket的I/O操作(如HTTP请求解析、数据库查询等)。
- 崩溃隔离:若某个Worker线程崩溃(如业务逻辑异常),不会影响Acceptor线程和其他Worker线程,线程池会回收异常线程并新建替补线程(取决于线程池配置)。
Acceptor线程模型升级版
若Worker线程池满载或处理缓慢,Acceptor线程仍持续提交新连接任务,可能导致线程池队列溢出或拒绝连接。可以采用Acceptor+线程池 或者NIO(Reactor模型)
Acceptor+线程池
- Acceptor线程:通过
while(true)
循环监听连接,接收后立即将Socket交给线程池处理。 - Worker线程池:使用
ThreadPoolExecutor
处理业务逻辑,需配置核心参数(核心线程数、队列类型、拒绝策略等)
PS:ServerSocket.accept() 为什么会出现阻塞?
TCP三次握手等待
当客户端发起连接时,需完成SYN→SYN-ACK→ACK的握手过程。若握手未完成,内核无法将连接放入"已完成连接队列",导致accept()
持续等待。内核队列状态依赖
accept()
实际是从内核维护的ESTABLISHED状态队列中提取连接。若队列为空(无已完成握手的连接),调用线程会被挂起35。同步设计特性
作为阻塞式I/O的标准行为,该设计避免了线程轮询的CPU浪费,但会牺牲响应性
NIO的通信模型
------------------------------------
NIO的工作过程
当Channel注册到Selector上并声明监听类型后,Selector会持续监控所有已注册的Channel。 selector会监控Channel的I/O就绪状态,如果为就绪了将会放到就绪集合。之后获取就绪集合对其进行后续的事件处理。
IO多路复用
举例说明:柜员(线程)快速巡视各个窗口(Channel)
只有亮起提示灯的窗口(就绪事件)才需要处理
处理完一个窗口立即检查下一个,不等待客户缓慢填单(非阻塞操作)
总结:多路复用其实就是“一对多”,只要就绪了就对其进行处理。传统的IO模型其实是“一对一”
select底层实现
select实现多路复用的核心分为三个阶段:准备监控、轮询扫描、事件处理,整个过程围绕fd_set位图机制展开。
准备阶段
- 用户通过
FD_SET
宏将待监控的文件描述符(fd)加入fd_set位图 - 提前标记需要关注的fd(如网络socket)
- 用户通过
轮询阶段
- 内核通过系统调用全量扫描位图中的所有fd
- 通过位图状态位判断哪些fd已就绪(如可读/可写)
- 相当于"服务员检查所有餐桌"的阻塞过程
处理阶段
- 用户遍历内核返回的就绪事件集合
- 对每个就绪fd执行对应IO操作
关于 select()
的数据拷贝时机,其核心流程中的拷贝操作发生在两个关键阶段:
调用
select()
时
用户态程序会将监控的fd_set
位图(包含所有待监控的 fd)完整拷贝到内核态,这是全量扫描的前提。此时内核需要获取完整的 fd 集合信息才能开始遍历检查。返回结果时
内核完成全量扫描后,会将就绪的 fd 标记在新的fd_set
位图中,并将该结果位图拷贝回用户态。用户程序需通过FD_ISSET
遍历位图解析就绪事件
select()底层原理总结:
select() 其实主要通过fd_set位图进行对fd(文件描述符)进行监控,系统触发全量fd扫描,根据fd的状态监测就绪事件,最后对就绪事件进行处理。
epoll底层实现
epoll的工作流程可分为三个核心阶段,结合红黑树和就绪队列实现高效事件管理:
初始化阶段
- 创建
epoll
实例(epoll_create
),内核会初始化红黑树和就绪链表结构 - 红黑树用于存储所有待监控的fd(文件描述符),确保快速插入/删除
- 创建
事件注册阶段
- 通过
epoll_ctl
将fd添加到红黑树,并设置回调函数(如读/写事件) - 网卡驱动通过中断触发回调,将活跃事件对应的节点移至就绪队列
- 通过
事件轮询阶段
epoll_wait
系统调用直接获取就绪队列中的事件,无需全量扫描- 用户空间仅处理实际活跃的连接,时间复杂度O(1)
关键优化点
- 零拷贝:用户态与内核态通过共享内存传递就绪事件(
典型实现如sendfile
系统调用,直接将文件数据从磁盘经内核缓冲区传输到网卡缓冲区 ) - 边缘触发(ET):减少重复事件通知,需配合非阻塞I/O使用
- LT/ET模式:LT(水平触发)会重复通知未处理事件,ET(边缘触发)仅通知一次
- 零拷贝:用户态与内核态通过共享内存传递就绪事件(
总结:
epoll通过红黑树和就绪队列两级数据结构实现高效事件管理:
- 红黑树作为全局存储,维护所有待监控的文件描述符(fd)
- 事件驱动机制通过回调函数(如网卡中断触发)将活跃事件对应的fd节点移至就绪队列;
- 最终
epoll_wait
仅需扫描就绪队列中的活跃事件,避免全量fd集合的遍历扫
epoll与select/poll的关键架构差异。具体表现为:
epoll的就绪队列机制
- 内核通过红黑树管理监控fd集合,同时独立维护双向链表结构的就绪队列
- 网卡中断触发回调时,内核直接将活跃事件对应的epitem节点插入队列,无需遍历检查
epoll_wait
仅需读取该队列内容,时间复杂度恒为O(1)
select/poll的临时检测机制
- 每次调用时需全量扫描监控的fd集合,通过轮询检查每个fd状态
- 返回的"就绪列表"是临时生成的位图(poll是revents数组),非持续维护结构
- 即使仅1个fd就绪,仍需完成O(n)次扫描
单线程的 Reactor 模型
工作流程
- 事件监听:Reactor线程通过select/epoll轮询监听所有连接事件
- 事件分发:
- 连接事件 → 交给Acceptor处理(创建新连接并注册Handler)
- 读写事件 → 分发给对应Handler处理
- 业务处理:Handler完成数据读取→业务计算→响应发送的全流程
典型应用场景
- Redis的多路复用模型采用此模式
- 适合低并发、轻量级业务场景
总结:reactor主要是负责监听和事件分发(Dispatcher分发事件),监听通过多路复用器(底层使用select和epoll),而事件分发如果是 连接事件交给Acceptor处理(创建新连接并注册Handler),读写事件的话直接交给handler处理。
事件分类
ACCEPT事件:
- 接受客户端连接
- 创建对应的SocketChannel
- 注册READ事件到Selector
READ事件:
- 从Channel读取数据到Buffer
- 解码并处理业务逻辑
- 如有响应,注册WRITE事件
WRITE事件:
- 将响应数据写入Buffer
- 从Buffer写入Channel
- 完成后取消WRITE事件注册
PS:
事件处理流程:
- 连接事件 → Reactor → Acceptor
• 创建SocketChannel
• 注册READ事件到Selector
• 绑定对应Handler- 读写事件 → Reactor → Handler
• 读:Channel→Buffer→业务处理
• 写:业务数据→Buffer→Channel
思考:
为什么连接事件交给Acceptor处理?
Acceptor专职处理连接事件(ACCEPT),因其涉及底层系统调用(如bind/listen)和资源初始化,需与业务逻辑隔离;
为什么读写事件直接交给Handler处理?
读写事件直接由Handler处理,避免冗余中转,实现数据到业务的最短路径。这种分工既保障了连接稳定性,又提升了数据处理效率
selector中的SelectionKey是什么东西,它作用是什么?
SelectionKey就是NIO中事件的通知单——它标记了哪个Channel触发了什么事件(比如可读/可写),并携带对应的上下文数据,相当于Selector和Channel之间的"事件快递员"
Handler的创建与绑定机制
Handler的创建和绑定主要通过以下方式确保:
创建时机:
- 在Acceptor接受新连接时创建(最常见)
- 预创建线程池中的Handler实例(多线程模型)
- 延迟初始化(按需创建)
绑定方式:
- 通过SelectionKey(attachment)关联
对应的SelectionKey无有效attachment怎么处理
若SelectionKey.attachment()返回null或无效对象,多数实现会直接关闭该Channel并取消Key注册,避免资源泄漏;
读事件特殊性的处理,即使无Handler,底层仍会触发读就绪通知,但数据会被丢弃(通过执行channel.read(ByteBuffer)
清空缓冲区)
最佳实践建议
- 在Acceptor阶段必须显式绑定Handler到新Channel
- 通过
key.isValid()
+attachment()!=null
双重校验防御无效事件 - 对异常Channel实现fallback处理策略(如重连机制)
事件触发 → 检查Key.attachment() → 存在Handler → 调用对应方法
不存在 → 关闭Channel或创建应急Handler
典型问题处理
事件堆积
需及时处理就绪事件并调用selectedKeys().clear()
,否则会导致重复触发。内存泄漏
长时间未关闭的Channel会导致SelectionKey堆积,需通过key.cancel()
主动释放。并发冲突
多线程修改interestOps()
需同步(如使用selector.wakeup()
)
多线程的 Reactor 模型
核心组件与职责
Reactor(反应器)
- 职责:统一监听所有I/O事件(连接、读写),通过I/O多路复用(如
epoll
)实现非阻塞监听。 - 线程模型:由多个线程共同运行事件循环(EventLoop),每个线程独立处理一组
Channel
。
- 职责:统一监听所有I/O事件(连接、读写),通过I/O多路复用(如
Handler(处理器)
- 职责:执行数据读写和业务逻辑,通常与Reactor线程绑定,避免跨线程竞争。
- 优化点:若业务耗时较长,可提交至独立Worker线程池处理。
Worker线程池(可选)
- 职责:异步处理耗时业务逻辑(如数据库操作),与Reactor线程解耦
执行过程
- Reactor 通过监听客户端请求事件。
- 如果是连接事件,Acceptor 通过 accept 接受连接,并注册到 Reactor 中,之后创建一个 Handler 处理后续事件。
- 如果是读写事件,Reactor 调用对应的 Handler 处理。
- Handler 只负责读取数据,将业务处理交给 Worker 线程池。
- Worker 线程池 完成业务处理,将结果返回给 Handler,由 Handler 发送给客户端。
当客户端发起连接时,EventLoop会通过Selector检测到ACCEPT事件,立即调用Acceptor接收新连接,并将生成Channel的注册到自己的Selector上监听读事件。当Channel有数据到达触发OP_READ时,EventLoop会调用Handler读取数据,然后将需要复杂计算的业务逻辑提交给Worker线程池处理。Worker线程完成计算后,通过任务队列将结果回调给EventLoop线程,最终由EventLoop负责将结果写回客户端。整个过程就像高效的流水线,EventLoop专注I/O调度,耗时操作交给线程池,既保证了响应速度又提升了吞吐量。
非主从模型中:只有一个EventLoop线程负责所有事件的监听和分发
EventLoop与线程的对应关系
模型类型 | EventLoop数量 | 线程数量 | 任务处理方式 |
---|---|---|---|
单线程Reactor | 1个 | 1个 | 所有任务串行执行 |
多线程Reactor | 1个 | N+1个 | EventLoop线程 + Worker线程池 |
Worker线程池的核心作用
职责分离原则
- EventLoop线程仅处理IO事件(如epoll事件监听)
- Worker线程池专职处理耗时业务逻辑(如数据库查询、复杂计算)26
性能优化关键
- 避免阻塞EventLoop线程,保证IO事件响应速度
- 通过线程池复用线程资源,降低线程创建销毁开销
经典问题解析
Q1:EventLoop如何保证线程安全?
通过任务队列的互斥锁(如pthread_mutex_t)实现生产-消费模型,EventLoop线程与Worker线程无共享状态Q2:EventLoop卡顿怎么办?
监控任务队列积压,动态扩容Worker线程池或拆分耗时任务Q3:Netty中如何实现?
通过NioEventLoopGroup管理I/O线程组,单个NioEventLoop绑定一个Selector处理多ChannelQ4:如何配置Worker线程池的最佳参数
核心参数配置策略得考虑是CPU密集型任务还是I/O密集型任务不同的任务类型需要不同的配置,同时还得考虑拒绝策略选择。Q5:Selector是如何检测ACCEPT事件的?
epoll机制或者select机制
主从多线程的 Reactor 模型
主从模型中主Reactor和子Reactor如何协作
主从Reactor模型的协作机制通过分层分工实现高效事件处理,具体流程如下:
1. 主Reactor职责
- 连接监听:主Reactor(Main-Reactor)由独立线程运行,通过
Acceptor
监听服务端端口,专门处理新连接请求。 - 连接分配:当新连接到达时,主Reactor调用
accept()
创建SocketChannel
,并通过轮询或负载均衡策略将其动态注册到某个子Reactor的Selector
上。 - 非阻塞设计:主Reactor仅处理连接事件,不参与I/O操作,避免阻塞后续连接请求。
2. 子Reactor职责
- 事件监听:每个子Reactor(Sub-Reactor)运行在独立线程中,通过多路复用器(如
epoll
)监听已注册Channel
的读写事件。 - 任务分发:当检测到I/O事件时,子Reactor将任务(如读/写操作)封装为回调函数,压入任务队列异步处理。
- 优先级处理:优先处理简单I/O事件,耗时任务(如业务逻辑)交由线程池(Worker)执行,避免阻塞事件循环。
3. 协作流程示例
- 连接阶段:客户端请求到达 → 主Reactor的
Acceptor
接收连接 → 分配至子Reactor。 - I/O阶段:子Reactor监听连接事件 → 触发
Handler
读取数据 → 线程池处理业务逻辑 →Handler
回写结果。 - 资源隔离:主/子Reactor线程互不干扰,子Reactor间通过任务队列实现负载均衡
谈谈你对Reactor模型的理解
Reactor模型本质上是一种高效的事件驱动架构,它通过I/O多路复用技术(如select/epoll)实现单线程监听大量网络连接,将就绪事件分发给对应的处理器执行。这种模型的核心价值在于解决了传统阻塞IO中"一连接一线程"的资源浪费问题,通过主从Reactor的分层设计——主线程专注接收新连接,子线程组处理已建立连接的I/O事件,再结合业务线程池异步执行耗时操作,形成连接处理、I/O操作与业务逻辑的三级流水线。在实际工程中(如Netty框架),这种设计既能保证高并发场景下的吞吐量,又通过Channel与EventLoop的绑定机制维持线程安全,使得系统在应对百万级连接时仍能保持优雅的扩展性。