NIO Buffer 与 Channel
Java NIO 的核心不是“换几个类名”,而是换了一种读写模型:
传统 IO 面向流,NIO 面向缓冲区和通道。
传统 IO 是从流里顺序读;NIO 是让 Channel 把数据读到 Buffer,程序再从 Buffer 取数据。写数据时反过来,程序先把数据放进 Buffer,再通过 Channel 写出去。
学习目标
学完本页,你应该能说明:
- Buffer、Channel 分别负责什么。
position、limit、capacity怎么变化。flip、clear、compact有什么区别。FileChannel怎么复制文件。- 堆内 Buffer 和直接 Buffer 有什么区别。
- 为什么 NIO 是 Netty 的前置知识。
Buffer 和 Channel 的关系
flowchart TD
A["文件 / Socket"] --> B["Channel"]
B --> C["Buffer"]
C --> D["Java 业务代码"]
D --> E["Buffer"]
E --> F["Channel"]
F --> G["文件 / Socket"]| 组件 | 作用 | 类比 |
|---|---|---|
| Buffer | 内存缓冲区,保存待读写数据 | 装数据的托盘 |
| Channel | 数据通道,连接文件、Socket 等资源 | 运输通道 |
传统流通常是单向的,InputStream 只读,OutputStream 只写。Channel 更像双向通道,具体能读还是能写取决于 Channel 类型和打开方式。
常见 Channel
| Channel | 场景 |
|---|---|
FileChannel | 文件读写 |
SocketChannel | TCP 客户端连接 |
ServerSocketChannel | TCP 服务端监听 |
DatagramChannel | UDP 通信 |
AsynchronousFileChannel | JDK7 NIO.2 异步文件 |
Buffer 的三个核心指针
Buffer 最容易卡住初学者的是三个属性:
| 属性 | 含义 |
|---|---|
capacity | 容量,Buffer 最多能放多少数据,创建后基本不变 |
position | 当前读写位置 |
limit | 当前可读或可写边界 |
新建一个 ByteBuffer.allocate(8):
flowchart TD
A["capacity = 8"] --> B["position = 0"]
B --> C["limit = 8"]
C --> D["写模式:可以写 0 到 7"]写入 3 个字节后:
capacity = 8
position = 3
limit = 8调用 flip() 后,Buffer 从写模式切换到读模式:
capacity = 8
position = 0
limit = 3这表示只能读取刚刚写入的 3 个字节。
Buffer 状态变化
flowchart TD
A["allocate<br/>position=0 limit=capacity"] --> B["put / channel.read<br/>position 向后移动"]
B --> C["flip<br/>limit=position position=0"]
C --> D["get / channel.write<br/>position 向后移动"]
D --> E{"数据读完了吗"}
E -- "是" --> F["clear<br/>position=0 limit=capacity"]
E -- "否,还剩半包" --> G["compact<br/>保留未读数据"]
F --> B
G --> Bflip、clear、compact 对比:
| 方法 | 做什么 | 什么时候用 |
|---|---|---|
flip() | 写完后切到读模式 | 从 Buffer 读数据前 |
clear() | 清空读写边界,切回写模式 | 数据已经处理完 |
compact() | 保留未读数据,切回写模式 | 粘包拆包、半包数据没处理完 |
注意:clear() 不会把数组内容真的清零,它只是重置指针。旧数据可能还在内存里,只是会被新数据覆盖。
Demo:观察 Buffer 指针
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class BufferStateDemo {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(8);
print("new", buffer);
buffer.put("abc".getBytes(StandardCharsets.UTF_8));
print("put abc", buffer);
buffer.flip();
print("flip", buffer);
while (buffer.hasRemaining()) {
System.out.println((char) buffer.get());
}
print("read all", buffer);
buffer.clear();
print("clear", buffer);
}
private static void print(String step, ByteBuffer buffer) {
System.out.println(step
+ " position=" + buffer.position()
+ " limit=" + buffer.limit()
+ " capacity=" + buffer.capacity());
}
}运行后你会看到 position 和 limit 的变化。学 NIO 必须把这套状态机搞明白,否则会出现读不到数据、重复读、数据丢失。
Demo:FileChannel 文件复制
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class FileChannelCopyDemo {
public static void main(String[] args) throws IOException {
copy("source.dat", "target.dat");
}
public static void copy(String source, String target) throws IOException {
Path sourcePath = Paths.get(source);
Path targetPath = Paths.get(target);
try (FileChannel in = FileChannel.open(sourcePath, StandardOpenOption.READ);
FileChannel out = FileChannel.open(targetPath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (in.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
out.write(buffer);
}
buffer.clear();
}
}
}
}为什么 out.write(buffer) 要放在 while (buffer.hasRemaining()) 里?
因为 Channel 的写操作不保证一次把 Buffer 里剩余的数据全部写完。尤其是非阻塞 SocketChannel,写一部分就返回很常见。文件 Channel 通常更容易写完,但保持这个习惯更安全。
堆内 Buffer 和直接 Buffer
ByteBuffer 有两种常见创建方式:
ByteBuffer heapBuffer = ByteBuffer.allocate(8192);
ByteBuffer directBuffer = ByteBuffer.allocateDirect(8192);| 类型 | 内存位置 | 优点 | 风险 |
|---|---|---|---|
| Heap Buffer | JVM 堆内 | 创建和回收相对简单 | 和本地 IO 交互可能多一次复制 |
| Direct Buffer | 堆外直接内存 | 适合本地 IO,减少复制 | 分配成本高,释放依赖 Cleaner,可能直接内存 OOM |
直接内存链路:
flowchart TD
A["磁盘 / 网卡"] --> B["操作系统缓冲区"]
B --> C["DirectByteBuffer<br/>堆外内存"]
C --> D["JVM 堆中的 DirectByteBuffer 对象引用"]不要因为“直接内存快”就到处用 allocateDirect。它适合频繁和本地 IO 交互、生命周期较长、复用率高的缓冲区。临时小 Buffer 用堆内内存通常更合适。
JDK7/JDK8 重点
| 版本 | 和 NIO 相关的重点 |
|---|---|
| JDK 7 | NIO.2:Path、Files、WatchService、AsynchronousFileChannel |
| JDK 8 | 企业项目主流版本,NIO/Selector/DirectBuffer/Files API 大量使用 |
| JDK 11+ | 增加更方便文件字符串 API,但底层思想没变 |
面试时不要说“JDK8 才有 NIO”。NIO 在 JDK1.4 就有;JDK7 加强了 NIO.2 文件 API。
商业场景:文件同步任务
一个文件同步任务通常不只是复制文件,还要处理校验、临时文件和失败重试。
flowchart TD
A["扫描待同步文件"] --> B["使用 FileChannel 分块复制到临时文件"]
B --> C["计算文件大小和摘要"]
C --> D{"摘要一致"}
D -- "否" --> E["记录失败并重试"]
D -- "是" --> F["移动为正式文件"]
F --> G["更新同步状态"]为什么要先写临时文件?因为复制过程中失败时,正式目录不会出现半成品。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
忘记 flip | 读不到刚写入的数据 | 写完读前必须 flip |
忘记 clear 或 compact | 后续数据写入位置错乱 | 处理完用 clear,未处理完用 compact |
假设 write 一次写完 | 数据丢失 | while (buffer.hasRemaining()) write |
滥用 allocateDirect | 直接内存 OOM | 只在高频 IO 缓冲中复用 |
| 大 Buffer 每次新建 | GC 或直接内存压力大 | 复用 Buffer 或使用成熟框架内存池 |
面试标准回答
NIO 的核心组件是 Buffer、Channel、Selector。Buffer 是内存缓冲区,核心属性是 position、limit、capacity;Channel 是连接文件或网络的通道。读数据时 Channel 把数据写入 Buffer,业务代码从 Buffer 读取;写数据时业务代码先写 Buffer,再由 Channel 输出。flip 用于从写模式切到读模式,clear 用于清空指针重新写,compact 用于保留未读数据。直接 Buffer 在堆外内存,适合高频 IO,但要注意直接内存 OOM。
