Java 网络编程与 NIO
相关笔记:02 Java 核心特性(4.6 NIO 入门)、Netty
02 篇只讲了 NIO 的「核心组件 + 一个示例」,Netty 那篇直接跳到 Reactor。中间缺的这一层——五种 IO 模型、select/poll/epoll、零拷贝、半包粘包——这篇补上。
1. 从 BIO 说起:一连接一线程的死穴
// 经典 BIO 服务端
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞点 1:没人连就一直等
new Thread(() -> {
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int n = in.read(buf); // 阻塞点 2:没数据就一直等
}).start();
}// 经典 BIO 服务端
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞点 1:没人连就一直等
new Thread(() -> {
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int n = in.read(buf); // 阻塞点 2:没数据就一直等
}).start();
}两个阻塞点决定了它的天花板:
| 问题 | 后果 |
|---|---|
accept() 阻塞 | 只能一个线程接连接 |
read() 阻塞 | 每个连接独占一个线程,哪怕它一直不发数据 |
| 线程昂贵 | 一个线程默认 1MB 栈 + 内核调度,几千连接就把机器拖死 |
C10K 问题就是这么来的:一万个连接要一万个线程。解法只有一个方向 —— 用少量线程管大量连接,这就是 IO 多路复用。
2. NIO 三件套
2.1 Buffer:数据容器
ByteBuffer buf = ByteBuffer.allocate(1024); // 堆内
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外(零拷贝/直接 IO 用)
buf.put("hello".getBytes()); // 写模式:position 前进
buf.flip(); // 切换读模式:limit = position,position = 0
while (buf.hasRemaining()) buf.get(); // 读
buf.clear(); // 重置(数据没删,只是 position=0, limit=capacity)ByteBuffer buf = ByteBuffer.allocate(1024); // 堆内
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外(零拷贝/直接 IO 用)
buf.put("hello".getBytes()); // 写模式:position 前进
buf.flip(); // 切换读模式:limit = position,position = 0
while (buf.hasRemaining()) buf.get(); // 读
buf.clear(); // 重置(数据没删,只是 position=0, limit=capacity)四个指针是 NIO 最容易被问懵的地方:
| 指针 | 含义 |
|---|---|
capacity | 容量,分配后不变 |
position | 下一个读/写位置 |
limit | 读模式下是「最多读到哪」,写模式下等于 capacity |
mark | 打标记,reset() 回到这里 |
flip() 忘写是新手第一大 bug(读出来全是 0)。
2.2 Channel:双向通道
Stream 是单向的(要么读要么写),Channel 是双向的。常用四个:
FileChannel:文件,支持transferTo/transferFrom(零拷贝的关键)SocketChannel/ServerSocketChannel:TCPDatagramChannel:UDP
Channel 必须工作在非阻塞模式(configureBlocking(false))才能交给 Selector 管理。
2.3 Selector:多路复用器
一个线程轮询多个 Channel 的就绪事件:
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有事件就绪(可设超时)
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须移除,否则下次重复处理
if (key.isAcceptable()) {
SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = sc.read(buf);
if (n == -1) { key.cancel(); sc.close(); } // 对端关闭
else if (n > 0) { buf.flip(); /* 处理 */ }
}
}
}Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有事件就绪(可设超时)
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须移除,否则下次重复处理
if (key.isAcceptable()) {
SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = sc.read(buf);
if (n == -1) { key.cancel(); sc.close(); } // 对端关闭
else if (n > 0) { buf.flip(); /* 处理 */ }
}
}
}四个事件:OP_ACCEPT(有新连接)、OP_CONNECT(连接建立)、OP_READ、OP_WRITE。
坑:
OP_WRITE几乎一直是就绪的(只要发送缓冲区有空位),无脑注册会导致 CPU 100%。只在「写不进去」时才注册它,写完立刻取消。
3. 五种 IO 模型
| 模型 | 阻塞点 | 一句话 |
|---|---|---|
| 阻塞 IO | read 阻塞到数据就绪且拷贝完 | 最简单,一线程一连接 |
| 非阻塞 IO | read 立即返回,没数据就空转 | 要自己轮询,CPU 空转 |
| IO 多路复用 | select/poll/epoll 阻塞,就绪后 read | NIO/Netty 用的就是这个 |
| 信号驱动 IO | 内核发信号通知 | Linux 上少用 |
| 异步 IO(AIO) | 调用即返回,内核拷贝完再回调 | 真正的异步,Java 的 AIO |
关键区别:前四种都是「同步」的 —— 数据从内核拷贝到用户态这一步,仍然要用户线程参与(read 调用里完成)。只有 AIO 是内核拷完再通知,才是真异步。
生活比喻:点外卖。
- 阻塞:一直站门口等骑手。
- 非阻塞:每 30 秒下楼看一眼,没到就回去。
- 多路复用:装个门铃,等门铃响(一次性盯多个单)。
- 异步:跟骑手说「放门口发消息给我」,期间该干嘛干嘛。
4. select / poll / epoll
这是面试最爱追问的一层。
| select | poll | epoll | |
|---|---|---|---|
| fd 上限 | 1024(FD_SETSIZE) | 无上限 | 无上限 |
| 数据结构 | 位图 | 数组 | 红黑树 + 就绪链表 |
| 每次调用 | 全量拷贝 fd 集合到内核 | 同左 | 只在注册时拷贝一次 |
| 就绪检测 | 遍历所有 fd(O(n)) | 遍历(O(n)) | 回调,只返回就绪的(O(1)) |
| 触发模式 | 水平触发 | 水平触发 | 水平触发(LT)+ 边缘触发(ET) |
// epoll 三步
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 注册,只拷一次
epoll_wait(epfd, events, MAX, -1); // 内核直接返回就绪数组// epoll 三步
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 注册,只拷一次
epoll_wait(epfd, events, MAX, -1); // 内核直接返回就绪数组LT 与 ET:
- 水平触发(LT,默认):只要缓冲区还有数据就一直通知。安全,不容易漏,但可能重复通知。
- 边缘触发(ET):状态变化时只通知一次,必须一次把数据读干净(
while (read > 0)),否则剩下的数据要等下次状态变化才通知。性能高,写法要求严。
一句话总结:select/poll 的瓶颈是「每次都要把整个 fd 集合拷进内核并遍历」;epoll 用红黑树管理注册、就绪链表 + 回调通知,把「遍历」变成「只处理就绪的」。连接数越多、活跃比例越低,epoll 优势越明显。
5. Reactor 模式:NIO 的工程化封装
裸写 Selector 太容易错,所以有了 Reactor:
单 Reactor 单线程 单 Reactor 多线程 主从 Reactor
┌──────────┐ ┌──────────┐ ┌──────────┐
│ selector │ │ selector │ │ boss 组 │ ← 只管 accept
│ 业务处理 │ │ 线程池 │ └────┬─────┘
└──────────┘ └──────────┘ ┌────▼─────┐
│ worker 组│ ← 读写 + 业务
└──────────┘单 Reactor 单线程 单 Reactor 多线程 主从 Reactor
┌──────────┐ ┌──────────┐ ┌──────────┐
│ selector │ │ selector │ │ boss 组 │ ← 只管 accept
│ 业务处理 │ │ 线程池 │ └────┬─────┘
└──────────┘ └──────────┘ ┌────▼─────┐
│ worker 组│ ← 读写 + 业务
└──────────┘| 模式 | 代表 | 适用 |
|---|---|---|
| 单 Reactor 单线程 | Redis 6.0 之前 | 极简、无阻塞业务 |
| 单 Reactor 多线程 | 一般自研框架 | 业务耗时,但连接数中等 |
| 主从 Reactor | Netty、Nginx | 高并发服务端标准答案 |
Netty 的对应关系:bossGroup 处理 accept,workerGroup 处理读写,NioEventLoop 就是一个「Selector + 任务队列」的死循环。
6. 零拷贝
6.1 传统 read + write 走了几次拷贝
磁盘 → 内核缓冲区(DMA 拷贝 1 次)
内核缓冲区 → 用户缓冲区(CPU 拷贝 1 次)
用户缓冲区 → socket 缓冲区(CPU 拷贝 1 次)
socket 缓冲区 → 网卡(DMA 拷贝 1 次)磁盘 → 内核缓冲区(DMA 拷贝 1 次)
内核缓冲区 → 用户缓冲区(CPU 拷贝 1 次)
用户缓冲区 → socket 缓冲区(CPU 拷贝 1 次)
socket 缓冲区 → 网卡(DMA 拷贝 1 次)4 次拷贝 + 4 次上下文切换(用户态/内核态来回)。真正要发文件,用户态那次完全是多余的。
6.2 三种零拷贝
| 方式 | 原理 | 拷贝次数 |
|---|---|---|
| mmap + write | 用户态与内核映射同一块内存,省掉「内核→用户」那次 | 3 次 |
| sendfile | 内核直接「磁盘 → socket 缓冲区」,全程不进用户态 | 3 次(DMA 2 + CPU 1) |
| sendfile + DMA gather | 只传「文件描述符 + 偏移」,连 CPU 拷贝都省了 | 2 次(全 DMA) |
Java 里对应 FileChannel.transferTo(),底层就是 sendfile。
注意:「零拷贝」不是真的 0 次,而是减少了 CPU 拷贝和上下文切换。而且 sendfile 期间用户态拿不到数据,所以它只适合「原样转发」的场景(静态文件、MQ 的消息投递)。
6.3 Netty 的零拷贝是另一回事
Netty 说的零拷贝有四层意思,容易混:
- 堆外直接内存:
DirectByteBuffer,读写不用在 JVM 堆和内核之间再拷一次。 - CompositeByteBuf:把多个 Buffer 逻辑合并,不用真拷贝。
- slice / duplicate:共享底层数组,只换视图。
- FileRegion:基于
transferTo的文件传输。
面试口径:操作系统的零拷贝指「减少内核与用户态之间的数据拷贝」;Netty 的零拷贝还包括「减少 JVM 堆内与堆外的拷贝、以及逻辑上的 Buffer 合并」。
7. 半包与粘包
TCP 是字节流,没有消息边界。「粘包」不是 TCP 的 bug,是应用层没定边界。
| 现象 | 原因 |
|---|---|
| 粘包 | 发送方合并小包(Nagle),接收方一次读到多段 |
| 半包 | 一次读到的数据不完整(超过缓冲区/分片) |
四种解决方式:
| 方式 | 说明 | 例子 |
|---|---|---|
| 固定长度 | 每个包定长,不够补齐 | 简单但对变长消息浪费 |
| 长度字段 | 包头写明 body 长度 | 最常用(Netty LengthFieldBasedFrameDecoder) |
| 分隔符 | 用 \r\n 等特殊符号切 | HTTP、Redis 协议 |
| 自定义协议 | magic + version + length + body + checksum | 生产级 RPC |
8. Java AIO 为什么没火
AIO(AsynchronousSocketChannel)在 Linux 上底层是 epoll 模拟(不是真正的内核 AIO),JDK 又长期存在实现问题;而 NIO + Reactor 已经够用,加上 Netty 生态碾压,所以 AIO 在服务端几乎没人用。
口径:AIO 在 Windows 上用 IOCP 是真异步,在 Linux 上是模拟的;Java 服务端主流是 NIO + epoll + Reactor。
9. 常见坑与调优
| 坑 | 说明 |
|---|---|
| epoll 空轮询 | JDK 早年的 epoll bug:没有事件也返回 0,导致 selector 空转烧 CPU。Netty 用「计数超过 512 次就重建 selector」规避 |
OP_WRITE 常就绪 | 见 2.3,别常驻注册 |
忘记 key.remove() | 同一个 key 被反复处理 |
| TCP_NODELAY | 默认 Nagle 算法会攒小包(延迟 40ms 级),RPC 场景务必 setTcpNoDelay(true) |
SO_BACKLOG | accept 队列长度,太小会拒绝连接 |
| 半关闭 | shutdownOutput() 后仍可读,别忘了处理 -1 |
| 直接内存 OOM | -XX:MaxDirectMemorySize 要设,Netty 的 ByteBuf 泄漏最终都是它 |
10. 速查表
| 问题 | 一句话 |
|---|---|
| BIO / NIO / AIO 区别 | 阻塞 / 多路复用 / 真异步(Linux 上是模拟) |
| NIO 三个核心 | Channel、Buffer、Selector |
flip() 干什么 | 写模式切读模式:limit = position,position = 0 |
| select 与 epoll 差别 | 每次全量拷贝 + 遍历 vs 注册一次 + 回调就绪链表 |
| LT 与 ET | 一直通知 vs 只通知一次(必须读干净) |
| Reactor 三种 | 单线程 / 多线程 / 主从(Netty) |
| 零拷贝几次拷贝 | sendfile + DMA gather 后 2 次,全是 DMA |
| Netty 零拷贝指什么 | 堆外内存 + CompositeByteBuf + slice + FileRegion |
| 粘包怎么解决 | 长度字段最常用;分隔符、定长、自定义协议 |
| 为什么 AIO 不流行 | Linux 上是 epoll 模拟,Netty + NIO 生态更强 |
Cao Note