Skip to content

第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
文件 APIopen/read/writeChannel/Buffer, Files
零拷贝sendfiletransferTo
多路复用select/poll/epollSelector
缓冲区手动 char*/mallocBuffer 封装

对 C 程序员:NIO 的 Selector 对应 C 的 epoll,transferTo 对应 sendfile,Buffer 对应手动缓冲区但封装了 position/limit 状态管理。NIO 让 Java 能做高性能 IO,接近 C 的能力。


💡 九、最佳实践

  1. 文件操作优先 NIO.2(Path/Files),简洁安全。
  2. 大文件复制用 transferTo(零拷贝)。
  3. Buffer 操作牢记 flip:写→读 flip,读→写 clear/compact。
  4. 大文件读用 Files.lines(流式,内存友好)。
  5. 高频大块 IO 才用 allocateDirect,否则用堆内 Buffer。
  6. 网络高并发用 Selector(或直接用 Netty)。

📝 练习预告

完成 练习/Ex22_NIO.java 中的 6 道题:

  1. Buffer 基本操作(put/flip/get/clear)
  2. FileChannel + Buffer 复制文件
  3. transferTo 零拷贝
  4. Files/Path 简洁 API
  5. Files.lines 流式读
  6. 综合:用 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、线程生命周期