结论先行:用 Java NIO 实现高吞吐网络通信的核心公式是「非阻塞 Channel + 一个 Selector 管住上万连接 + 直接缓冲区 + 多路复用线程模型」,本质是把「一连接一线程」的线程数从万级压到与 CPU 核数同数量级(通常 8~32 个)。
Java NIO 和 BIO 的区别在哪?为什么 NIO 吞吐高?
结论:BIO 的瓶颈不是 CPU 而是线程。BIO 每个连接占用一个线程,线程栈默认 1MB(64 位 Linux 下 -Xss 默认值),1 万个连接就是约 10GB 内存,再加上上下文切换开销,机器直接被拖垮;NIO 用 Selector 做 IO 多路复用,1 个线程可以监听数万个 SocketChannel。
Linux 上 Selector 底层走的是 epoll(JDK 5 起支持),复杂度是 O(1) 就绪事件通知,而老的 select 有 FD_SETSIZE 1024 的文件描述符上限。所以同样是「等数据」,BIO 是 1 万线程各自阻塞在 read() 上,NIO 是 1 个线程调 selector.select() 一次拿到全部就绪列表。
NIO 的三个核心组件怎么配合?
结论:Channel 负责读写、Buffer 负责承载数据、Selector 负责感知就绪,三者缺一不可。
Channel:SocketChannel、ServerSocketChannel、DatagramChannel,必须调 configureBlocking(false) 才是非阻塞。
Buffer:ByteBuffer 是唯一能直接对接 Channel 的缓冲区,核心是 position/limit/capacity 三指针,flip() 切读模式、clear()/compact() 切写模式。
Selector:register(selector, SelectionKey.OP_ACCEPT) 返回 SelectionKey,就绪后从 selectedKeys() 拿集合,处理完必须 iterator.remove(),否则 key 会反复触发。
一个能跑的最小 Reactor 服务端怎么写?
结论:单线程 Reactor 骨架不到 40 行,先跑通再谈优化。
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080), 1024); // backlog=1024
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if (selector.select(1000) == 0) continue; // 返回就绪通道数
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
if (!key.isValid()) continue;
if (key.isAcceptable()) {
SocketChannel ch = server.accept();
ch.configureBlocking(false);
ch.socket().setTcpNoDelay(true);
ch.register(selector, SelectionKey.OP_READ, ByteBuffer.allocateDirect(16 * 1024));
} else if (key.isReadable()) {
// read → 业务处理 → 写不出去才注册 OP_WRITE
}
}
}
提升吞吐的关键参数和写法有哪些?
结论:真正拉开差距的是这 5 个点,每一项都能带来可测量收益。
- 用直接缓冲区:
ByteBuffer.allocateDirect() 分配堆外内存,省掉一次堆内到内核的拷贝,但要受 -XX:MaxDirectMemorySize 约束(默认等于 -Xmx),且不要每次读都新建,绑定在 SelectionKey.attachment() 上复用。
- 写半包处理:只在
write() 返回 0(发送缓冲区满)时才 key.interestOps(OP_READ | OP_WRITE),写完后立刻取消 OP_WRITE。结论:长期挂着 OP_WRITE 会让 select() 永远立即返回,CPU 跑满 100%。
- 零拷贝:文件转发场景用
FileChannel.transferTo(),Linux 上走 sendfile 系统调用,数据不落地用户态。
- TCP 参数:低延迟场景开
setTcpNoDelay(true) 关掉 Nagle 算法;大吞吐场景把 SO_RCVBUF/SO_SNDBUF 从默认值往 256KB~1MB 调。
- 多线程 Reactor:单线程 Reactor 的读写和业务全在一个线程,业务一慢就全堵。主从 Reactor 模型是:1 个
bossGroup 线程只管 OP_ACCEPT,把新连接分给 N 个 workerGroup(N = CPU 核数 × 1~2)各自持有独立 Selector,业务逻辑再甩给单独线程池。
Java NIO 有哪些著名的坑?
结论:epoll 空轮询和粘包是上线后最常见的两个事故源。
空轮询指 JDK 6/7 在 Linux 上 select() 明明没有就绪事件却返回非 0,导致 while 循环空转吃满 CPU(JDK-6403933)。Netty 的对策是统计空轮询次数,超过 512 次就重建 Selector 并把所有 Channel 迁移过去;自己写的话,每轮记录非 0 返回但 selectedKeys 为空的情况,连续若干次就 new Selector 重建。
粘包/半包是 TCP 字节流的固有特性:一次 read() 可能拿到半个包或两个半包。结论:必须自定义长度字段(如前 4 字节 int 表示 body 长度)或用分隔符,不能假设一次读完一条消息。
selector.wakeup() 别忘了在跨线程注册新 Channel 前调用,否则 select() 阻塞时新注册的连接要等超时才被感知。
总结一下:NIO 高吞吐 = 非阻塞 Channel + Selector 多路复用 + 直接缓冲区 + 主从 Reactor 线程模型,再配 TCP_NODELAY、合理的 SO_RCVBUF、写半包才注册 OP_WRITE、以及空轮询检测这四条工程细节。真要上生产,直接看 Netty 的 NioEventLoop 实现比自己手撸更省事——它把这几个坑都填过了。