主题
IO 与 NIO
一句话:IO 模型真正决定的是「谁在等数据」和「数据要抄几遍」;NIO 用 Selector 让一个线程等多个连接,零拷贝减少用户态参与,但两者都没有消除内核和网卡之间的搬运。
适用 JDK 17 / JDK 21。示例以 Linux 上的 NIO 行为为主,Windows 的 Selector 实现不同。
为什么需要
BIO 的模型是「一个连接一个线程」:线程调用 read() 后在 kernel 里睡眠,直到数据到达。连接数上升,线程栈、上下文切换和调度开销一起上涨,C10K 问题就是这么来的。
NIO 把「等待」从每个连接一个线程,变成 Selector 统一等待就绪事件;线程只在有数据可读时处理。代价是编程模型从顺序调用变成事件循环,半包、粘包、写缓冲都要自己处理。
核心机制
BIO、NIO、多路复用
| 模型 | 等待方式 | 线程数 | 适用 |
|---|---|---|---|
| BIO | read() 阻塞到数据到达 | 约等于连接数 | 连接少、逻辑简单 |
| NIO + Selector | 注册事件,select() 返回就绪连接 | 少量 IO 线程 | 大量长连接、网关 |
| AIO | 发起读,完成时回调 | 少量 | 回调风格,Linux 支持受限 |
| 虚拟线程 + BIO | 阻塞写法,运行时挂起虚拟线程 | 每任务一个虚拟线程 | JDK 21+ 的 IO 密集服务 |
NIO 的核心是 Channel(双向)、Buffer(读写共用一块内存,靠 position/limit/flip 切换模式)和 Selector(就绪事件通知)。Channel 必须配置为非阻塞,才能注册到 Selector。
select、poll、epoll
select用位图传 fd 集合,用户态和内核态之间来回拷贝,FD_SETSIZE默认 1024,返回后还要遍历所有 fd,复杂度 O(n)。poll用数组去掉 1024 限制,但仍然每次拷贝全量 fd,返回后遍历,复杂度 O(n)。epoll用红黑树维护关注的 fd,用就绪链表保存已触发的事件,epoll_wait只返回就绪项,复杂度与就绪数相关;fd 只需注册一次。
epoll 的 LT(水平触发)只要缓冲区还有数据就持续通知,ET(边缘触发)只在状态变化时通知一次。ET 必须配合非阻塞 fd 和循环读到 EAGAIN,否则会漏数据。
Java NIO 的 Selector 在 Linux 上由 EPollSelectorImpl 实现,本质仍是 epoll;JDK 抽象掉了 LT/ET 差异,应用层通常按就绪事件处理。
零拷贝的真实边界
传统 read + write:
text
磁盘 → 内核页缓存(DMA)→ 用户缓冲区(CPU)→ socket 缓冲区(CPU)→ 网卡(DMA)
4 次拷贝,4 次用户/内核上下文切换sendfile(Linux)把「内核页缓存 → socket 缓冲区」交给内核完成:
text
磁盘 → 页缓存(DMA)→ socket 缓冲区(CPU 或 DMA)→ 网卡(DMA)
2 次上下文切换,2–3 次拷贝;支持 scatter-gather 的网卡可省掉 CPU 拷贝mmap + write 把页缓存映射到用户态,省掉一次「内核 → 用户」拷贝,但仍有 3 次拷贝和 4 次切换。splice 在管道之间移动页,适合代理转发。
Java 的 FileChannel.transferTo 在 Linux 上可能走 sendfile,但只适用于「文件 → socket」;socket 到 socket、TLS 加密、压缩、内容改写都必须经过用户态。DirectByteBuffer 省掉的是 JVM 堆到 native 的拷贝,不是内核拷贝;Netty 的 CompositeByteBuf 是应用层避免合并复制的「零拷贝」,和 sendfile 不是一回事。
最小上手
一个单线程 NIO echo 服务,启动后用 nc 连接:
java
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.*;
public class NioEcho {
public static void main(String[] args) throws Exception {
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
Selector sel = Selector.open();
server.register(sel, SelectionKey.OP_ACCEPT);
ByteBuffer buf = ByteBuffer.allocate(1024);
while (true) {
sel.select();
var it = sel.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须移除,否则下一轮重复处理
if (key.isAcceptable()) {
SocketChannel c = server.accept();
c.configureBlocking(false);
c.register(sel, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel c = (SocketChannel) key.channel();
int n = c.read(buf);
if (n < 0) { c.close(); continue; }
buf.flip();
while (buf.hasRemaining()) c.write(buf);
buf.clear();
}
}
}
}
}bash
javac NioEcho.java && java NioEcho
# 另开终端
printf 'hello\n' | nc localhost 8080这个例子没有处理半包:一次 read 可能只读到半条消息,协议边界要由长度字段或分隔符定义,NIO 本身不提供消息边界。
生产动作
- 文件描述符:
ulimit -n决定单进程能开多少连接;ss -s看 socket 总量,lsof -p <pid> | wc -l看进程占用。fd 耗尽时表现为Too many open files,不是 NIO 失效。 - epoll 空轮询:旧版本 Netty 在 epoll 返回 0 但 Selector 仍报告就绪时可能空转,CPU 100%。升级 Netty,或检查
select()返回值和就绪集合是否为空。 - DirectBuffer:堆外内存不受
-Xmx限制,泄漏表现为进程 RSS 上涨但堆 dump 正常;测试环境开-Dio.netty.leakDetection.level=paranoid,生产用默认级别并监控PooledByteBufAllocator指标。 selectedKeys必须移除:处理完的 key 不移除,下一轮会重复处理,严重时空转;这是原生 NIO 最常见的错误。- TCP 粘包/半包:用长度前缀、固定长度或分隔符定义帧;不要假设一次
read等于一条消息。 - 零拷贝验证:Linux 上
strace -f -e trace=sendfile,read,write -p <pid>观察是否真的走sendfile;如果业务做了 TLS 或压缩,零拷贝链路已经被打断。
边界
- NIO 不一定更快:连接数少时,BIO 加线程池更简单;NIO 的收益来自大量连接和少量活跃数据。
- 零拷贝不是「不拷贝」:它减少的是用户态参与和 CPU 拷贝,DMA 拷贝仍然存在;TLS、压缩、协议转换都会让它失效。
- AIO 不是默认优选:Linux 的异步 IO 有历史和实现限制,Java AIO 的收益依赖场景;不要因为名字更「异步」就选它。
- 虚拟线程改变的是线程成本:JDK 21 可以用阻塞写法处理大量 IO,但连接数、fd、下游容量仍然是硬限制。
- Selector 空转不是业务 bug:先看 JDK/Netty 版本和就绪集合,再怀疑代码。
讲清楚
BIO 的瓶颈是一个连接一个线程,NIO 把等待集中到 Selector,epoll 用红黑树和就绪链表把「遍历所有 fd」变成「只处理就绪 fd」。Java NIO 的编程成本主要在 Buffer 的 flip/clear、半包处理和 selectedKeys 清理。
零拷贝要分清层次:
sendfile让数据不进用户态,减少上下文切换和 CPU 拷贝,但仍有两到三次 DMA/CPU 搬运;它只适用于文件到 socket,TLS、压缩和 socket 转发都用不上。DirectByteBuffer 省的是堆到 native 的拷贝,Netty 的 CompositeByteBuf 省的是应用层合并,三者不是同一个东西。生产上先看 fd、连接数和就绪事件,再谈零拷贝。