PAYATHON 2026

Java 顺序写入大型二进制文件,应使用标准 I/O 还是 NIO?

数据小许

结论:先使用带缓冲的标准 I/O

这个场景可以先用 DataOutputStream 配合 BufferedOutputStream 顺序写入文件,并通过 try-with-resources 在结束时关闭流。文件达到 1~10 GB,本身不足以说明必须使用 NIO 或内存映射。

问题补充中已确认标准 I/O 能满足当前用例。除非测量发现文件写入限制了处理速度,否则暂时不必引入内存映射及其管理复杂度。

先核对数据产生速度

原始描述提到了大型文件,以及“每隔约 0.1 秒到几秒写入 2 字节”。如果这是整个应用的数据产生频率,即便每 0.1 秒写入一次,速度也只有约 20 字节/秒。

在这个前提下,磁盘顺序写入吞吐量通常不会是主要瓶颈。还需要确认:

  • 这个间隔描述的是完整的数据流,还是某一类记录。
  • 后续应用是否需要在文件仍在写入时读取数据。
  • 是否要求断电后仍能保留最近写入的数据。

这些要求会影响刷新策略。文件大小主要影响容量规划,不必将整个文件放进内存。

标准 I/O 的实现示例

假设每条记录是一个 16 位整数,数据由迭代器逐个提供,就无需提前将全部记录载入内存:

import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.PrimitiveIterator;

public final class BinaryFileWriter {
    public static void writeFile(
            Path target,
            PrimitiveIterator.OfInt samples) throws IOException {

        try (DataOutputStream out = new DataOutputStream(
                new BufferedOutputStream(
                        Files.newOutputStream(target),
                        64 * 1024))) {

            while (samples.hasNext()) {
                out.writeShort(samples.nextInt());
            }
        }
    }
}

64 * 1024 是一个可调整的缓冲区大小示例,并不代表性能最优值。缓冲区会重复使用,不会随文件大小增长。

需要注意以下行为:

  • writeShort(int) 写入参数的低 16 位,采用大端字节序;读取端必须使用一致的格式。
  • 如果数据本来就是两个原始字节,应按既定格式写入,不能随意改用整数编码。
  • Files.newOutputStream(target) 默认会创建文件,或截断已有文件;示例适用于重新生成目标文件。
  • 正常退出 try 块时,关闭外层流会刷新缓冲区并关闭底层流。写入或关闭期间发生的 IOException 仍需由调用方处理。

如果原有实现已经逐条生成数据,通常只需将文件输出流换成带缓冲的流,不必改变数据生成方式。

flush 应该什么时候调用?

BufferedOutputStream 会先积累小块数据,缓冲区空间不足时再向底层流写出。每写入 2 字节就调用一次 flush(),会降低合并小写入的效果。

如果后续应用在当前程序完成后才读取文件,正常关闭输出流通常就足够了,不必因为文件体积大而额外定期刷新。

如果后续应用需要在写入过程中读取,可以在一批完整记录结束后,或按业务允许的时间间隔调用 flush()。低频写入时,缓冲区可能长时间无法填满,因此不能只依赖缓冲区满时自动写出。

还需要区分两个层次:

  • flush():把 Java 输出流缓冲区中的数据交给底层输出流。
  • 持久化要求:如果需要明确请求将更新写入存储设备,可根据实现选择 FileChannel.force(...) 或 FileDescriptor.sync()。

flush() 本身不能承诺数据在断电后仍然存在。持久化操作也有成本,频率应由业务允许的数据丢失范围决定。

如果使用 NIO,ByteBuffer 应怎样复用?

对于连续的小记录,通常可以创建一个缓冲区并重复使用,无需每写入 2 字节就分配一个新的 ByteBuffer。

常见流程是:

  1. 创建 ByteBuffer,确认字节序与文件格式一致。
  2. 用 putShort() 等方法不断填入数据。
  3. 空间不足时调用 flip(),切换到读取缓冲区内容的状态。
  4. 循环调用 FileChannel.write(buffer),直到 buffer.hasRemaining() 为 false。
  5. 调用 clear(),准备下一轮填充。
  6. 数据生成结束后,按同样流程写出最后一批尚未写入的数据。

FileChannel.write() 的返回值表示本次实际写入的字节数,一次调用不一定能写完全部剩余数据。

clear() 只重置位置和限制,不会把底层内存清零。ByteBuffer 也不会自行定时写文件;批量写入和定时刷新需要由应用安排。

NIO 可以提供更直接的缓冲区与通道控制,但是否更快,需要在实际工作负载下测量。

Memory Mapped Files 和 DeferredFileOutputStream 是否适合?

Memory Mapped Files

内存映射可用于访问大型文件,尤其适合需要按位置读写的场景。但这里是持续产生少量数据并顺序写入,映射带来的收益未必能抵消管理成本。

使用映射时,还要处理映射区域大小、文件扩展、跨区域写入和资源生命周期。不能把操作系统可能进行的后台回写当成应用可控制的固定自动刷新周期;需要主动请求写回时,应了解 MappedByteBuffer.force() 的语义和运行环境限制。

当前可以先保留这个选项,等测量结果表明标准 I/O 不满足要求后再评估。

DeferredFileOutputStream

DeferredFileOutputStream 主要用于先把数据保存在内存中,达到阈值后再转到文件,适合需要根据数据量决定存储位置的场景。

如果已经确定最终要生成一个 GB 级文件,直接使用 BufferedOutputStream 更容易理解和控制。引入“先保存在内存、超过阈值再转文件”的流程,也不能据此认定会提高顺序写入速度。

如何判断是否还需要优化?

先用真实的数据生成逻辑和目标存储环境测量,分别观察数据计算、编码和文件写入耗时。若业务要求定期持久化,测试也应包含相同的持久化操作。

比较方案时,应保持记录格式、缓冲策略和持久化要求一致。仅测量把数据交给操作系统缓存的速度,不能代表完成持久化后的速度。

对于当前需求,可以先使用固定大小的缓冲区,正确关闭流,并明确读取端何时需要看到数据。

备注:内容仅供参考。