Skip to content

NIO Buffer 与 Channel

Java NIO 的核心不是“换几个类名”,而是换了一种读写模型:

传统 IO 面向流,NIO 面向缓冲区和通道。

传统 IO 是从流里顺序读;NIO 是让 Channel 把数据读到 Buffer,程序再从 Buffer 取数据。写数据时反过来,程序先把数据放进 Buffer,再通过 Channel 写出去。

学习目标

学完本页,你应该能说明:

  1. Buffer、Channel 分别负责什么。
  2. positionlimitcapacity 怎么变化。
  3. flipclearcompact 有什么区别。
  4. FileChannel 怎么复制文件。
  5. 堆内 Buffer 和直接 Buffer 有什么区别。
  6. 为什么 NIO 是 Netty 的前置知识。

Buffer 和 Channel 的关系

mermaid
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文件读写
SocketChannelTCP 客户端连接
ServerSocketChannelTCP 服务端监听
DatagramChannelUDP 通信
AsynchronousFileChannelJDK7 NIO.2 异步文件

Buffer 的三个核心指针

Buffer 最容易卡住初学者的是三个属性:

属性含义
capacity容量,Buffer 最多能放多少数据,创建后基本不变
position当前读写位置
limit当前可读或可写边界

新建一个 ByteBuffer.allocate(8)

mermaid
flowchart TD
    A["capacity = 8"] --> B["position = 0"]
    B --> C["limit = 8"]
    C --> D["写模式:可以写 0 到 7"]

写入 3 个字节后:

text
capacity = 8
position = 3
limit    = 8

调用 flip() 后,Buffer 从写模式切换到读模式:

text
capacity = 8
position = 0
limit    = 3

这表示只能读取刚刚写入的 3 个字节。

Buffer 状态变化

mermaid
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 --> B

flipclearcompact 对比:

方法做什么什么时候用
flip()写完后切到读模式从 Buffer 读数据前
clear()清空读写边界,切回写模式数据已经处理完
compact()保留未读数据,切回写模式粘包拆包、半包数据没处理完

注意:clear() 不会把数组内容真的清零,它只是重置指针。旧数据可能还在内存里,只是会被新数据覆盖。

Demo:观察 Buffer 指针

java
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());
    }
}

运行后你会看到 positionlimit 的变化。学 NIO 必须把这套状态机搞明白,否则会出现读不到数据、重复读、数据丢失。

Demo:FileChannel 文件复制

java
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 有两种常见创建方式:

java
ByteBuffer heapBuffer = ByteBuffer.allocate(8192);
ByteBuffer directBuffer = ByteBuffer.allocateDirect(8192);
类型内存位置优点风险
Heap BufferJVM 堆内创建和回收相对简单和本地 IO 交互可能多一次复制
Direct Buffer堆外直接内存适合本地 IO,减少复制分配成本高,释放依赖 Cleaner,可能直接内存 OOM

直接内存链路:

mermaid
flowchart TD
    A["磁盘 / 网卡"] --> B["操作系统缓冲区"]
    B --> C["DirectByteBuffer<br/>堆外内存"]
    C --> D["JVM 堆中的 DirectByteBuffer 对象引用"]

不要因为“直接内存快”就到处用 allocateDirect。它适合频繁和本地 IO 交互、生命周期较长、复用率高的缓冲区。临时小 Buffer 用堆内内存通常更合适。

JDK7/JDK8 重点

版本和 NIO 相关的重点
JDK 7NIO.2:PathFilesWatchServiceAsynchronousFileChannel
JDK 8企业项目主流版本,NIO/Selector/DirectBuffer/Files API 大量使用
JDK 11+增加更方便文件字符串 API,但底层思想没变

面试时不要说“JDK8 才有 NIO”。NIO 在 JDK1.4 就有;JDK7 加强了 NIO.2 文件 API。

商业场景:文件同步任务

一个文件同步任务通常不只是复制文件,还要处理校验、临时文件和失败重试。

mermaid
flowchart TD
    A["扫描待同步文件"] --> B["使用 FileChannel 分块复制到临时文件"]
    B --> C["计算文件大小和摘要"]
    C --> D{"摘要一致"}
    D -- "否" --> E["记录失败并重试"]
    D -- "是" --> F["移动为正式文件"]
    F --> G["更新同步状态"]

为什么要先写临时文件?因为复制过程中失败时,正式目录不会出现半成品。

常见坑

后果正确做法
忘记 flip读不到刚写入的数据写完读前必须 flip
忘记 clearcompact后续数据写入位置错乱处理完用 clear,未处理完用 compact
假设 write 一次写完数据丢失while (buffer.hasRemaining()) write
滥用 allocateDirect直接内存 OOM只在高频 IO 缓冲中复用
大 Buffer 每次新建GC 或直接内存压力大复用 Buffer 或使用成熟框架内存池

面试标准回答

NIO 的核心组件是 Buffer、Channel、Selector。Buffer 是内存缓冲区,核心属性是 positionlimitcapacity;Channel 是连接文件或网络的通道。读数据时 Channel 把数据写入 Buffer,业务代码从 Buffer 读取;写数据时业务代码先写 Buffer,再由 Channel 输出。flip 用于从写模式切到读模式,clear 用于清空指针重新写,compact 用于保留未读数据。直接 Buffer 在堆外内存,适合高频 IO,但要注意直接内存 OOM。

关联知识点