Java IO 与 NIO:从阻塞模型到零拷贝的演进
Java IO 与 NIO:从阻塞模型到零拷贝的演进
同一个"读文件"的需求,用 FileInputStream 和 FileChannel 写出来的代码差别不大,但在高并发场景下吞吐可能差一个数量级。原因不在 API 本身,而在背后的线程模型与数据拷贝次数。
本文从字节流与字符流的分层讲起,逐步过渡到 NIO 的三大组件、Selector 多路复用,最后落到 mmap 与 sendfile 这两种零拷贝手段。
IO 的基本分层
java.io 分为字节流与字符流两条线:
- 字节流:
InputStream/OutputStream,处理原始字节,适合二进制文件、网络。 - 字符流:
Reader/Writer,在字节流之上做编码转换,适合文本。
字符流的本质是"字节流 + 字符集"。InputStreamReader 内部持有 StreamDecoder,按指定字符集解码;OutputStreamWriter 反之。直接读写字节再手工 new String(bytes, charset) 容易在跨缓冲区边界时截断多字节字符,用字符流可以避免。
装饰器模式是 java.io 的核心设计:
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
new FileInputStream("app.log"), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
FileInputStream 负责读字节,InputStreamReader 负责解码,BufferedReader 负责缓冲与按行切分,每层只做一件事。BufferedInputStream 默认 8 KB 缓冲,减少系统调用次数,对小块读取提升明显。
BIO 的瓶颈
传统的 ServerSocket 模型是一连接一线程:
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞
new Thread(() -> handle(socket)).start();
}
accept 阻塞、read 阻塞,每个连接独占一个线程。线程的栈默认 1 MB,几千连接就会耗尽内存,上下文切换也会拖垮 CPU。这就是 C10K 问题的由来。
NIO 的三大组件
Buffer
Buffer 是一个线性字节容器,核心是四个下标:position(下一个读写位置)、limit(可读写边界)、capacity(容量)、mark(临时标记)。
读写切换依赖 flip():把 limit 设为当前 position,position 归零。忘记 flip 是 NIO 最经典的错误,会导致读到 0 字节。
ByteBuffer buf = ByteBuffer.allocate(16);
buf.put("hello".getBytes(StandardCharsets.UTF_8)); // position=5
buf.flip(); // limit=5, position=0
byte[] out = new byte[buf.remaining()];
buf.get(out); // out = "hello"
buf.clear(); // position=0, limit=capacity
clear() 只是重置下标,不清零数据;compact() 会把未读数据移到开头,适合"读一半、继续写"的场景。
allocate 分配的是堆内内存(JVM 堆),allocateDirect 分配堆外直接内存。直接内存不受 GC 管理,分配与释放成本高,但做 IO 时少一次堆内到堆外的拷贝,适合长期复用的缓冲区。JDK 9 起 ByteBuffer 支持 slice、duplicate 等视图,MappedByteBuffer 则用于内存映射。
Channel
Channel 是双向的,既可读也可写,这一点与单向的流不同。常见实现有 FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
try (FileChannel in = FileChannel.open(Paths.get("a.bin"), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get("b.bin"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
in.transferTo(0, in.size(), out);
}
transferTo 在 Linux 上可能走 sendfile,避免用户态与内核态之间的数据拷贝。
Selector
Selector 是 NIO 实现单线程管理多连接的关键。它把 Channel 注册到选择器上,由操作系统提供就绪通知:
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有就绪事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
if (key.isAcceptable()) {
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = client.read(buf);
if (n == -1) {
client.close();
}
}
it.remove(); // 必须移除,否则下次重复处理
}
}
底层在 Linux 上是 epoll,在 macOS 上是 kqueue,Windows 上是 IOCP 的封装。select 返回的是就绪的 key 集合,遍历处理即可,线程数不再与连接数挂钩。
注意:
selectedKeys()返回的集合不会自动清理,处理完必须remove(),否则同一个事件会被反复处理。
零拷贝
传统文件发送到网络的路径是:磁盘 → 内核页缓存 → 用户缓冲区 → socket 缓冲区 → 网卡。四次拷贝、四次上下文切换。
FileChannel.transferTo 在 Linux 上使用 sendfile,数据不经过用户态:
long sent = fileChannel.transferTo(position, count, socketChannel);
MappedByteBuffer 使用 mmap,把文件映射到进程地址空间,读写直接作用于页缓存:
try (FileChannel ch = FileChannel.open(Paths.get("big.dat"), StandardOpenOption.READ)) {
MappedByteBuffer mapped = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size());
byte b = mapped.get(0);
}
mmap 适合随机访问大文件,但映射区不计入堆内存,OutOfMemoryError: Map failed 时容易误判。释放依赖 GC 回收 DirectByteBuffer,需要确定性释放时要配合 Cleaner 或改用 FileChannel.read。
Kafka 的日志读写就同时用到了这两种手段:写日志用顺序追加加页缓存,消费时用 sendfile 直接把日志段发给消费者。
文件操作的现代写法
JDK 7 引入的 java.nio.file 覆盖了绝大多数日常文件操作,比 java.io.File 更完整,也更少踩平台差异的坑:
Path dir = Paths.get("/data/logs");
Files.createDirectories(dir); // 父目录不存在也能创建
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(p -> p.toString().endsWith(".log"))
.forEach(System.out::println);
}
long size = Files.size(Paths.get("/data/a.bin"));
byte[] bytes = Files.readAllBytes(Paths.get("/data/small.txt"));
List<String> lines = Files.readAllLines(Paths.get("/data/small.txt"));
Files.list 和 Files.walk 返回的 Stream 持有目录句柄,必须关闭,否则在 Windows 上会锁定目录。Files.readAllBytes 只适合小文件,大文件应使用 Files.lines 配合 try-with-resources 流式处理。
Files.move 在同一文件系统内是原子重命名,跨文件系统则会退化为复制加删除。写日志滚动、配置文件热更新这类需要原子性的场景,应先在目标目录写临时文件,再 Files.move(tmp, target, ATOMIC_MOVE),避免读方看到半截内容。
File 与 Path 的转换用 file.toPath() 和 path.toFile(),两者可以互操作,但新代码优先使用 Path。
序列化与 IO 的关系
ObjectOutputStream 把对象图写成字节流,但它与 NIO 的 Buffer 模型是两套体系:序列化产生的是 Java 私有的二进制格式,只有 Java 能读,且与类的结构强绑定。
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("user.bin")))) {
oos.writeObject(user);
}
serialVersionUID 不显式声明时,JVM 会根据类结构计算一个哈希值,任何字段增删都会导致它变化,反序列化时抛 InvalidClassException。规范做法是显式声明 private static final long serialVersionUID = 1L;。
transient 字段不参与序列化,适合缓存、连接、密码这类不应落盘的数据。writeObject 与 readObject 可以自定义序列化逻辑,readResolve 用于保证单例在反序列化后仍是同一个实例。
注意:Java 原生序列化存在反序列化漏洞风险,不可信数据不要用
ObjectInputStream读取。跨语言、跨版本的数据交换应使用 JSON、Protobuf 等格式。
常见坑 / 最佳实践
- 忘记
flip()。写完切换读必须调用,否则remaining()为 0。 - 在循环里
allocate。缓冲区应在循环外复用,clear()后继续使用。 - 用
available()判断是否读完。它只表示当前可无阻塞读取的字节数,不是流的总长度。判断结束用返回值-1。 - 忘记关闭 Channel 或流。用 try-with-resources,Selector 与 Channel 都要关闭,否则文件句柄泄漏。
- 在 Selector 线程里做耗时业务。会阻塞所有连接的事件处理,业务逻辑应丢给工作线程池。
- 直接用
Files.readAllBytes读大文件。会一次性加载到内存,大文件应使用Files.lines流式处理并注意关闭。 - 字符集不一致。读写两端显式指定
StandardCharsets.UTF_8,不要依赖平台默认编码(JDK 18 起默认已改为 UTF-8,但显式声明仍是最佳实践)。
BIO、NIO、AIO 对比
| 模型 | 线程模型 | 适用场景 | 代表 |
|---|---|---|---|
| BIO | 一连接一线程 | 连接数少、逻辑简单 | 传统 Socket、Tomcat BIO(已移除) |
| NIO | 多路复用,少量线程 | 高并发短连接 | Netty、Tomcat NIO |
| AIO | 回调或 Future | 连接多且 IO 耗时长 | Windows IOCP、Linux io_uring(JDK 未直接支持) |
AIO 在 Linux 上的实现长期不理想,Netty 最终也放弃了 AIO 支持,主流的异步框架仍然是"NIO + 事件循环"。
小结
java.io用装饰器组合职责,字符流在字节流之上做编码转换。- BIO 的瓶颈是线程与连接一一对应,NIO 用 Selector 把连接管理收敛到少量线程。
- Buffer 的
position、limit决定读写位置,flip与clear是最容易出错的两个方法。 transferTo走 sendfile、map走 mmap,都能减少用户态拷贝,适合大文件与高吞吐场景。- 生产环境直接用 Netty 或框架的 NIO 实现,不要手写事件循环。
- 点赞
- 收藏
- 关注作者
评论(0)