Java IO 与 NIO:从阻塞流到 Selector 多路复用
引言
Java 的 IO 体系经历了三个阶段:JDK 1.0 的 BIO(阻塞 IO)、JDK 1.4 的 NIO(非阻塞 + 多路复用)、JDK 7 的 AIO(异步 IO,NIO.2)。理解它们的区别,是理解 Netty、Tomcat、Kafka 等一切高并发网络框架的基础。
这篇文章从 BIO 的瓶颈讲起,重点拆解 NIO 的三大件(Buffer / Channel / Selector),介绍文件 IO 的实用 API(Files、零拷贝),最后给出选型建议。
一、BIO:一连接一线程
传统 IO(java.io 包)基于流,读写都是阻塞的:
java
// 经典 BIO 服务器:每个连接一个线程
try (ServerSocket server = new ServerSocket(8080)) {
while (true) {
Socket socket = server.accept(); // 阻塞等待连接
new Thread(() -> {
try (InputStream in = socket.getInputStream()) {
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) != -1) { // 阻塞等待数据
// 处理数据
}
} catch (IOException e) { ... }
}).start();
}
}
BIO 的瓶颈:
- 一连接一线程:1 万连接 = 1 万线程 ≈ 10GB 栈内存(每线程 1MB),还没算上下文切换开销
- 线程大部分时间在
read上阻塞等待,CPU 利用率极低
改进版是"线程池 + BIO"(伪异步 IO),但连接数远超线程数时,请求会排队等待,本质没解决问题。
二、NIO 三大件:Buffer、Channel、Selector
NIO(java.nio 包)的核心思想:一个线程管理多个连接。
2.1 Buffer:数据容器
NIO 的数据读写都经过 Buffer。核心四要素:
makefile
capacity: 容量上限
position: 当前读写位置
limit: 可读写的边界
mark: 标记位置(备份 position)
java
ByteBuffer buf = ByteBuffer.allocate(1024);
channel.read(buf); // 数据写入 buffer,position 前进
buf.flip(); // 切换为读模式:limit = position, position = 0
while (buf.hasRemaining()) {
byte b = buf.get();
}
buf.clear(); // 切换回写模式(数据未清除,只是重置指针)
flip/clear 是 NIO 新手的第一道坎:忘了 flip 就读不到数据(limit 还在 capacity,position 在末尾)。
2.2 Channel:双向通道
| Channel | 用途 |
|---|---|
| FileChannel | 文件读写 |
| SocketChannel | TCP 客户端 |
| ServerSocketChannel | TCP 服务端 |
| DatagramChannel | UDP |
Channel 是双向的(可读可写),流是单向的(InputStream 只读)。
2.3 Selector:多路复用的核心
java
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();
it.remove(); // 必须手动移除,否则重复处理
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 len = client.read(buf);
if (len == -1) {
client.close();
} else {
// 处理数据
}
}
}
}
Selector 底层是操作系统的 IO 多路复用:Linux 的 epoll、macOS 的 kqueue。一个线程监听成千上万个连接,只有连接真正有数据时才处理------这就是 Redis、Netty 单线程模型的基础。
NIO 的代价:API 极其繁琐(上面 40 行才干完 BIO 10 行的事),所以实际项目几乎不直接写 NIO,而是用 Netty 封装。
三、BIO vs NIO vs AIO
| 维度 | BIO | NIO | AIO (NIO.2) |
|---|---|---|---|
| 模型 | 同步阻塞 | 同步非阻塞 + 多路复用 | 异步非阻塞 |
| 线程模型 | 一连接一线程 | 一线程多连接 | 回调通知,无需轮询 |
| 面向 | 流(Stream) | 块(Buffer) | 块(Buffer) |
| 适用场景 | 连接数少且固定 | 连接数多、短请求(聊天、推送) | 连接数多、长请求(相册服务器) |
| 代表应用 | 早期 Tomcat | Netty、Redis、Kafka | Linux 支持不完善,用得少 |
AIO 的现实:Linux 的 epoll 太成功了,AIO(基于 io_uring 之前的实现)收益不明显,Netty 曾支持后又移除。JDK 21+ 的虚拟线程则给出了另一条路:用同步阻塞的写法获得异步的吞吐量(见多线程篇)。
四、文件 IO:java.nio.file(NIO.2)
日常开发用得最多的是 Files 工具类,比老的 File + Stream 简洁得多:
java
Path path = Path.of("/data/logs/app.log");
// 读写(小文件一把梭)
String content = Files.readString(path); // Java 11+
List<String> lines = Files.readAllLines(path);
Files.writeString(Path.of("/tmp/out.txt"), content); // Java 11+
byte[] bytes = Files.readAllBytes(path);
// 流式读写(大文件,不会撑爆内存)
try (BufferedReader r = Files.newBufferedReader(path);
BufferedWriter w = Files.newBufferedWriter(Path.of("/tmp/copy.log"))) {
String line;
while ((line = r.readLine()) != null) {
w.write(line);
w.newLine();
}
}
// 常用操作
Files.exists(path);
Files.createDirectories(Path.of("/data/new/dir"));
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
Files.copy(in, dst, StandardCopyOption.REPLACE_EXISTING);
Files.size(path);
Files.list(dir); // 惰性 Stream,记得 try-with-resources 关闭
// 遍历目录树
try (Stream<Path> walk = Files.walk(Path.of("/data"))) {
walk.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".log"))
.forEach(System.out::println);
}
注意 :Files.readAllLines / readString 会把整个文件读进内存,GB 级文件必须用流式 API。
五、零拷贝
传统文件传输(磁盘 → 网卡)要经过 4 次拷贝 + 4 次用户态/内核态切换:
磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡
零拷贝绕过用户缓冲区,数据不出内核:
java
// FileChannel.transferTo:底层是 Linux sendfile
try (FileChannel file = FileChannel.open(Path.of("/data/video.mp4"), READ);
SocketChannel socket = SocketChannel.open(serverAddr)) {
long size = file.size();
long sent = 0;
while (sent < size) {
sent += file.transferTo(sent, size - sent, socket);
}
}
// MappedByteBuffer:内存映射文件(大文件随机读写)
try (FileChannel ch = FileChannel.open(path, READ)) {
MappedByteBuffer mbb = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size());
// 像访问内存一样访问文件,缺页由 OS 处理
}
应用案例:Kafka 用 sendfile 把磁盘消息直接推给网卡;RocketMQ 用 mmap 读写 CommitLog。
六、序列化:IO 的隐形陷阱
网络传输和文件存储都涉及对象序列化:
| 方案 | 特点 |
|---|---|
| Java Serializable | JDK 原生,体积大、慢、安全漏洞多,不推荐 |
| JSON(Jackson/Gson) | 可读性好,通用,体积中等 |
| Protobuf | 二进制,体积小速度快,需要 IDL |
| Kryo | Java 生态最快的二进制序列化之一 |
Java 原生序列化的坑:
java
// 1. 反序列化 = 执行任意代码(ysoserial 攻击链),永远不要反序列化不可信数据
Object obj = new ObjectInputStream(untrustedInput).readObject(); // 危险!
// 2. serialVersionUID 不显式声明,类结构一改就无法反序列化旧数据
private static final long serialVersionUID = 1L;
// 3. static 和 transient 字段不会被序列化
新项目对外接口一律 JSON/Protobuf,Java 原生序列化只在框架内部出现。
七、常见陷阱
7.1 Buffer 忘记 flip
读模式下 limit 还停在 capacity,hasRemaining() 直接 false,"读不到数据"。flip 之后再读,读完 clear/compact。
7.2 Selector 的 selectedKeys 不移除
处理完不 iterator.remove(),下一轮 select 会重复处理同一个 key,导致重复响应甚至 CPU 空转。
7.3 Files.readAllBytes 读大文件
整个文件进堆内存,512MB 的文件直接 OOM。大文件用 newBufferedReader / newInputStream 流式处理。
7.4 FileChannel 与 Files 流混用不关闭
FileChannel 持有 OS 文件描述符,不 close 会泄漏。Files.list / Files.walk 返回的 Stream 也持有目录句柄,必须 try-with-resources。
7.5 用 BIO 硬扛高并发
"先跑起来再优化"可以,但要心里有数:BIO 模型在连接数 > 线程池大小时开始排队。长连接场景(WebSocket、推送)直接上 Netty 或虚拟线程。
7.6 反序列化不可信数据
Java 原生反序列化是 RCE 漏洞重灾区。对外接口禁用 Serializable,改用 JSON。
八、总结
| 需求 | 方案 |
|---|---|
| 简单文件读写 | Files.readString / writeString / newBufferedReader |
| 大文件处理 | 流式 API 或 MappedByteBuffer |
| 高并发网络 | Netty(NIO)或 JDK 21 虚拟线程(BIO 写法) |
| 文件直传网络 | FileChannel.transferTo(sendfile) |
| 对象传输 | JSON / Protobuf,不用 Java 原生序列化 |
IO 选型的关键问题:连接数多少?连接存活多久?数据量多大? 短连接低并发用 BIO 足够;海量长连接用 NIO/Netty;JDK 21 之后,虚拟线程让"同步阻塞写法 + 高并发"重新成为可行选项。
参考资料
作者 :eralong
个人网站 :eralong.com