0
0
0

Java NIO:FileChannel 文件操作、分散读取与聚集写入

文章摘要
|

一、FileChannel 的工作原理

1. 什么是 FileChannel?

FileChannel 是 Java NIO 提供的文件通道,主要用于文件数据的读取、写入和传输。

在 NIO 中,文件数据通常通过 FileChannel 和 ByteBuffer 配合完成传输。

读取文件:

磁盘文件 → FileChannel → ByteBuffer → 程序

写入文件:

程序 → ByteBuffer → FileChannel → 磁盘文件

常见 Channel 类型:

Channel

主要作用

支持 Selector

FileChannel

文件读写

不支持

SocketChannel

TCP 数据通信

支持

ServerSocketChannel

TCP 连接监听

支持

DatagramChannel

UDP 数据通信

支持

注意:FileChannel 不支持通过 configureBlocking(false) 设置非阻塞模式,也不能注册到 Selector。

2. Path、Paths、Files 的区别

Java NIO.2 提供了文件操作相关 API,Java 8 已经自带,不需要引入第三方依赖。

类或接口

作用

Path

表示文件或目录路径

Paths

提供创建 Path 的工厂方法

Files

提供文件创建、复制、删除和读取等操作

FileChannel

通过通道进行文件数据读写

例如:

Path path = Paths.get("D:\\data\\test.txt");

// 判断文件是否存在
Files.exists(path);

// 获取文件名
path.getFileName();

// 获取文件大小
Files.size(path);

// 删除文件
Files.deleteIfExists(path);

注意:Paths.get() 只是创建路径对象,不会真正创建文件。

3. FileChannel 核心方法

方法

作用

read(ByteBuffer)

从文件读取数据到 Buffer

write(ByteBuffer)

将 Buffer 数据写入文件

position()

获取当前文件位置

position(long)

设置文件读写位置

size()

获取文件大小

truncate(long)

截断文件

force(boolean)

请求将文件更新同步到存储设备

transferTo()

将文件数据传输到目标 Channel

transferFrom()

从源 Channel 接收数据并写入文件

其中最常用的是 read()、write()、position() 和 transferTo()。

二、FileChannel 数据读写与位置管理

1. read() 和 write()

读取文件:

channel.read(buffer);

作用:从 FileChannel 读取文件数据,并写入 ByteBuffer。

写入文件:

channel.write(buffer);

作用:从 ByteBuffer 读取数据,写入 FileChannel。

注意:read 和 write 的命名是站在 Channel 的角度,而不是 Buffer 的角度。

2. FileChannel.position 与 Buffer.position 的区别

这两个 position 是完全独立的。

假设文件内容:

索引:0 1 2 3 4 5 6 7 8 9
数据:A B C D E F G H I J

执行:

ByteBuffer buffer = ByteBuffer.allocate(4);

channel.read(buffer);

假设成功读取 4 个字节:

FileChannel.position = 4
ByteBuffer.position  = 4

虽然此时两个 position 都是 4,但含义不同。

  • FileChannel.position:下一次读取或写入的文件位置。

  • Buffer.position:下一次读取或写入的缓冲区位置。

例如:

channel.position(0);

只修改文件通道位置,不会修改 Buffer 的 position。

buffer.clear();

只重置 Buffer 状态,不会修改 FileChannel 的文件位置。

容易混淆:Channel 和 Buffer 各自管理自己的 position,两者互不影响。

3. 指定文件位置读取

FileChannel 支持从指定文件位置读取数据:

channel.read(buffer, 5);

表示从文件索引 5 开始读取。

例如:

channel.position(2);

ByteBuffer buffer = ByteBuffer.allocate(4);

channel.read(buffer, 5);

假设本次读取了 4 个字节:

FileChannel.position = 2
ByteBuffer.position  = 4

重点:带 position 参数的 read() 不会改变 FileChannel 当前的文件位置,但会根据实际读取的数据量推进 Buffer.position。

4. Java 8 完整文件读写示例

import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class FileChannelDemo {

    public static void main(String[] args) throws Exception {

        Path dir = Paths.get("nio-demo");
        Files.createDirectories(dir);

        Path file = dir.resolve("test.txt");

        try (FileChannel channel = FileChannel.open(
                file,
                StandardOpenOption.CREATE,
                StandardOpenOption.READ,
                StandardOpenOption.WRITE,
                StandardOpenOption.TRUNCATE_EXISTING)) {

            // 1. 准备写入的数据
            byte[] data = "Hello NIO,文件操作"
                    .getBytes(StandardCharsets.UTF_8);

            ByteBuffer writeBuffer = ByteBuffer.wrap(data);

            // 2. 写入文件
            while (writeBuffer.hasRemaining()) {
                channel.write(writeBuffer);
            }

            System.out.println("文件大小:" + channel.size());

            // 3. 将文件位置移动到开头
            channel.position(0);

            // 4. 创建读取缓冲区
            ByteBuffer readBuffer = ByteBuffer.allocate(8);

            ByteArrayOutputStream output =
                    new ByteArrayOutputStream();

            while (true) {

                int length = channel.read(readBuffer);

                if (length == -1) {
                    break;
                }

                if (length == 0) {
                    continue;
                }

                // 5. 切换读取模式
                readBuffer.flip();

                byte[] bytes =
                        new byte[readBuffer.remaining()];

                readBuffer.get(bytes);
                output.write(bytes);

                // 6. 准备下一次读取
                readBuffer.clear();
            }

            System.out.println(
                    new String(
                            output.toByteArray(),
                            StandardCharsets.UTF_8
                    )
            );
        }
    }
}

输出:

文件大小:24
Hello NIO,文件操作

注意以下问题:

为什么 write() 使用 while 循环?

因为一次 write() 不保证把 Buffer 中的数据全部写入文件。

while (buffer.hasRemaining()) {
    channel.write(buffer);
}

为什么读取文件前调用 channel.position(0)?

因为写入文件后,FileChannel 的位置已经移动到文件末尾。如果不重置位置,后续读取可能直接返回 -1。

为什么不直接将每次读取的 Buffer 转成字符串?

因为 UTF-8 中文字符可能横跨两次读取。

例如一个中文字符占 3 个字节,第一次可能只读取到前 2 个字节,下一次才读取到剩余的 1 个字节。

直接分别转换可能出现乱码。

示例先将字节收集起来再解码,适合小文件。大文件应该使用流式解码,避免全部加载到内存。

另外,示例使用的 TRUNCATE_EXISTING 会清空原文件内容,生产环境使用时需要特别注意。

三、分散读取与聚集写入

1. Scattering Read:分散读取

分散读取是指:

一个 Channel 将读取到的数据依次写入多个 ByteBuffer。

普通读取:

channel.read(buffer);

分散读取:

channel.read(new ByteBuffer[]{buffer1, buffer2});

假设文件内容:

HEAD12345678

分别创建两个 Buffer:

ByteBuffer header = ByteBuffer.allocate(4);

ByteBuffer body = ByteBuffer.allocate(8);

执行:

ByteBuffer[] buffers = {header, body};

channel.read(buffers);

假设一次完整读取 12 个字节:

文件:HEAD12345678
           |
           v
      FileChannel
           |
      +----+----+
      |         |
      v         v
   header      body
   HEAD       12345678

执行顺序:

  1. 先将数据写入 header。

  2. header 填满后,再写入 body。

  3. 如果本次读取的字节不够,部分 Buffer 可能尚未填满。

注意:分散读取不会自动识别消息头和消息体,只是按照 Buffer 的顺序以及剩余容量填充数据。

2. Gathering Write:聚集写入

聚集写入与分散读取相反。

将多个 Buffer 中的数据依次写入一个 Channel。

例如:

ByteBuffer header = ByteBuffer.wrap(
        "HEAD".getBytes(StandardCharsets.UTF_8)
);

ByteBuffer body = ByteBuffer.wrap(
        "12345678".getBytes(StandardCharsets.UTF_8)
);

ByteBuffer[] buffers = {header, body};

channel.write(buffers);

数据流向:

header:HEAD       body:12345678
       |                 |
       +--------+--------+
                |
                v
            FileChannel
                |
                v
          HEAD12345678

聚集写入的优点是:

不需要先将多个 Buffer 的数据手动拼接到一个新 Buffer 中,就可以按顺序写入目标 Channel。

不过,一次 write() 同样不保证写完所有 Buffer。

3. 完整代码示例

import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class ScatterGatherDemo {

    public static void main(String[] args) throws Exception {

        Path dir = Paths.get("nio-demo");
        Files.createDirectories(dir);

        Path source = dir.resolve("source.txt");
        Path target = dir.resolve("target.txt");

        Files.write(
                source,
                "HEAD12345678".getBytes(StandardCharsets.US_ASCII)
        );

        ByteBuffer header = ByteBuffer.allocate(4);
        ByteBuffer body = ByteBuffer.allocate(8);

        ByteBuffer[] buffers = {header, body};

        // 1. 分散读取
        try (FileChannel channel = FileChannel.open(
                source, StandardOpenOption.READ)) {

            while (header.hasRemaining() || body.hasRemaining()) {

                long length = channel.read(buffers);

                if (length == -1) {
                    throw new EOFException("文件数据不足");
                }

                if (length == 0) {
                    throw new IOException("读取未取得进展");
                }
            }
        }

        // 2. 切换为读取模式
        header.flip();
        body.flip();

        // 使用只读视图,避免改变原Buffer的position
        System.out.println(
                StandardCharsets.US_ASCII.decode(
                        header.asReadOnlyBuffer()
                )
        );

        System.out.println(
                StandardCharsets.US_ASCII.decode(
                        body.asReadOnlyBuffer()
                )
        );

        // 3. 聚集写入
        try (FileChannel channel = FileChannel.open(
                target,
                StandardOpenOption.CREATE,
                StandardOpenOption.WRITE,
                StandardOpenOption.TRUNCATE_EXISTING)) {

            while (header.hasRemaining() || body.hasRemaining()) {

                long length = channel.write(buffers);

                if (length == 0) {
                    throw new IOException("写入未取得进展");
                }
            }
        }
    }
}

最终 target.txt 内容:

HEAD12345678

4. 分散读取和聚集写入的应用场景

例如某个 TCP 自定义协议:

+----------------+----------------+
| Header         | Body           |
| 4字节长度字段   | N字节消息正文  |
+----------------+----------------+

可以分别使用两个 Buffer 存储消息头和消息体。

ByteBuffer header = ByteBuffer.allocate(4);
ByteBuffer body = ByteBuffer.allocate(1024);

发送数据时:

channel.write(new ByteBuffer[]{header, body});

不需要先将两个 Buffer 拼接为一个大的 Buffer。

不过,分散读取与聚集写入不能自动解决 TCP 半包、粘包问题,仍然需要根据协议判断消息完整性。

四、transferTo()、transferFrom() 与常见问题

1. transferTo()

transferTo() 用于将 FileChannel 中的数据传输到另一个 Channel。

方法:

source.transferTo(position, count, target);

三个参数:

参数

含义

position

源文件开始传输的位置

count

最多传输的字节数

target

接收数据的目标 Channel

例如:

source.transferTo(0, 1024, target);

表示尝试从源文件索引 0 开始,将最多 1024 字节传输到目标 Channel。

返回值表示实际传输的字节数。

注意:transferTo() 不保证一次完成指定数量的数据传输。

2. transferTo() 文件复制示例

import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.*;

public class FileTransferDemo {

    public static void main(String[] args) throws Exception {

        Path sourcePath = Paths.get("nio-demo", "source.txt");
        Path targetPath = Paths.get("nio-demo", "copy.txt");

        try (FileChannel source = FileChannel.open(
                    sourcePath,
                    StandardOpenOption.READ);
             FileChannel target = FileChannel.open(
                    targetPath,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.WRITE,
                    StandardOpenOption.TRUNCATE_EXISTING)) {

            long size = source.size();
            long position = 0;

            while (position < size) {

                long transferred = source.transferTo(
                        position,
                        size - position,
                        target
                );

                if (transferred <= 0) {
                    throw new IOException("文件传输未取得进展");
                }

                position += transferred;
            }

            System.out.println(
                    "文件复制完成:" + position + "字节"
            );
        }
    }
}

这里使用 while 循环,是因为一次 transferTo() 可能只完成部分数据传输。

示例目标是普通 FileChannel;如果目标为非阻塞 SocketChannel,返回 0 可能只是暂时不可写,需要配合 Selector 等待可写,而不能直接认为传输失败。

对于普通文件复制,如果没有特殊的 Channel 操作需求,生产中通常更推荐直接使用:

Files.copy(
        sourcePath,
        targetPath,
        StandardCopyOption.REPLACE_EXISTING
);

这种写法更简洁,文件复制由 Java 标准库负责处理。

3. transferFrom()

transferFrom() 表示从源 Channel 接收数据,并写入当前 FileChannel。

target.transferFrom(source, 0, source.size());

可以理解为:

transferTo():

源 FileChannel → 目标 Channel

transferFrom():

源 Channel → 目标 FileChannel

二者的区别主要在于调用者和数据传输方向。

注意:transferTo(position, ...) 指定的是源文件位置;transferFrom(source, position, ...) 指定的是目标文件位置。

它们都不保证一次传输全部指定字节。

4. transferTo() 是否属于零拷贝?

transferTo() 在某些操作系统和传输场景下,可以利用内核的数据传输机制,减少用户态与内核态之间不必要的数据复制。

传统方式可以简化理解为:

磁盘文件
   |
   v
内核缓冲区
   |
   v
用户空间 Buffer
   |
   v
内核发送缓冲区
   |
   v
网络设备

某些零拷贝优化场景可以避免数据先复制到用户空间,再复制回内核。

但需要注意:

transferTo() 不等于一定实现零拷贝,更不代表整个传输过程没有任何内存复制。

实际效果取决于操作系统、JDK 实现、底层文件系统及目标 Channel。

5. FileChannel 常见踩坑

问题一:文件写入后直接读取,发现没有数据。

原因:

FileChannel 的文件位置已经移动到末尾。

解决:

channel.position(0);

问题二:认为 Channel.position 和 Buffer.position 是同一个属性。

实际上:

channel.position(0);

只修改文件位置。

buffer.clear();

只重置 Buffer 状态。

两者互不影响。

问题三:分散读取后,认为所有 Buffer 都已经填满。

一次 read() 可能只读取部分数据,必须根据返回的字节数和各个 Buffer 的 remaining() 判断是否还需要读取。

问题四:聚集写入后,认为全部数据都写入成功。

一次 write() 可能只写入部分数据,需要继续处理剩余数据。

问题五:认为 transferTo() 一次可以传输整个文件。

一次 transferTo() 可能只完成部分传输,需要根据返回值更新偏移量,继续传输。

问题六:认为 write() 成功后,文件一定已经物理持久化。

文件写入成功后,操作系统可能仍然使用缓存。

如果需要更高的数据持久性保证,可以使用:

channel.force(true);

其中 true 表示同时请求同步文件内容和相关元数据。

但最终持久化保证仍取决于文件系统和存储设备。

五、重点总结

  1. FileChannel 是 Java NIO 提供的文件通道,负责文件数据读写。

  2. FileChannel 不支持注册到 Selector。

  3. Path 表示路径,Files 提供文件操作方法。

  4. FileChannel.position 和 Buffer.position 是两个独立属性。

  5. read(buffer):从文件读取数据,写入 Buffer。

  6. write(buffer):从 Buffer 读取数据,写入文件。

  7. read(buffer, position):从指定文件位置读取,不改变 FileChannel 当前的位置。

  8. Scattering Read:一个 Channel 将数据依次读取到多个 Buffer。

  9. Gathering Write:将多个 Buffer 的数据依次写入一个 Channel。

  10. 分散读取和聚集写入都不保证一次完成全部数据传输。

  11. transferTo():将源 FileChannel 的数据传输到目标 Channel。

  12. transferFrom():从源 Channel 读取数据,写入目标 FileChannel。

  13. transferTo() 可能利用零拷贝优化,但不保证一定实现零拷贝。

  14. 普通文件复制优先考虑 Files.copy(),需要控制 Channel 传输过程时再使用 transferTo()。

  15. 文件读写需要特别注意 position、flip()、clear() 以及部分读写问题。

六、常见问题与答案

1. FileChannel 和 SocketChannel 最大的区别是什么?

FileChannel 负责文件读写,不支持 Selector;SocketChannel 负责 TCP 数据通信,可以使用非阻塞模式注册到 Selector。

2. FileChannel.position() 和 Buffer.position() 有什么区别?

FileChannel.position() 表示文件的当前读写位置,Buffer.position() 表示缓冲区的下一次读写位置,两者相互独立。

3. 什么是分散读取?

使用一个 Channel 按顺序向多个 Buffer 写入读取的数据。

4. 什么是聚集写入?

将多个 Buffer 中尚未读取的数据按顺序写入同一个 Channel。

5. 分散读取可以自动解决 TCP 半包问题吗?

不能。分散读取只负责将数据依次填入多个 Buffer,消息完整性仍需要根据协议判断。

6. transferTo() 为什么还需要 while 循环?

因为一次 transferTo() 不保证传输完全部数据,需要根据返回的实际传输字节数继续传输。

7. transferTo() 和传统 read()、write() 有什么区别?

传统 read()、write() 通常需要通过应用程序管理 Buffer;transferTo() 可以让 Channel 直接进行数据传输,在某些平台和场景下能够减少数据复制及相关开销。

8. transferTo() 一定是零拷贝吗?

不一定。是否利用零拷贝优化取决于底层操作系统、JDK 和具体 Channel。

9. 为什么不推荐直接使用 FileChannel 手动实现所有文件复制需求?

因为普通文件复制使用 Files.copy() 更简洁,能够减少代码复杂度。FileChannel 更适合需要控制文件位置、数据范围或 Channel 传输过程的场景。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论