跳到正文

IO 与 NIO ​

一句话:IO 模型真正决定的是「谁在等数据」和「数据要抄几遍」;NIO 用 Selector 让一个线程等多个连接,零拷贝减少用户态参与,但两者都没有消除内核和网卡之间的搬运。

适用 JDK 17 / JDK 21。示例以 Linux 上的 NIO 行为为主,Windows 的 Selector 实现不同。

为什么需要 ​

BIO 的模型是「一个连接一个线程」:线程调用 read() 后在 kernel 里睡眠,直到数据到达。连接数上升,线程栈、上下文切换和调度开销一起上涨,C10K 问题就是这么来的。

NIO 把「等待」从每个连接一个线程,变成 Selector 统一等待就绪事件;线程只在有数据可读时处理。代价是编程模型从顺序调用变成事件循环,半包、粘包、写缓冲都要自己处理。

核心机制 ​

BIO、NIO、多路复用 ​

模型等待方式线程数适用
BIOread() 阻塞到数据到达约等于连接数连接少、逻辑简单
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、连接数和就绪事件,再谈零拷贝。

把一门技术讲透到既懂也会用