IO模型演进BIO到AIO
# 40.IO模型演进BIO到AIO
# 目录介绍
- 11.1 开篇疑问
- 11.2 理解IO的本质
- 11.3 五种IO模型
- 11.4 Java BIO详解
- 11.5 Java NIO核心原理
- 11.6 Java AIO异步IO
- 11.7 Reactor模式与Netty
- 11.8 常见面试深度问题
- 11.9 总结与核心要点
# 11.1 开篇疑问
疑惑:Java 的 IO 为什么有 BIO、NIO、AIO 三代?阻塞和非阻塞有什么区别?同步和异步又是什么意思?Netty 这种高性能框架底层用的是哪种 IO?epoll 为什么比 select 快?
答疑:IO 模型的演进直接影响了服务端的并发处理能力。从 BIO 的"一连接一线程"到 NIO 的"一线程管多连接",再到 AIO 的"完全异步",每一步都是对系统资源利用率的深度优化。
# 11.2 理解IO的本质
# 11.2.1 用户态与内核态
应用程序不能直接操作硬件(网卡、磁盘),必须通过操作系统内核。IO 操作就是应用程序向内核发起请求,内核与硬件交互后将数据返回给应用。
应用程序(用户态)
│ 系统调用 (read/write/accept/connect)
↓
操作系统内核(内核态)
│ 驱动程序
↓
硬件(网卡/磁盘)
2
3
4
5
6
7
系统调用的开销:每次系统调用都涉及用户态→内核态的上下文切换(保存/恢复寄存器、切换内存空间等),大约几微秒。频繁的系统调用会严重影响性能。
# 11.2.2 IO操作的两个阶段
一次网络 IO 读操作分为两个阶段:
- 等待数据就绪:数据从网卡到达内核缓冲区(数据从网络传输到内核)
- 数据拷贝:数据从内核缓冲区拷贝到用户缓冲区(内核空间→用户空间)
网卡 ──→ 内核缓冲区 ──→ 用户缓冲区
阶段1 阶段2
(等待数据) (拷贝数据)
2
3
不同 IO 模型的区别,就在于这两个阶段是否阻塞。
# 11.2.3 同步异步与阻塞非阻塞
这两组概念经常被混淆,但它们描述的是不同维度:
阻塞/非阻塞:描述调用者的行为
阻塞:线程被挂起,等待操作完成后才返回
非阻塞:调用立即返回,不等待操作完成
同步/异步:描述操作的完成方式
同步:调用者自己等待结果(主动检查)
异步:操作完成后通过回调/通知告知调用者(被动接收)
类比:
去餐厅点菜
- 同步阻塞(BIO):在柜台等,一直站着等菜做好
- 同步非阻塞(NIO):点完菜后逛一圈,每隔几分钟来柜台问一次
- 异步非阻塞(AIO):点完菜后逛街,菜做好了服务员打电话通知你
2
3
4
5
6
7
8
9
10
11
12
13
# 11.3 五种IO模型
# 11.3.1 阻塞IO模型
应用线程 内核
│ │
│── read() ──→ │
│ (阻塞等待) │ ← 等待数据到达(阶段1)
│ (阻塞等待) │ ← 数据从内核拷贝到用户(阶段2)
│←── 返回数据 ── │
│ │
2
3
4
5
6
7
两个阶段都阻塞。线程在等待 IO 完成期间无法做其他事。
# 11.3.2 非阻塞IO模型
应用线程 内核
│── read() ──→ │
│←── EAGAIN ── │ 数据没好
│── read() ──→ │
│←── EAGAIN ── │ 数据还没好
│── read() ──→ │
│ (阻塞) │ ← 数据拷贝(阶段2仍阻塞)
│←── 返回数据 ── │
2
3
4
5
6
7
8
等待阶段不阻塞(立即返回),但需要轮询。数据拷贝阶段仍然阻塞。CPU 空转浪费严重。
# 11.3.3 IO多路复用模型
应用线程 内核
│── select(fds) ──→ │
│ (阻塞在select上) │ ← 同时监听多个fd
│←── fd就绪通知 ── │ ← 某个或某些fd就绪
│── read(fd) ──→ │
│ (阻塞) │ ← 数据拷贝
│←── 返回数据 ── │
2
3
4
5
6
7
核心思想:用一个线程(一次系统调用)监控多个 IO 通道,哪个就绪就处理哪个。这是 Java NIO 和 Netty 的基础。
| 实现 | 特点 | 最大fd数 |
|---|---|---|
| select | 每次调用全量拷贝fd集合,全量遍历 | 1024 (FD_SETSIZE) |
| poll | 用链表存储fd,无数量限制,但仍全量遍历 | 无限制 |
| epoll | 事件驱动,只返回就绪的fd,性能最优 | 无限制(受内存限制) |
# 11.3.4 信号驱动IO模型
应用线程 内核
│── sigaction(SIGIO) ──→ │ 注册信号处理函数
│ (继续执行其他操作) │
│ │ ← 数据到达
│←── SIGIO 信号 ── │ 内核发送信号
│── read() ──→ │
│ (阻塞) │ ← 数据拷贝
│←── 返回数据 ── │
2
3
4
5
6
7
8
数据就绪时内核发送 SIGIO 信号通知应用。阶段1不阻塞,阶段2阻塞。UDP 场景可用,TCP 场景不实用(信号过多)。
# 11.3.5 异步IO模型
应用线程 内核
│── aio_read() ──→ │
│ (立即返回,继续执行) │ ← 内核完成等待+拷贝
│ (做其他事) │ (两个阶段都由内核处理)
│←── 回调通知 ── │ 全部完成
2
3
4
5
两个阶段都不阻塞。内核完成所有工作后通知应用。这是真正的异步。
# 11.3.6 五种模型对比总结
阶段1(等待数据) 阶段2(拷贝数据)
阻塞IO 阻塞 阻塞
非阻塞IO 非阻塞(轮询) 阻塞
IO多路复用 阻塞(在select上) 阻塞
信号驱动IO 非阻塞(信号通知) 阻塞
异步IO 非阻塞 非阻塞
↑ ↑
前四种都是同步IO 只有AIO是异步IO
2
3
4
5
6
7
8
关键认知:IO 多路复用(select/epoll)本质上还是同步IO,因为数据拷贝阶段仍需要用户线程参与。只有 AIO 是真正的异步——内核完成所有工作后通知应用。
# 11.4 Java BIO详解
# 11.4.1 BIO编程模型
// BIO 服务端:一连接一线程
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept(); // 阻塞等待连接
new Thread(() -> {
try {
InputStream in = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = in.read(buffer)) != -1) { // 阻塞等待数据
String msg = new String(buffer, 0, len);
System.out.println("收到: " + msg);
// 响应
OutputStream out = socket.getOutputStream();
out.write(("Echo: " + msg).getBytes());
out.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 11.4.2 BIO的瓶颈
每个连接需要一个独立线程处理。当连接数到达数千时:
- 线程数暴增(每个线程占 1MB 栈内存)
- 大量线程上下文切换(每次切换约 1-10μs)
- 大部分线程在阻塞等待,CPU 利用率极低
1000 连接 → 1000 线程 → ~1GB 栈内存 + 大量上下文切换
10000 连接 → 10000 线程 → 系统崩溃
C10K 问题:如何同时处理10000个并发连接?
2
3
# 11.4.3 伪异步IO优化
用线程池限制线程数,但根本问题未解决:
// 伪异步 IO:用线程池处理连接
ExecutorService pool = Executors.newFixedThreadPool(200);
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept();
pool.execute(() -> {
// 处理连接...
// 如果客户端不发数据,线程仍然阻塞在 read() 上
// 200个线程池如果全部阻塞,新连接将被拒绝
});
}
// 问题:200个线程全部阻塞在 read(),
// 即使有新连接进来,也没有线程可用
// 本质:阻塞模型下,一个线程只能服务一个连接
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 11.5 Java NIO核心原理
# 11.5.1 三大核心组件
| 组件 | 作用 | 类比 |
|---|---|---|
| Channel | 双向数据通道 | 水管(可读可写) |
| Buffer | 数据缓冲区 | 水桶(临时存储) |
| Selector | 多路复用器 | 调度员(管理多个通道) |
Selector(一个线程)
/ | \
Channel1 Channel2 Channel3
| | |
Buffer Buffer Buffer
2
3
4
5
与 BIO 的关键区别:BIO 面向流(Stream),NIO 面向缓冲区(Buffer)。BIO 的 InputStream/OutputStream 是单向的,NIO 的 Channel 是双向的。
# 11.5.2 Channel详解
// Channel 的常见实现
FileChannel // 文件读写(不能设置非阻塞)
SocketChannel // TCP 客户端
ServerSocketChannel // TCP 服务端
DatagramChannel // UDP
// Channel 的双向性
FileChannel channel = FileChannel.open(path, READ, WRITE);
channel.read(buffer); // 从 Channel 读到 Buffer
channel.write(buffer); // 从 Buffer 写到 Channel
// SocketChannel 的非阻塞模式
SocketChannel client = SocketChannel.open();
client.configureBlocking(false); // 设置非阻塞
client.connect(new InetSocketAddress("localhost", 8080));
// 非阻塞模式下,connect/read/write 都不会阻塞
// connect() 可能立即返回 false(连接还未完成)
// read() 可能返回 0(没有数据可读)
while (!client.finishConnect()) {
// 等待连接完成
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 11.5.3 Buffer深度设计
Buffer 的核心是四个属性:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑ ↑
mark position capacity
limit=10
0 <= mark <= position <= limit <= capacity
2
3
4
5
6
7
8
| 属性 | 含义 |
|---|---|
| capacity | 缓冲区总容量,创建后不变 |
| limit | 可读/写的边界 |
| position | 当前读/写位置 |
| mark | 标记位,调用 reset() 可以回到 mark 的位置 |
关键操作:
ByteBuffer buffer = ByteBuffer.allocate(10);
// 写模式(初始状态)
// position=0, limit=10, capacity=10
buffer.put("Hello".getBytes());
// position=5, limit=10, capacity=10
// flip() 切换到读模式
buffer.flip();
// position=0, limit=5, capacity=10
// flip 做了两件事:limit=position, position=0
// 读取数据
byte b = buffer.get(); // position++
// position=1, limit=5
// compact() 压缩:把未读数据移到开头,继续写
buffer.compact();
// 将 position~limit 的数据拷贝到 0~(limit-position)
// position = limit-position, limit = capacity
// clear() 清空(只重置指针,不清数据)
buffer.clear();
// position=0, limit=capacity
// rewind() 重新读:position 回到 0,limit 不变
buffer.rewind();
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
Buffer 使用的标准模板:
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从 Channel 读数据到 Buffer
int bytesRead = channel.read(buffer); // 写模式
// 切换到读模式
buffer.flip();
// 读取 Buffer 中的数据
while (buffer.hasRemaining()) {
byte b = buffer.get();
}
// 清空 Buffer,准备下一次写入
buffer.clear(); // 或 buffer.compact()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 11.5.4 DirectBuffer与堆外内存
// HeapByteBuffer:在 JVM 堆中分配
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// DirectByteBuffer:在堆外(本地内存)分配
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
2
3
4
5
| 特性 | HeapByteBuffer | DirectByteBuffer |
|---|---|---|
| 内存位置 | JVM 堆 | 本地内存(堆外) |
| 分配速度 | 快 | 慢(需要系统调用) |
| GC 影响 | 受 GC 管理 | 不受 GC 直接管理 |
| IO 效率 | 需要额外拷贝 | 零拷贝(内核直接访问) |
| 适用场景 | 临时、小缓冲区 | 长期使用、大缓冲区 |
为什么 DirectBuffer IO 更快?
HeapBuffer 的 IO 过程(多一次拷贝):
用户空间 HeapBuffer → 临时 DirectBuffer → 内核缓冲区
DirectBuffer 的 IO 过程(少一次拷贝):
用户空间 DirectBuffer → 内核缓冲区
原因:JVM 堆内存可能被 GC 移动(地址变化)
内核需要稳定的内存地址做 DMA 传输
所以 HeapBuffer 需要先拷贝到堆外的固定地址区域
2
3
4
5
6
7
8
9
# 11.5.5 Selector多路复用
一个 Selector 可以管理成千上万个 Channel,实现"一个线程处理多个连接":
// NIO 服务端核心模型
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int readyCount = selector.select(); // 阻塞直到有事件就绪
if (readyCount == 0) continue;
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {
// 新连接
SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 有数据可读
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int len = client.read(buffer);
if (len > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
System.out.println("收到: " + new String(data));
} else if (len == -1) {
key.cancel();
client.close();
}
}
iter.remove(); // 必须移除已处理的 key!
}
}
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
35
36
37
38
39
40
SelectionKey 的四种事件:
| 事件 | 常量 | 触发条件 |
|---|---|---|
| OP_ACCEPT | 16 | ServerSocketChannel 收到新连接 |
| OP_CONNECT | 8 | SocketChannel 连接完成 |
| OP_READ | 1 | Channel 有数据可读 |
| OP_WRITE | 4 | Channel 可以写数据 |
# 11.5.6 select/poll/epoll底层对比
| 特性 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | bitmap (fd_set) | 数组 (pollfd) | 红黑树 + 就绪链表 |
| fd 数量限制 | 1024 | 无限制 | 无限制 |
| 传递方式 | 每次拷贝全量fd | 每次拷贝全量fd | fd 只需注册一次 |
| 检查方式 | 线性遍历所有fd | 线性遍历所有fd | 回调,只返回就绪fd |
| 时间复杂度 | O(n) | O(n) | O(1)(就绪事件) |
| 触发方式 | 水平触发 | 水平触发 | 水平触发+边缘触发 |
# 11.5.7 epoll的工作原理
// epoll 三个核心系统调用
// 1. epoll_create: 创建 epoll 实例(红黑树 + 就绪链表)
int epfd = epoll_create(1);
// 2. epoll_ctl: 注册/修改/删除 fd 的监听事件
// fd 插入红黑树,同时注册回调函数
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
// 3. epoll_wait: 等待就绪事件
// 检查就绪链表,如果为空则阻塞
// 有 fd 就绪时,回调函数将 fd 加入就绪链表
int n = epoll_wait(epfd, events, maxevents, timeout);
2
3
4
5
6
7
8
9
10
11
12
13
epoll 的内部数据结构:
epoll 实例
├── 红黑树(存储所有注册的 fd)
│ ├── fd1 + callback
│ ├── fd2 + callback
│ └── fd3 + callback
└── 就绪链表(存储有事件的 fd)
├── fd1(有数据可读)
└── fd3(有新连接)
当 fd 上有事件时:
→ 网卡中断 → 内核处理 → 执行 fd 的回调函数
→ 回调将 fd 加入就绪链表
→ epoll_wait 返回就绪链表中的 fd
2
3
4
5
6
7
8
9
10
11
12
13
水平触发(LT) vs 边缘触发(ET):
水平触发(Level Triggered,默认):
fd 就绪后,如果没有处理完,下次 epoll_wait 还会返回
类似:水位线,只要水位高于阈值就通知
安全但可能频繁唤醒
边缘触发(Edge Triggered):
fd 状态变化时才通知,只通知一次
类似:水位变化的瞬间才通知
高效但必须一次性处理完所有数据(否则丢失事件)
Netty 默认使用 ET 模式
2
3
4
5
6
7
8
9
10
# 11.5.8 零拷贝原理
传统 IO 需要 4 次数据拷贝和 4 次上下文切换:
传统IO(read + write):
1. read(): 用户态→内核态(上下文切换)
2. DMA 拷贝: 磁盘 → 内核缓冲区(Page Cache)
3. CPU 拷贝: 内核缓冲区 → 用户缓冲区
4. read() 返回: 内核态→用户态(上下文切换)
5. write(): 用户态→内核态(上下文切换)
6. CPU 拷贝: 用户缓冲区 → Socket 缓冲区
7. DMA 拷贝: Socket 缓冲区 → 网卡
8. write() 返回: 内核态→用户态(上下文切换)
总计: 4次拷贝 + 4次上下文切换
2
3
4
5
6
7
8
9
10
11
sendfile 零拷贝:
sendfile() 零拷贝:
1. sendfile(): 用户态→内核态(上下文切换)
2. DMA 拷贝: 磁盘 → 内核缓冲区
3. CPU 拷贝: 内核缓冲区 → Socket 缓冲区(Linux 2.4+ 可省略)
4. DMA 拷贝: Socket 缓冲区 → 网卡
5. sendfile() 返回: 内核态→用户态(上下文切换)
总计: 2-3次拷贝 + 2次上下文切换
Linux 2.4+ (DMA Scatter/Gather):
只需 2 次 DMA 拷贝,0 次 CPU 拷贝
内核缓冲区的描述符直接传给网卡
2
3
4
5
6
7
8
9
10
11
12
// Java NIO 的零拷贝实现
FileChannel fileChannel = FileChannel.open(path);
// transferTo 底层调用 sendfile
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
// Kafka、Netty 的文件传输都使用了零拷贝
2
3
4
5
6
7
# 11.5.9 内存映射mmap
// mmap 将文件直接映射到用户空间内存
// 省去了内核缓冲区→用户缓冲区的拷贝
FileChannel channel = FileChannel.open(path, READ, WRITE);
MappedByteBuffer mmap = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
// 直接操作映射的内存区域
mmap.put(0, (byte) 'H'); // 修改会反映到文件
byte b = mmap.get(0); // 读取文件内容
// mmap 的优势:
// 1. 减少数据拷贝(内核空间和用户空间共享同一块物理内存)
// 2. 文件读写变成内存操作(PageFault 由内核自动处理)
// 3. RocketMQ 的 CommitLog 使用 mmap 提升性能
2
3
4
5
6
7
8
9
10
11
12
13
# 11.6 Java AIO异步IO
Java 7 引入 AIO(NIO.2),真正的异步非阻塞:
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
// 异步接受连接——注册回调
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
@Override
public void completed(AsynchronousSocketChannel client, Void att) {
server.accept(null, this); // 继续接受下一个连接
ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
buf.flip();
byte[] data = new byte[buf.remaining()];
buf.get(data);
System.out.println("收到: " + new String(data));
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
exc.printStackTrace();
}
});
}
@Override
public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});
// 也支持 Future 方式
Future<AsynchronousSocketChannel> future = server.accept();
AsynchronousSocketChannel client = future.get(); // 阻塞等待
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
AIO 的现状:AIO 在 Linux 上的实现底层仍然基于 epoll(模拟异步),并非真正的内核异步IO(io_uring 是 Linux 5.1 才引入的真正异步IO)。因此 AIO 在 Linux 上性能提升有限,Netty 等框架并未采用。
各平台 AIO 实现:
Linux: 基于 epoll 模拟(非真正异步) → 性能无优势
Windows: 基于 IOCP(真正异步) → 但 Java 服务端很少用 Windows
macOS: 基于 kqueue → 支持不完善
2
3
4
# 11.7 Reactor模式与Netty
# 11.7.1 Reactor模式的三种变体
1. 单Reactor单线程(Redis 6.0前):
┌──────────────────────────────┐
Clients →│ Reactor(select + dispatch) │
│ ↓ ↓ ↓ │
│ accept read/send decode │
│ (全部在一个线程中处理) │
└──────────────────────────────┘
2
3
4
5
6
2. 单Reactor多线程:
┌──────────────┐
Clients →│ Reactor │ ← select + dispatch
│ (主线程) │
└──────┬───────┘
│
┌──────▼───────┐
│ Worker Pool │ ← 多个工作线程处理业务
│ Thread 1-N │
└──────────────┘
2
3
4
5
6
7
8
9
3. 主从Reactor多线程(Netty 默认):
┌──────────────┐
Clients →│ Main Reactor │ ← 只负责 accept
│ (Boss Group) │
└──────┬───────┘
│ 将新连接分配给
┌──────▼───────┐
│ Sub Reactors │ ← 每个 Sub Reactor 管理一组连接的 IO
│ (Worker Group)│ 每个 Sub Reactor 一个线程 + 一个 Selector
│ Reactor 1 │
│ Reactor 2 │
│ ... │
└──────┬───────┘
│ 业务处理交给
┌──────▼───────┐
│ Business Pool │ ← 可选的业务线程池
└──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 11.7.2 Netty的线程模型
// Netty 的主从 Reactor 模型
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // Main Reactor
EventLoopGroup workerGroup = new NioEventLoopGroup(); // Sub Reactors
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new MyBusinessHandler());
}
});
bootstrap.bind(8080).sync();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Netty 的核心设计:
EventLoop = 一个线程 + 一个 Selector + 一个任务队列
→ 一个 EventLoop 管理多个 Channel
→ 一个 Channel 只绑定一个 EventLoop(线程安全!)
→ 所有 IO 操作都在 EventLoop 线程中执行
EventLoopGroup = 多个 EventLoop
→ BossGroup: 通常1个 EventLoop,负责 accept
→ WorkerGroup: 通常 CPU核心数×2 个 EventLoop,负责 IO
2
3
4
5
6
7
8
# 11.7.3 Netty为什么选择NIO
| 考量 | NIO | AIO |
|---|---|---|
| Linux 支持 | epoll 成熟高效 | 基于 epoll 模拟,无真正优势 |
| Windows | 不太关注 | IOCP 真异步,但 Java 服务端少用 Windows |
| 编程模型 | Reactor 模式,清晰可控 | 回调嵌套,代码复杂(回调地狱) |
| 社区生态 | 成熟稳定,大量生产验证 | 使用较少 |
| 连接数 | 大量连接少量活跃时优势明显 | 理论上所有场景都优 |
Netty 基于 NIO + Reactor 模式,结合 EventLoop 实现了高性能的网络通信框架。Dubbo、gRPC、Elasticsearch、Kafka 等都使用 Netty。
# 11.8 常见面试深度问题
Q1:NIO 的 Selector 空轮询 bug 是什么?
// JDK 的 epoll 实现有一个 bug(JDK-6670302)
// 在某些情况下,select() 本应阻塞,但立即返回 0
// 导致 while(true) 死循环,CPU 100%
// Netty 的解决方案:
// 记录 select 空轮询的次数
// 当空轮询次数超过阈值(默认512次),重建 Selector
if (selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
rebuildSelector(); // 创建新的 Selector,重新注册所有 Channel
}
2
3
4
5
6
7
8
9
10
Q2:为什么 Netty 不直接使用 JDK 的 NIO?
- JDK NIO 的 API 复杂,容易出错(忘记 remove SelectionKey、Buffer flip 等)
- Selector 空轮询 bug
- 没有完善的异常处理和重连机制
- 没有拆包/粘包处理
- Netty 封装了更高效的 ByteBuf(池化、引用计数、零拷贝)
Q3:什么是粘包和拆包?
TCP 是面向字节流的协议,没有消息边界的概念
发送方发送两条消息: "Hello" + "World"
接收方可能收到:
1. "HelloWorld" → 粘包
2. "Hel" + "loWorld" → 拆包
3. "Hello" + "World" → 正常(运气好)
解决方案(Netty 内置支持):
1. 固定长度: FixedLengthFrameDecoder
2. 分隔符: DelimiterBasedFrameDecoder
3. 长度字段: LengthFieldBasedFrameDecoder(最常用)
4. 自定义协议: 消息头(长度)+ 消息体
2
3
4
5
6
7
8
9
10
11
12
13
# 11.9 总结与核心要点
IO 模型演进:
| 模型 | 等待阶段 | 拷贝阶段 | 线程模型 | 典型应用 |
|---|---|---|---|---|
| BIO | 阻塞 | 阻塞 | 一连接一线程 | 传统 Servlet |
| NIO | 非阻塞(多路复用) | 阻塞 | 一线程管多连接 | Netty、Tomcat NIO |
| AIO | 非阻塞 | 非阻塞 | 回调/Future | 少量使用 |
核心设计思想:
- 多路复用是关键突破:从"一连接一线程"变为"一线程管多连接",支撑了百万级并发
- 事件驱动:Selector/epoll 只通知有事件的 Channel,避免了无效遍历
- 零拷贝:减少数据在用户态和内核态之间的复制次数,提升传输效率
- Reactor 模式:主从 Reactor 分离连接接入和 IO 处理,充分利用多核
核心要点速查:
| 问题 | 答案 |
|---|---|
| BIO 的瓶颈 | 一连接一线程,线程资源耗尽 |
| NIO 的核心 | Channel + Buffer + Selector |
| epoll 比 select 快 | 红黑树 + 就绪链表 + 回调,O(1) 获取就绪fd |
| 零拷贝实现 | sendfile(transferTo)、mmap |
| AIO 为何不流行 | Linux 上基于 epoll 模拟,无真正优势 |
| Netty 用什么模型 | 主从 Reactor + NIO(epoll) |
| Buffer 的 flip | limit=position, position=0,切换读模式 |
| DirectBuffer 优势 | 避免堆内存到堆外的拷贝,IO 更高效 |
理解 IO 模型,是理解高性能网络编程(Netty、Tomcat、Redis)的基础。