Appearance
第22课:NIO
🎯 学习目标
- 理解 NIO 与传统 IO 的区别(Channel/Buffer vs Stream、阻塞 vs 非阻塞)
- 掌握 Buffer 的核心属性与操作(capacity/position/limit/flip/clear)
- 掌握 Channel(FileChannel/transferTo 零拷贝)
- 掌握 Path/Files(NIO.2)的简洁文件 API
- 了解 Selector 多路复用与直接内存零拷贝
📖 一、概念讲解:NIO vs 传统 IO
1. 三个核心区别
| 维度 | 传统 IO(java.io) | NIO(java.nio) |
|---|---|---|
| 数据单位 | 流(Stream,单向) | 通道 + 缓冲区(双向) |
| 阻塞 | 阻塞(read 卡住等数据) | 可非阻塞(Selector 多路复用) |
| API | 装饰器层层包装 | Path/Files 简洁静态方法 |
核心抽象:
- Channel(通道):双向数据管道,连接文件/网络。类比 Stream,但可读可写。
- Buffer(缓冲区):数据容器,读写都经过它。IO 直接对流读写,NIO 把数据从 Channel 读到 Buffer、从 Buffer 写到 Channel。
传统 IO: 数据 ──Stream──→ 程序(逐字节/字符)
NIO: 数据 ──Channel──→ Buffer ──→ 程序(批量,可控)2. 何时用 NIO
- 文件操作:用 NIO.2(Path/Files)更简洁。
- 大文件复制/传输:FileChannel + transferTo(零拷贝)。
- 高并发网络:Selector 多路复用(一个线程管理多个连接)。
📖 二、Buffer:核心属性与操作
Buffer 是 NIO 的核心,所有数据先入 Buffer。
1. 四个核心属性
容量 capacity:Buffer 最大容量(不变)
位置 position:当前读写位置
上限 limit:读写上限(写模式=capacity,读模式=已写数据量)
标记 mark:可记录的 position(reset 回到 mark)2. 状态转换(关键!)
写模式:position=0, limit=capacity
↓ 写入数据,position 前进
写满:position=limit
↓ flip():切换到读模式
读模式:position=0, limit=原position(即已写数据量)
↓ 读取数据,position 前进
读完:position=limit
↓ clear():回到写模式(position=0, limit=capacity)java
ByteBuffer buf = ByteBuffer.allocate(1024); // 分配缓冲区
buf.put("Hello".getBytes()); // 写:position 前进
buf.flip(); // 切换到读模式:limit=position, position=0
while (buf.hasRemaining()) {
System.out.print((char) buf.get()); // 读
}
buf.clear(); // 回到写模式flip 是最易错点:写完要读必须 flip(否则 position 在末尾、limit=capacity,读不到数据或读到空)。读完要再写必须 clear 或 compact。
3. 常用 Buffer
ByteBuffer(最常用)、CharBuffer、IntBuffer... 都是 Buffer 子类。allocateDirect 分配直接内存(见下)。
📖 三、Channel:FileChannel
java
// 用 FileChannel 复制文件
try (FileChannel in = FileChannel.open(Paths.get("src.txt"));
FileChannel out = FileChannel.open(Paths.get("dst.txt"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
while (in.read(buf) != -1) { // Channel → Buffer
buf.flip();
out.write(buf); // Buffer → Channel
buf.clear();
}
}零拷贝:transferTo
java
// 大文件传输,零拷贝(操作系统直接传,不经用户态)
try (FileChannel in = FileChannel.open(Paths.get("big.iso"));
FileChannel out = FileChannel.open(Paths.get("copy.iso"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
in.transferTo(0, in.size(), out); // 一行复制
}transferTo 利用操作系统 sendfile,数据在内核态直接从源文件传到目标,不拷贝到用户态 Buffer,性能远高于自己 read/write。
📖 四、Path/Files(NIO.2,JDK 7+)
NIO.2 提供了远比 File 类好用的文件 API:
java
import java.nio.file.*;
// 创建/删除
Files.write(Paths.get("a.txt"), "Hello".getBytes()); // 一行写
byte[] data = Files.readAllBytes(Paths.get("a.txt")); // 一行读
List<String> lines = Files.readAllLines(Paths.get("a.txt")); // 读所有行
// 复制/移动/删除
Files.copy(Paths.get("a.txt"), Paths.get("b.txt"), StandardCopyOption.REPLACE_EXISTING);
Files.move(...);
Files.delete(Paths.get("a.txt"));
// 文件信息
long size = Files.size(Paths.get("a.txt"));
boolean exists = Files.exists(Paths.get("a.txt"));
// 遍历目录
try (Stream<Path> s = Files.list(Paths.get("."))) {
s.forEach(System.out::println);
}
// 流式读大文件(懒加载,内存友好)
try (Stream<String> ls = Files.lines(Paths.get("big.txt"))) {
ls.filter(l -> l.contains("error")).forEach(System.out::println);
}Path 替代 String 路径,操作更安全(resolve/normalize/relativize)。Files 全静态方法,一行完成传统 IO 几十行的事。
📖 五、Selector(多路复用,简介)
Selector 让一个线程管理多个 Channel(网络连接),只在有数据可读/可写时才处理:
java
Selector selector = Selector.open();
channel.configureBlocking(false); // 非阻塞
channel.register(selector, SelectionKey.OP_READ);
while (true) {
selector.select(); // 阻塞直到有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 处理就绪的 Channel
}这是 Netty/Redis 等高并发的基础(I/O 多路复用)。详见多线程/网络编程章节。
📖 六、直接内存与零拷贝
java
ByteBuffer buf = ByteBuffer.allocateDirect(1024); // 直接内存直接内存(堆外内存):不在 JVM 壁,IO 时免去"堆→本地内存"的拷贝(零拷贝的一部分)。适合大块、频繁 IO 场景。代价:分配/释放慢(系统调用),不受 GC 直接管理。
应用:NIO、Netty 用直接内存提升 IO 性能。
⚠️ 七、常见陷阱
陷阱1:忘记 flip
写完直接读 → position 在末尾,读不到数据。写转读必须 flip。
陷阱2:clear vs compact
clear 清空整个 Buffer(position=0, limit=capacity),未读数据丢失。compact 把未读数据移到开头再写(保留未读)。读完部分就写新数据用 compact。
陷阱3:Path 与 File 路径混淆
Paths.get("a.txt") 是 Path,new File("a.txt") 是 File。可互转:file.toPath() / path.toFile()。新代码用 Path。
陷阱4:allocateDirect 滥用
直接内存分配慢、不受 GC 管理(可能泄漏)。小数据用 allocate(堆内)即可,大数据高频 IO 才用 direct。
陷阱5:Files.lines 不关闭资源泄漏
返回 Stream,须 try-with-resources 关闭(否则文件句柄泄漏)。
🆚 八、Java vs C 对比
| 特性 | C 语言 | Java NIO |
|---|---|---|
| 文件 API | open/read/write | Channel/Buffer, Files |
| 零拷贝 | sendfile | transferTo |
| 多路复用 | select/poll/epoll | Selector |
| 缓冲区 | 手动 char*/malloc | Buffer 封装 |
对 C 程序员:NIO 的 Selector 对应 C 的 epoll,transferTo 对应 sendfile,Buffer 对应手动缓冲区但封装了 position/limit 状态管理。NIO 让 Java 能做高性能 IO,接近 C 的能力。
💡 九、最佳实践
- 文件操作优先 NIO.2(Path/Files),简洁安全。
- 大文件复制用 transferTo(零拷贝)。
- Buffer 操作牢记 flip:写→读 flip,读→写 clear/compact。
- 大文件读用 Files.lines(流式,内存友好)。
- 高频大块 IO 才用 allocateDirect,否则用堆内 Buffer。
- 网络高并发用 Selector(或直接用 Netty)。
📝 练习预告
完成 练习/Ex22_NIO.java 中的 6 道题:
- Buffer 基本操作(put/flip/get/clear)
- FileChannel + Buffer 复制文件
- transferTo 零拷贝
- Files/Path 简洁 API
- Files.lines 流式读
- 综合:用 NIO 统计文件信息(大小/行数)
完成后对比 答案/Sol22.java,查看逐行讲解与多解法。
📖 十、Buffer 操作清单
Buffer 最容易错的是状态切换。
常见方法:
text
put:写入数据,position 前进。
flip:写模式切读模式,limit=当前 position,position=0。
get:读取数据,position 前进。
clear:切回写模式,未读数据视为丢弃。
compact:保留未读数据,切回写模式。
rewind:position 回到 0,limit 不变,重新读。
mark/reset:记录和恢复 position。典型读写循环:
java
while (channel.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
target.write(buffer);
}
buffer.clear();
}如果 target.write(buffer) 一次没有写完,必须用 while (buffer.hasRemaining()) 继续写。
📖 十一、Selector 事件模型
Selector 的核心是“只处理就绪事件”。
常见事件:
text
OP_ACCEPT:服务端接收连接。
OP_CONNECT:客户端连接完成。
OP_READ:通道可读。
OP_WRITE:通道可写。基本流程:
text
打开 Selector。
Channel 设置为非阻塞。
Channel 注册到 Selector。
select 等待就绪事件。
遍历 selectedKeys。
处理事件。
移除已处理 key。注意:
text
必须 remove 已处理的 SelectionKey。
OP_WRITE 通常一直就绪,不能长期注册,否则会空转。
业务处理不要阻塞 Selector 线程。生产环境通常直接使用 Netty,因为手写 Selector 边界很多。
🧪 十二、Path 组合实践
Path 比字符串路径更安全。
java
Path base = Path.of("data");
Path userFile = base.resolve("users").resolve("2026.txt").normalize();常用操作:
text
resolve:拼接子路径。
normalize:消除 . 和 ..。
relativize:计算相对路径。
toAbsolutePath:转绝对路径。
getFileName:获取文件名。
getParent:获取父路径。安全提示:
text
处理用户上传文件名时要防路径穿越。
normalize 后仍要确认路径位于允许目录下。
不要直接信任用户传入的相对路径。📖 十三、Files.walk 与目录遍历
递归遍历目录:
java
try (Stream<Path> paths = Files.walk(Path.of("src"))) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(System.out::println);
}重点:
text
Files.walk 返回 Stream,必须关闭。
大目录遍历是惰性的,不会一次性读入全部。
遍历时可能遇到权限异常。
符号链接可能造成循环,需要谨慎。如果只遍历一层,用 Files.list;需要递归用 Files.walk。
🛠 十四、NIO 排查清单
常见问题:
text
写完 Buffer 忘记 flip。
读完 Buffer 忘记 clear 或 compact。
Channel.write 没有循环写完。
Files.lines/Files.walk 返回 Stream 未关闭。
直接内存使用过多。
Selector selectedKeys 未 remove。
OP_WRITE 注册不当导致 CPU 空转。
路径拼接使用字符串导致跨平台问题。排查建议:
text
打印 position、limit、capacity。
检查 Buffer 模式是读还是写。
确认文件句柄是否关闭。
大文件复制优先测试 transferTo。
网络高并发优先使用成熟框架。✅ 十五、掌握标准
学完本课后,应能做到:
text
能解释 Channel 和 Buffer 的协作方式。
能正确使用 flip、clear、compact。
能用 FileChannel 复制文件。
能用 transferTo 完成大文件复制。
能使用 Path/Files 处理现代文件 API。
能用 Files.lines/Files.walk 并正确关闭 Stream。
能说明 Selector 的多路复用思想。
能知道直接内存的价值和风险。
能排查 Buffer 状态错误导致的空读、漏写和重复写问题。NIO 的难点不是 API 数量,而是状态管理。尤其是 Buffer 状态和 Selector 事件处理,写错后问题通常很隐蔽。
🎓 下一步
- 第23课:多线程基础 — Thread、Runnable、Callable、线程生命周期