Java I/O 与 NIO 实战:BIO、NIO、AIO 与零拷贝深度解析

面试里问完并发、集合,下一个必考的就是 I/O 与网络编程。本文不重复线程池、《Java 并发编程核心》里的 volatile/CAS/AQS,也不展开集合源码,而是聚焦一个独立主题:Java 到底怎么读写数据、怎么用少量线程扛住海量连接。读完你能回答:BIO 为什么扛不住 C10K?Selector 凭什么一个线程管几万个连接?零拷贝究竟”零”掉了什么?

一、为什么 I/O 模型是 Java 工程师的分水岭

写业务代码时,I/O 往往被框架封装得无影无踪(Spring MVC 一个 @RequestMapping 就完事)。但一旦要写中间件、网关、RPC 框架、消息队列客户端,或者排查”线程一路涨到几万、GC 频繁、CPU 却不高”的线上问题,I/O 模型的理解深度就直接决定了你的定位能力。

I/O 的本质只有一句话:数据在「用户空间」与「内核空间」之间的搬运。所有模型差异,都是”这段搬运由谁触发、要不要阻塞线程、要不要来回切换”的组合。

二、先把概念厘清:阻塞/非阻塞、同步/异步

这两组词最容易被混用,必须彻底分开看,它们描述的是两个正交维度

  • 阻塞 vs 非阻塞:描述”应用程序发起系统调用后,线程会不会被挂起”。阻塞调用会交出 CPU 让出线程;非阻塞调用立刻返回,即使数据还没准备好。
  • 同步 vs 异步:描述”数据从内核拷贝到用户空间这一步,由谁来完成、结果如何通知”。
组合 含义 代表
同步阻塞 调用后线程挂起,直到数据拷贝完成 传统 BIO
同步非阻塞 调用立刻返回,需轮询;就绪后仍由自己拷贝 NIO 非阻塞模式
异步非阻塞 调用后立即返回,内核完成拷贝后回调通知 AIO

一句话记忆:“阻塞/非阻塞”看线程等不等,”同步/异步”看拷贝谁来做。常见误区是把 NIO 当成”异步”,其实标准 NIO 是同步非阻塞——就绪后读数据仍是用户线程去读;真正异步的是 AIO。

UNIX 网络编程里经典的五种 I/O 模型:

  1. 阻塞 I/O:最传统,读/写/accept 全程阻塞。
  2. 非阻塞 I/O:轮询,CPU 空转严重。
  3. I/O 多路复用:select/poll/epoll,一个线程监听多个 fd(NIO 的 Selector)。
  4. 信号驱动 I/O:内核用信号通知就绪,实际使用少。
  5. 异步 I/O:内核完成全部工作后通知(AIO,Linux 上实现有争议)。

三、BIO:传统阻塞式 I/O

3.1 字节流与字符流体系

Java 传统 I/O(java.io)以两个维度组织:输入/输出 × 字节/字符

1
2
3
4
5
6
7
8
9
InputStream(字节输入)      OutputStream(字节输出)
├ FileInputStream ├ FileOutputStream
├ ByteArrayInputStream ├ ByteArrayOutputStream
├ BufferedInputStream ├ BufferedOutputStream
└ FilterInputStream └ FilterOutputStream
Reader(字符输入) Writer(字符输出)
├ FileReader ├ FileWriter
├ InputStreamReader ├ OutputStreamWriter
└ BufferedReader └ BufferedWriter

字节流以 8 位为单位,处理一切二进制数据(图片、音频、网络包);字符流char(UTF-16 码元)为单位,内部靠 StreamDecoder/StreamEncoder 做编码转换。读写文本优先用字符流,避免手动处理编码导致中文乱码。

3.2 装饰器模式:缓冲流的真正价值

java.io 是装饰器模式的教科书实现:节点流负责”连到数据源”,处理流负责”增强能力”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 不加缓冲:每次 read() 都可能触发一次系统调用
try (InputStream in = new FileInputStream("big.txt")) {
int b;
while ((b = in.read()) != -1) { /* 逐字节读,慢 */ }
}

// 加缓冲:一次性读 8KB 到内存,后续命中缓冲区
try (BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream("big.txt"), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}

BufferedInputStream 默认 8KB 缓冲区把”每次一字节一次系统调用”变成”每 8KB 一次”,吞吐量能提升一到两个数量级。try-with-resources 保证 close() 一定执行,且按后开先关的顺序释放。

3.3 BIO 的致命伤:一连接一线程

传统服务端写法:ServerSocket.accept() 会阻塞,每来一个连接就 new Thread 处理。

1
2
3
4
5
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept(); // 阻塞,直到有连接
new Thread(() -> handle(socket)).start(); // 一连接一线程
}

问题很直接:线程是稀缺资源。每个线程默认占用约 1MB 栈空间,且线程上下文切换有成本。1000 个连接就要 1000 个线程,1 万个就是 C10K 问题——线程创建/切换直接压垮系统。

BIO 的线程数 ≈ 连接数。连接数一上来,内存与上下文切换双双爆炸。这正是 NIO 多路复用要解决的核心命题:用少量线程管理海量连接

四、NIO 三件套之一:Buffer(缓冲区)

NIO(java.nio)与流最大的区别是面向缓冲区:数据不再逐字节流动,而是先读进 Buffer 再处理。

Buffer 通过四个指针控制读写:

属性 含义
capacity 容量,创建后不可变
limit 可读/可写的边界
position 下一个要读/写的位置
mark 临时记录位置,便于 reset() 回退

读模式与写模式切换靠 flip(),这是最容易踩坑的地方:

1
2
3
4
5
6
7
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.put("hello".getBytes()); // 写模式:position=5, limit=1024
buf.flip(); // 切换到读模式:limit=5, position=0
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
buf.clear(); // 清空,回到写模式:position=0, limit=capacity

三个高频方法的区别:

  • flip()limit = position; position = 0,写→读。
  • clear()position = 0; limit = capacity,丢弃全部数据。
  • compact():把未读完的数据挪到头部,再切回写模式(部分消费场景用)。

ByteBuffer.allocate() 分配在 JVM 堆上,allocateDirect() 分配在堆外内存(直接缓冲区)。堆外缓冲区不走 GC、FileChannel/SocketChannel 的读写可直接交给内核,适合大流量场景;代价是分配/回收昂贵,且不受堆内存限制,滥用会导致物理内存吃紧。

五、NIO 三件套之二:Channel(通道)

Channel 与流的根本区别:

  • 双向:一个 Channel 既可读也可写(流是单向的)。
  • 面向缓冲区:读写必须通过 Buffer。
  • 可非阻塞:可注册到 Selector 实现多路复用。

常用实现:

Channel 用途
FileChannel 文件读写,支持 transferTo/transferFrom(零拷贝核心)
SocketChannel 客户端 TCP 通道
ServerSocketChannel 服务端监听通道
DatagramChannel UDP 通道
1
2
3
4
5
6
7
8
9
try (FileChannel in = FileChannel.open(Paths.get("a.dat"), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get("b.dat"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = in.size();
long pos = 0;
while (pos < size) {
pos += in.transferTo(pos, size - pos, out); // 零拷贝搬运
}
}

六、NIO 三件套之三:Selector(多路复用器)

Selector 是 NIO 的灵魂:一个线程通过一个 Selector 监听多个 Channel 的就绪事件,从而用极少的线程支撑海量连接。

SelectionKey 的四种事件:

事件常量 触发时机
OP_ACCEPT 16 服务端有新连接
OP_CONNECT 8 客户端连接完成
OP_READ 1 可读
OP_WRITE 4 可写(发送缓冲区有空位)

一个可运行的 NIO Echo 服务端骨架:

1
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
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false); // 必须非阻塞才能注册
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);

ByteBuffer buf = ByteBuffer.allocate(1024);
while (true) {
selector.select(); // 阻塞,直到有通道就绪
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须手动移除,否则重复处理
if (key.isAcceptable()) {
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
buf.clear();
int n = sc.read(buf);
if (n == -1) { sc.close(); continue; }
buf.flip();
sc.write(buf); // Echo 回去
}
}
}

两个经典陷阱:① selectedKeys() 处理完必须 it.remove(),否则已处理的事件会被反复触发;② 注册 Channel 前必须 configureBlocking(false),否则抛 IllegalBlockingModeException。此外 JDK 早期版本在 Linux 上有 epoll 空轮询 bug(selector.select() 无就绪却立即返回,导致 CPU 100%),Netty 通过”统计空轮询次数、超阈值时重建 Selector”来规避。

七、AIO:真正的异步 I/O

AIO(NIO.2,JDK 7 引入)才是真正的异步:发起读写后立即返回,内核完成数据拷贝后回调通知

两种使用风格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 风格一:Future —— 主动轮询结果
AsynchronousSocketChannel ch = AsynchronousSocketChannel.open();
Future<Integer> f = ch.read(ByteBuffer.allocate(1024));
int n = f.get(); // 阻塞在这里取结果

// 风格二:CompletionHandler —— 回调,真正的异步
ch.read(buffer, null, new CompletionHandler<Integer, Void>() {
@Override
public void completed(Integer result, Void attachment) {
buffer.flip();
// 处理数据……
}
@Override
public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});

AIO 在国内为什么没火起来?Linux 原生 AIO(io_uring 之前)对网络 I/O 支持不完善,JDK 的 AIO 在 Linux 上本质是用 epoll 加线程池模拟,并没有比 NIO 更快,反而 API 更别扭。Netty 也因此早早放弃了 AIO,专注打磨 NIO 多路复用。

八、零拷贝:把上下文切换砍到最低

“零拷贝”(Zero-Copy)不是真的不拷贝,而是消除 CPU 参与的数据拷贝与不必要的上下文切换。以经典的”把文件从磁盘发给网卡”为例。

传统 read + write:4 次上下文切换 + 4 次拷贝

1
2
磁盘 →[DMA]→ 内核缓冲区 →[CPU]→ 用户缓冲区 →[CPU]→ Socket缓冲区 →[DMA]→ 网卡
读(切换) 读(切换) 写(切换) 写(切换)

mmap + write:3 次拷贝,用户与内核共享缓冲区。

sendfile / transferTo:2 次拷贝,数据不经过用户空间。

1
2
// FileChannel.transferTo 底层即 sendfile(Linux 上还可用 sendfile + DMA gather 进一步优化)
in.transferTo(position, count, out);

三种方式对比:

方式 上下文切换 数据拷贝 数据经用户空间
传统 read+write 4 4
mmap+write 4 3 是(共享)
sendfile / transferTo 2 2

Kafka、RocketMQ、Netty 的高吞吐都建立在零拷贝之上(Kafka 的 FileRecords.writeTo 用的就是 transferTo)。但注意:transferTo 单次传输有上限(约 2GB),大文件必须循环调用;且它无法在传输途中修改数据,需要加密时零拷贝会失效。

九、BIO / NIO / AIO 选型对比

维度 BIO NIO AIO
模型 同步阻塞 同步非阻塞(多路复用) 异步非阻塞
编程复杂度 高(要自己管 Buffer/Selector) 中(回调风格)
连接数 低(一连接一线程) 高(少量线程管海量连接)
适用场景 连接少、并发低 高并发、长连接 连接多且 IO 密集(当前生态偏弱)
典型代表 InputStream / 早期 Tomcat Netty、Nginx、Redis 使用极少

实际生产建议:高并发网络编程直接上 Netty(NIO + Reactor 模型),不必纠结原生 NIO 的繁琐 API。

十、生产避坑清单

  1. 文本读写用字符流,并显式指定 StandardCharsets.UTF_8,不要依赖平台默认编码。
  2. try-with-resources 关闭资源,流/通道不关是句柄泄漏的头号来源。
  3. Buffer 别忘了 flip():”写完直接读”是新手第一个 bug,读到的永远是 0 字节。
  4. selectedKeys() 处理完要 remove(),否则事件被重复消费。
  5. 注册 Selector 前必须 configureBlocking(false)
  6. 堆外内存要节制allocateDirect 不受 GC 管理,用 Cleaner/显式释放,警惕 OutOfMemoryError: Direct buffer memory
  7. 零拷贝注意单次 2GB 上限,大文件循环 transferTo;需加密/改写时不能用 sendfile。
  8. 不要自己手写 NIO 服务端上生产,Reactor 模型、半包粘包、空轮询、写缓冲区水位等问题坑极深,用 Netty。

一句话收尾:BIO 是”一个连接一个服务员”,NIO 是”一个服务员用叫号机管理全部窗口”,AIO 是”顾客自己办完再叫服务员”,零拷贝则是”让数据抄近道不打扰 CPU”。理解了这个类比,I/O 模型就再也不会混乱。