侧边栏壁纸
  • 累计撰写 143 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Java 文件 I/O:为什么快,何时才落盘?

YaFuX
2026-06-19 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

假设我们使用 Java 向文件中写入一条数据:

try (FileOutputStream output = new FileOutputStream("order.log", true)) {
    output.write("order created\n".getBytes(StandardCharsets.UTF_8));
}

write() 方法返回以后,这条数据在哪里?

它可能已经进入:

Java 程序
Linux 内核
Page Cache
文件系统
磁盘控制器缓存
SSD

中的哪一层?

如果程序随后正常退出,数据是否一定不会丢失?

如果服务器突然断电呢?

如果调用:

output.flush();

是不是就代表数据已经写入磁盘?

如果调用:

output.getFD().sync();

又有什么不同?

继续深入,还会遇到更多问题:

为什么 BufferedOutputStream 通常比普通 FileOutputStream 更快?

Buffered I/O 的性能提升,究竟来自 Java 缓冲区还是 Linux Page Cache?

Java 堆内缓冲区和直接缓冲区有什么区别?

DirectByteBuffer 所谓的“直接”,是不是等于 Linux 的 Direct I/O?

MappedByteBuffer.put() 没有显式调用 write(),数据是怎样写进文件的?

mmap 是否完全绕过了 Page Cache?

为什么写入成功的数据仍然可能在断电后丢失?

脏页什么时候由内核写回磁盘?

dirty_ratiodirty_background_ratio 分别控制什么?

FileChannel.force()MappedByteBuffer.force()fsync() 有什么关系?

mmap、堆外内存和普通 Buffered I/O,哪一种一定更快?

这些问题看起来分别属于 Java、JVM、Linux 和硬件,但它们实际上组成了一条完整的文件 I/O 链路:

Java 对象
    ↓
应用程序缓冲区
    ↓
系统调用
    ↓
Linux Page Cache
    ↓
文件系统
    ↓
块设备层
    ↓
设备控制器缓存
    ↓
持久化存储介质

要真正理解 Java I/O,不能只停留在:

BIO
NIO
Buffer
Channel

这些 API 名称上。

我们还需要回答一个更根本的问题:

一次 Java 文件读写,究竟经过了多少层缓冲,以及在哪一层完成时才算真正持久化?

本文将沿着这条主线,依次理解:

普通文件 I/O
BufferedOutputStream
系统调用
Page Cache
脏页与回写
flush、sync 和 force
HeapByteBuffer
DirectByteBuffer
FileChannel
MappedByteBuffer
mmap
Direct I/O
数据可靠性

一、先建立完整的文件 I/O 模型

1. 一次写入可能经过三层缓存

假设 Java 程序使用:

BufferedOutputStream output =
        new BufferedOutputStream(
                new FileOutputStream("data.log")
        );

那么一次写入可能经过:

第一层:Java 应用程序缓冲区
第二层:Linux Page Cache
第三层:存储设备自身的写缓存

完整链路可以表示为:

Java 数据
    ↓
BufferedOutputStream 内部 byte[]
    ↓
FileOutputStream
    ↓
write() 系统调用
    ↓
Linux Page Cache
    ↓
脏页回写
    ↓
文件系统与块设备层
    ↓
磁盘或 SSD 控制器缓存
    ↓
非易失存储介质

这三层缓存解决的问题不同。

Java 应用程序缓冲区

主要目的:

合并多次小写入
减少底层 OutputStream 调用
减少系统调用次数

Linux Page Cache

主要目的:

缓存文件内容
合并和延迟设备 I/O
提高重复读取速度
统一普通读写与内存映射文件的缓存管理

设备缓存

主要目的:

吸收写入请求
合并底层操作
重新排序 I/O
改善存储设备吞吐量

因此,“写入完成”至少可能有三种含义:

数据被 Java 缓冲区接受
数据被 Linux 内核接受
数据已经进入持久化介质

它们不是一回事。


二、普通 FileOutputStream 为什么可能比较慢?

1. 没有应用缓冲区的写入

下面的代码每次只写入 10 个字节:

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public final class SmallWriteExample {

    private static final byte[] DATA =
            "123456789\n".getBytes(StandardCharsets.UTF_8);

    public static void main(String[] args) throws IOException {
        try (FileOutputStream output =
                     new FileOutputStream("plain.log")) {

            for (int i = 0; i < 1_000_000; i++) {
                output.write(DATA);
            }
        }
    }
}

FileOutputStream 本身不提供类似 BufferedOutputStream 的应用层批量缓冲。

当程序反复执行小块写入时,底层往往需要频繁进入操作系统。实际调用次数和实现细节可以通过 strace 验证,而不应该仅凭 Java 方法调用次数猜测。

可以执行:

strace -f \
  -e trace=write,writev,fsync,fdatasync \
  -c \
  java SmallWriteExample

或者查看具体调用:

strace -f \
  -e trace=write,writev,fsync,fdatasync \
  java SmallWriteExample

可能观察到大量类似操作:

write(fd, "123456789\n", 10)
write(fd, "123456789\n", 10)
write(fd, "123456789\n", 10)

2. 系统调用为什么有成本?

用户程序通常运行在用户态。

访问文件、网络和设备时,需要通过系统调用请求内核服务:

用户态 Java 代码
        ↓
系统调用入口
        ↓
切换到内核态
        ↓
执行 VFS 和文件系统代码
        ↓
返回用户态

这个过程涉及:

参数检查
权限检查
文件描述符查找
内核执行路径
寄存器与执行状态切换
调度和安全边界

系统调用并不是“慢得不能使用”,但如果每次只传输几个字节,固定开销占总成本的比例就会非常高。

例如:

执行 100 万次、每次写 10 字节

与:

执行约 1221 次、每次写约 8 KiB

最终写入的数据量相同,但系统调用次数可能相差几个数量级。


三、BufferedOutputStream 为什么更快?

1. 在 Java 内部先积累数据

BufferedOutputStream 内部维护一个字节数组。

程序执行:

output.write(data);

时,数据可以先进入该数组。

只有满足下列条件之一时,才需要进一步写入底层流:

缓冲区已满
显式调用 flush()
关闭输出流
单次数据过大,具体实现选择直接下传

Oracle 对 BufferedOutputStream 的定义是:应用可以先把字节写入内部缓冲区,而不必让每次写入都立即触发对底层系统的调用。

示例:

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public final class BufferedWriteExample {

    private static final byte[] DATA =
            "123456789\n".getBytes(StandardCharsets.UTF_8);

    public static void main(String[] args) throws IOException {
        try (BufferedOutputStream output =
                     new BufferedOutputStream(
                             new FileOutputStream("buffered.log"),
                             64 * 1024
                     )) {

            for (int i = 0; i < 1_000_000; i++) {
                output.write(DATA);
            }
        }
    }
}

数据链路从:

每写 10 字节
    ↓
可能调用一次底层写入

变成:

多次 10 字节写入
    ↓
先累积到 64 KiB 缓冲区
    ↓
批量写入底层流

2. Buffered I/O 没有绕过 Page Cache

BufferedOutputStream 中的“Buffer”是:

Java 用户空间缓冲区

Linux Page Cache 则是:

内核空间中的文件缓存

两者是不同层次。

完整链路为:

Java BufferedOutputStream
            ↓
Java byte[] 缓冲区
            ↓
FileOutputStream
            ↓
write() 系统调用
            ↓
Linux Page Cache

因此:

BufferedOutputStream 不是用来替代 Page Cache,而是用来减少应用程序调用内核的次数。

3. Buffer 越大越好吗?

不一定。

增大缓冲区可能减少系统调用,但也会带来:

更高的应用内存占用
更长的未下传时间
更大的单次延迟
更多等待 flush 的数据

如果一次写入本身已经非常大:

output.write(largeByteArray);

继续增加缓冲区大小可能不会产生明显收益。

合理缓冲区大小取决于:

单次数据大小
吞吐量目标
延迟要求
并发连接数量
可用内存
底层文件系统与设备特征

四、Page Cache 到底是什么?

1. Page Cache 缓存的是文件内容

Linux 会在内存中缓存普通文件的数据。

读取路径可以抽象为:

应用调用 read()
       ↓
根据文件与偏移量查找 Page Cache
       ↓
┌──────────────┴──────────────┐
│                             │
命中                         未命中
│                             │
从内存复制给应用             发起设备 I/O
                              ↓
                         数据进入 Page Cache
                              ↓
                         再复制给应用

写入路径则通常为:

应用调用 write()
       ↓
修改 Page Cache 中对应的文件页
       ↓
页面标记为 Dirty
       ↓
write() 可以返回
       ↓
内核稍后回写存储设备

Linux VFS 会为文件维护缓存页面及其 Dirty、Writeback 等状态,使内核能够定位需要写回的文件缓存。

2. Page Cache 是固定的 4 KiB 吗?

不能绝对这样说。

在常见的 x86-64 Linux 环境中,基础页大小经常是:

4 KiB

因此很多课程和工具按 4 KiB 解释 Page Cache。

但页面大小与:

CPU 架构
内核配置
大页机制
具体内存管理对象

有关。

现代 Linux 内核还使用 folio 表示由一个或多个连续页面组成的内存管理对象。

因此更准确的说法是:

Page Cache 按内核页面或 folio 管理文件内容;4 KiB 是非常常见的基础页大小,但不是跨所有平台的绝对常量。

3. 同一个文件会不会被多个进程重复缓存?

通常不会因为每个进程分别 open(),就在 Page Cache 中保存多份完全相同的文件页。

可以抽象为:

进程 A 的 fd
    ↓
打开文件描述 A
    ↓
同一个文件对象 ─────→ Page Cache

进程 B 的 fd
    ↓
打开文件描述 B
    ↓
同一个文件对象 ─────→ Page Cache

两个进程可以拥有独立的:

文件描述符
打开文件描述
当前文件偏移量

但会共享与文件内容关联的缓存。

需要注意:

共享 Page Cache
≠
整个应用只有一份数据副本

应用程序仍可能拥有自己的:

byte[]
BufferedInputStream 缓冲区
ByteBuffer
反序列化对象

4. pcstat 能说明什么?

pcstat 一类工具通常用于观察:

文件的哪些页面当前驻留在内存中

它可以帮助我们判断某个文件有多少区域处于缓存状态。

但:

页面驻留在内存

不能证明:

页面是干净的
页面已经持久化
页面不会被回收

因此,看到:

cached: 100%

只能说明文件页面当前处于内存中,不能说明文件已经安全落盘。


五、什么是脏页?

1. 内存内容比磁盘更新

假设磁盘中的文件内容为:

ABC

应用程序把它修改为:

ABD

修改刚进入 Page Cache、尚未写回磁盘时:

Page Cache:ABD
磁盘文件: ABC

此时内存和持久化存储不一致,对应的缓存页被称为:

Dirty Page
脏页

页面完成回写后:

Page Cache:ABD
磁盘文件: ABD

脏标记才可以被清除。

2. 新分配的页不一定天然是文件脏页

“刚创建的页面都是脏页”并不准确。

一个页面是否为脏,取决于:

它是否代表需要写回后端存储的修改

例如:

从文件读取到 Page Cache 的页面

如果没有被修改,通常是干净的。

而:

新文件被写入的数据页

则需要写回文件系统,因此会变成脏页。

3. 干净页和脏页怎样回收?

当系统出现内存压力时:

干净的文件缓存页

磁盘中已经有相同内容,可以直接回收:

干净页
   ↓
丢弃内存副本
   ↓
以后需要时重新读取文件

脏的文件缓存页

磁盘中没有最新内容,通常必须先安排回写:

脏页
   ↓
写回文件系统
   ↓
变为干净页
   ↓
才可以安全回收

“脏页永远不能淘汰”也不准确。

正确说法是:

脏页不能像干净文件页一样直接丢弃,通常要先完成或安排必要的写回,再释放其内存。


六、Linux 什么时候回写脏页?

Linux 的脏页回写不是只由一个固定的:

5 秒
10%
30 秒

规则决定。

它受到多个 VM 参数、文件系统行为、内存压力和写入负载共同影响。

1. dirty_background_ratio

vm.dirty_background_ratio

表示后台回写线程开始工作的脏内存比例阈值。

可以抽象为:

脏页逐渐增加
    ↓
达到后台阈值
    ↓
内核后台回写线程开始异步回写
    ↓
产生脏页的应用通常仍可继续运行

2. dirty_ratio

vm.dirty_ratio

表示产生脏数据的进程需要受到限制、并参与回写或等待回写进度的较高阈值。

可以抽象为:

脏页继续增加
    ↓
达到较高阈值
    ↓
写入进程被限速或进入回写路径
    ↓
应用写入延迟明显增加

Linux 内核文档将 dirty_ratio 描述为产生磁盘写入的进程自身开始参与回写的脏内存阈值。

这不等于:

达到 dirty_ratio 后立即把系统全部脏页同步写完

它更接近一种写入节流和回写压力机制。

3. dirty_background_bytes 与 dirty_bytes

除了百分比,还可以使用绝对字节数:

dirty_background_bytes
dirty_bytes

它们分别对应:

dirty_background_ratio
dirty_ratio

比例参数和字节参数是互斥的配置方式:写入其中一个时,相对应的另一种形式会被置为零。

4. dirty_writeback_centisecs

vm.dirty_writeback_centisecs

控制内核回写线程周期性醒来的时间间隔。

单位是:

百分之一秒
centisecond

例如:

500

表示:

5 秒

而不是“500 毫秒”。

5. dirty_expire_centisecs

vm.dirty_expire_centisecs

表示脏数据达到多大年龄后,具备被周期性回写处理的资格。

它不表示:

某个页面到达该时间后一定在那个瞬间立即落盘

它只是回写决策的一个时间条件。

Linux 官方文档集中说明了这些 /proc/sys/vm 参数的含义和相互关系。

6. 不要直接把阈值改成 90%

将:

dirty_background_ratio = 90

理解为“高性能配置”是危险的。

可能带来的问题包括:

积累大量待回写数据
回写开始后产生巨大 I/O 峰值
写请求延迟剧烈抖动
系统回收内存更加困难
断电时可能损失更多未持久化数据

调优需要结合:

内存容量
写入吞吐
存储设备速度
业务延迟要求
可接受的数据丢失窗口
文件系统
容器或 cgroup 限制

而不是照抄一个比例。


七、flush、close、sync 和 force 有什么区别?

这是 Java 文件 I/O 中最容易混淆的一组概念。

1. OutputStream.flush()

output.flush();

flush() 的作用是让当前 Java 流实现中尚未下传的缓冲数据,尽快写向它的目标。

如果目标是操作系统提供的文件抽象,Java 官方文档明确说明:

flush() 只能保证先前缓冲的数据被传递给操作系统,不保证已经写入物理磁盘。

因此:

BufferedOutputStream.flush()
        ↓
Java 缓冲区数据进入底层 FileOutputStream
        ↓
通常进入 Linux Page Cache

但此时数据仍可能只是脏页。

2. FileOutputStream 自身的 flush()

OutputStream 基类的默认 flush() 实现什么也不做。

FileOutputStream 没有应用层字节缓冲区需要清空,因此对一个裸 FileOutputStream 调用 flush(),不能理解为请求磁盘持久化。

真正需要清空的是外层:

BufferedOutputStream
BufferedWriter
OutputStreamWriter
PrintWriter

等可能持有应用缓冲区的对象。

3. close()

关闭一个带缓冲的 Java 输出流,通常会先把 Java 缓冲数据传递给底层流,然后关闭文件描述符。

但是:

close()

不能被普遍理解为:

数据已稳定写入磁盘介质

Linux 的 write() 成功也不保证数据已经提交到磁盘,某些错误甚至可能延迟到之后的 write()fsync()close() 才暴露。

4. FileDescriptor.sync()

Java 可以显式调用:

output.getFD().sync();

Oracle 文档说明,如果文件描述符对应物理存储,sync() 会等待相关的内存修改缓冲区写入物理介质;但它只影响该文件描述符下游的缓冲,因此应用层存在 BufferedOutputStream 时,必须先执行 flush()

正确顺序是:

try (FileOutputStream fileOutput =
             new FileOutputStream("transaction.log", true);
     BufferedOutputStream bufferedOutput =
             new BufferedOutputStream(fileOutput)) {

    bufferedOutput.write(record);

    // 第一层:清空 Java 应用缓冲区
    bufferedOutput.flush();

    // 第二层:请求操作系统将数据同步到存储设备
    fileOutput.getFD().sync();
}

5. FileChannel.force()

channel.force(true);

用于请求将通过该文件通道完成的修改强制写到底层存储设备。

参数含义为:

false
    主要要求文件内容写出

true
    还要求写出实现持久化所需的相关元数据

FileChannel 官方文档把 force() 定义为把文件更新强制写到底层存储,使数据在系统崩溃后仍可恢复。

示例:

try (FileChannel channel = FileChannel.open(
        Path.of("orders.log"),
        StandardOpenOption.CREATE,
        StandardOpenOption.WRITE,
        StandardOpenOption.APPEND
)) {
    ByteBuffer buffer =
            StandardCharsets.UTF_8.encode("order created\n");

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

    channel.force(true);
}

6. SYNC 与 DSYNC

Java NIO 还提供:

StandardOpenOption.SYNC
StandardOpenOption.DSYNC

两者要求每次更新同步写到底层存储。

大致区别为:

DSYNC
    同步文件内容以及读取内容所必需的元数据

SYNC
    同步文件内容和更完整的文件元数据

Oracle 文档把 SYNC 定义为同步文件内容或元数据,把 DSYNC 定义为同步文件内容。

示例:

try (FileChannel channel = FileChannel.open(
        Path.of("critical.log"),
        StandardOpenOption.CREATE,
        StandardOpenOption.WRITE,
        StandardOpenOption.APPEND,
        StandardOpenOption.DSYNC
)) {
    channel.write(StandardCharsets.UTF_8.encode("record\n"));
}

同步写会显著提高单次写入延迟,因此通常需要批量提交。


八、fsync 能保证所有事情都绝对安全吗?

1. fsync 的保证范围

Linux 的 fsync() 会把文件修改过的内存数据及必要元数据同步到持久存储,并等待设备完成相应操作。

但它不能自动保证:

业务操作具备事务原子性
多个文件之间保持一致
数据结构不会出现半条记录
文件格式没有逻辑损坏
异步副本已经收到数据
应用没有错误覆盖旧数据
硬件不存在故障

例如,一条业务记录包含:

长度
正文
校验和
索引

如果写到一半进程崩溃,即使之前的一部分已经持久化,文件中仍可能出现半条记录。

2. 新建文件还涉及目录持久化

假设流程是:

创建 temp 文件
写入数据
fsync(temp)
rename(temp, target)

这是一种常见的原子替换思路。

但在要求严格崩溃一致性的场景下,还要考虑:

文件数据是否落盘
文件元数据是否落盘
rename 对应的目录项是否落盘
父目录是否同步

单纯对文件内容调用同步,不一定覆盖目录项的持久化语义。

3. 数据可靠性不是一个方法解决的

可靠存储系统通常还需要:

WAL 或追加日志
记录边界
长度字段
校验和
版本号
幂等恢复
Copy-on-Write
原子重命名
Group Commit
多副本确认
崩溃恢复扫描

因此:

fsync

是持久化协议的重要组成部分,但不是完整事务协议。


九、ByteBuffer 的 position、limit 和 capacity

Java NIO 的核心对象之一是:

ByteBuffer

一个 Buffer 主要包含四个状态:

capacity
limit
position
mark

1. 初始状态

ByteBuffer buffer = ByteBuffer.allocate(8);

初始状态:

capacity = 8
limit    = 8
position = 0

图示:

索引      0 1 2 3 4 5 6 7
          ↑               ↑
       position         limit
                          capacity

2. put()

写入三个字节:

buffer.put((byte) 'A');
buffer.put((byte) 'B');
buffer.put((byte) 'C');

状态变成:

capacity = 8
limit    = 8
position = 3
索引      0 1 2 3 4 5 6 7
数据      A B C
                ↑         ↑
             position   limit

3. flip()

写完以后准备读取:

buffer.flip();

flip() 会完成:

limit = 原 position
position = 0
mark 被清除

状态变成:

capacity = 8
limit    = 3
position = 0
索引      0 1 2 3 4 5 6 7
数据      A B C
          ↑     ↑
       position limit

因此读取不会越过已经写入的数据。

4. get()

byte first = buffer.get();

读取 A 后:

position = 1
limit    = 3

5. clear()

buffer.clear();

状态恢复为:

position = 0
limit    = capacity

注意:

clear() 不一定真的把底层字节全部填零。

它主要重置状态,让整个缓冲区可以重新写入。

6. compact()

假设只读取了一个字节,缓冲区还剩:

B C

调用:

buffer.compact();

会把未读取部分移动到缓冲区开头:

B C _ _ _ _ _ _
    ↑
 position

之后可以继续追加写入。

NIO 的 Buffer API 正是通过 position、limit、capacity、flip、clear 和 compact 等状态操作,在同一块内存中切换读写模式。


十、HeapByteBuffer 和 DirectByteBuffer 有什么区别?

1. HeapByteBuffer

ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

其数据通常由 JVM 堆中的 byte[] 支持。

结构可以理解为:

Java 堆
┌──────────────────────┐
│ HeapByteBuffer 对象   │
│        ↓              │
│ byte[1024]            │
└──────────────────────┘

优点:

分配速度通常较快
由 GC 正常管理
访问 Java 数组方便
适合短生命周期和普通业务数据

2. DirectByteBuffer

ByteBuffer directBuffer =
        ByteBuffer.allocateDirect(1024);

Direct Buffer 的数据区域通常位于普通垃圾回收堆之外的原生内存中。

但:

DirectByteBuffer 对象本身

仍然是 Java 对象,JVM 需要通过它追踪和释放对应的原生内存。

Oracle 文档说明,JVM 会尽力直接在 Direct ByteBuffer 上执行原生 I/O;直接缓冲区还可能位于普通 GC 堆之外的原生内存。

优点可能包括:

适合与本地 I/O 接口配合
减少某些场景下的中间复制
适合长生命周期、重复使用的大块 I/O 缓冲区

成本包括:

分配和释放成本通常更高
不受普通堆大小完全约束
容易增加 Native Memory 压力
生命周期依赖 Cleaner 或显式资源管理机制
频繁创建小 DirectBuffer 可能适得其反

3. 堆内缓冲区是否一定复制一次?

不能把所有实现都概括为:

HeapByteBuffer 写文件
    一定先完整复制到一个堆外 Buffer

JVM 和底层实现可以:

固定或暂时固定堆内存
使用临时直接缓冲区
执行分块复制
使用平台特定优化

具体路径取决于:

JDK 版本
JVM 实现
操作系统
缓冲区类型
Channel 实现
数据大小

官方 API 只承诺 Direct Buffer 会尽力用于直接原生 I/O,并不承诺每次都必然减少一次固定形式的复制。

4. DirectByteBuffer 不等于 Direct I/O

这是非常重要的区别。

DirectByteBuffer

表示:

Java 缓冲区位于直接或原生内存
适合与本地 I/O 操作配合

Linux:

O_DIRECT

则表示:

文件 I/O 尽量绕过 Page Cache

所以:

DirectByteBuffer
≠
O_DIRECT

Direct Buffer 通过普通 FileChannel.write() 写文件时,数据仍然通常进入 Linux Page Cache。


十一、什么是 Linux Direct I/O?

Linux 可以使用:

O_DIRECT

打开文件。

它用于尽量降低文件 I/O 对 Page Cache 的影响,让数据在应用缓冲区和存储设备之间走更直接的路径。

O_DIRECT 有严格限制。

不同文件系统和内核版本可能要求:

用户缓冲区地址对齐
写入长度对齐
文件偏移量对齐

不满足条件时,操作可能失败,或者在部分平台退回其他路径。Linux open(2) 文档明确说明,O_DIRECT 的对齐要求随文件系统和内核版本变化。

1. Direct I/O 不等于同步落盘

即使使用:

O_DIRECT

也不能自动理解为:

write() 返回后数据已持久化

绕过 Page Cache 和保证持久化是两个不同问题。

通常仍要根据可靠性要求使用:

fsync
fdatasync
O_SYNC
O_DSYNC

write(2) 文档同样强调,成功返回并不保证数据已经提交到磁盘。

2. Java 标准 API 是否提供 O_DIRECT?

Java 标准的 StandardOpenOption 提供:

SYNC
DSYNC

但没有定义一个可移植、跨平台的 O_DIRECT 等价选项。

文件系统 Provider 可以提供实现专属的额外 OpenOption,因此某些 JDK 或平台可能存在非标准扩展,但不能把它写成通用 Java 规范能力。Oracle 文档也明确允许实现支持额外的专属打开选项。


十二、FileChannel 解决了什么问题?

FileChannel 提供了比传统 Stream 更贴近文件随机访问的接口。

它支持:

顺序读取和写入
指定绝对位置读写
修改当前位置
截断文件
文件锁
内存映射
Channel 间数据传输
强制持久化

Oracle 的 FileChannel 文档将随机位置访问、内存映射、强制写出和 Channel 间传输列为其主要文件能力。

1. 绝对位置读写

ByteBuffer buffer = ByteBuffer.allocate(1024);

int count = channel.read(buffer, 10_000);

这会从文件绝对位置:

10000

开始读取,而不必先修改 Channel 的共享当前位置。

适合:

文件索引
分段文件
断点续传
固定记录文件
并发读取不同区域

2. RandomAccessFile 与 FileChannel 的位置关系

RandomAccessFile file =
        new RandomAccessFile("data.bin", "rw");

FileChannel channel = file.getChannel();

二者连接到同一底层文件,RandomAccessFile 的文件指针与 Channel 位置相互关联:通过一方读取、写入或 seek() 修改位置,也会影响另一方观察到的位置。


十三、mmap 到底做了什么?

1. 把文件区域映射到虚拟地址空间

传统读取方式:

read()
  ↓
内核找到文件数据
  ↓
从 Page Cache 复制到用户缓冲区
  ↓
程序访问用户缓冲区

内存映射方式:

mmap()
  ↓
在进程虚拟地址空间中建立文件映射
  ↓
程序通过普通内存地址访问文件内容

Linux mmap() 会在调用进程的虚拟地址空间中创建一段新映射。

Java 中使用:

try (FileChannel channel = FileChannel.open(
        Path.of("data.bin"),
        StandardOpenOption.READ,
        StandardOpenOption.WRITE
)) {
    MappedByteBuffer mapped = channel.map(
            FileChannel.MapMode.READ_WRITE,
            0,
            channel.size()
    );

    mapped.put(0, (byte) 42);
    mapped.force();
}

MappedByteBuffer 是一个内容对应文件内存映射区域的 Direct ByteBuffer,由 FileChannel.map() 创建。

2. mmap 是否把整个文件立即读入内存?

通常不是。

mmap() 主要先建立:

虚拟地址区域
    ↓
文件偏移区间

之间的映射关系。

第一次访问尚未驻留的页面时,可能发生:

CPU 访问映射地址
    ↓
页表中没有可用页面
    ↓
触发 Page Fault
    ↓
内核准备对应文件页面
    ↓
更新映射
    ↓
重新执行访问指令

因此,映射一个很大的文件不代表整个文件立刻全部进入物理内存。

3. put() 为什么没有系统调用?

映射建立后:

mapped.put(index, value);

从 Java 和 CPU 的角度看,是一次普通内存写入。

它通常不需要为每次 put() 单独执行 write() 系统调用。

但这不意味着:

完全没有内核参与

内核仍然负责:

建立映射
处理缺页
维护文件缓存
标记脏页
回写文件
处理权限和异常

所以更准确的表达是:

mmap 把反复的显式 read/write 系统调用,转换成了内存访问与按需缺页处理。

4. mmap 是否绕过 Page Cache?

普通文件的共享内存映射通常仍然与 Linux 文件缓存体系密切相关。

可以理解为:

普通 read/write
    通过系统调用访问文件缓存

mmap
    通过虚拟地址映射访问文件缓存支持的页面

因此:

mmap
≠
O_DIRECT

5. MappedByteBuffer.force()

修改映射区域后:

mapped.force();

用于请求把该映射缓冲区的修改写入包含映射文件的存储设备。

Java 还提供指定范围的:

mapped.force(index, length);

Oracle 文档将其定义为强制把映射缓冲区内容的修改写到存储设备。

在 Linux 层面,类似需求通常对应:

msync()

msync() 用于把文件映射区域的修改同步到底层文件。


十四、mmap 一定比 read/write 快吗?

不一定。

1. mmap 的优势

它适合:

大型文件的随机访问
频繁读取文件中不同位置
多进程共享文件数据
减少反复显式 read/write 调用
以数据结构方式访问固定格式文件

FileChannel 文档指出,对大型文件而言,内存映射有时会比普通 read()write() 更高效。

注意关键词是:

有时

而不是:

永远

2. mmap 的成本

它也会引入:

Page Fault 成本
页表和 TLB 压力
随机访问导致的缺页抖动
映射生命周期管理
文件截断造成映射区域不可访问
大文件分段映射复杂度
异常恢复和数据边界处理困难

Oracle 文档提醒,映射文件被截断等情况下,MappedByteBuffer 的部分区域可能在任意时刻变得不可访问。

3. 小文件顺序读写不一定适合 mmap

如果业务只是:

顺序读取一个小文件
只访问一次
读完立即关闭

传统 Buffered I/O 可能更加简单,并且已经能够利用:

Page Cache
文件系统预读
批量系统调用

mmap 并不会自动消除所有内存访问和 I/O 成本。

4. 不要写死性能排序

下面这种排序是不严谨的:

Heap Buffer < Direct Buffer < mmap

真正的性能取决于:

顺序还是随机访问
数据大小
访问次数
缓冲区是否复用
系统调用频率
缺页次数
内存压力
存储设备
文件系统
并发模型
是否要求同步落盘

性能必须通过目标环境中的基准测试验证。


十五、Page Cache 和 mmap 会导致数据必然丢失吗?

不会。

“只要使用操作系统 I/O,就至少会丢一条数据”是错误的。

数据是否可能丢失,取决于系统提供的持久化协议和应用如何使用它。

1. 不请求同步持久化

write()
    ↓
数据进入 Page Cache
    ↓
应用认为操作完成

如果此时突然断电,尚未回写的数据可能丢失。

2. 请求同步持久化

write()
    ↓
fsync / force / SYNC / DSYNC
    ↓
等待文件系统和设备完成持久化要求
    ↓
向应用返回

可以提供明显更强的持久性保证。

但性能通常会下降。

3. Group Commit

系统不一定必须在:

每写一条数据
    ↓
立即执行一次 fsync

之间二选一。

可以先积累一批事务:

事务 A
事务 B
事务 C
事务 D
    ↓
一次写入
    ↓
一次 fsync
    ↓
共同确认

这就是常见的:

Group Commit
组提交

它能够在:

吞吐量
延迟
持久性

之间取得更合理的平衡。

4. 多副本也不是自动安全

假设主节点采用异步复制:

主节点写入内存
    ↓
立即向客户端返回成功
    ↓
尚未复制到从节点
    ↓
主节点宕机

数据仍然可能丢失。

副本可靠性取决于:

返回成功前要求几个副本确认
副本是否已经持久化
复制是同步还是异步
故障切换选择哪个副本
是否存在脑裂

所以:

有主从复制
≠
绝不会丢数据

十六、怎样设计一个更可靠的 Java 文件写入?

下面是一个简单的同步写入示例:

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Objects;

public final class DurableFileWriter {

    private final Path path;

    public DurableFileWriter(Path path) {
        this.path = Objects.requireNonNull(path, "path");
    }

    public void appendAndSync(String value) throws IOException {
        byte[] data = value.getBytes(StandardCharsets.UTF_8);

        try (FileOutputStream fileOutput =
                     new FileOutputStream(path.toFile(), true);
             BufferedOutputStream bufferedOutput =
                     new BufferedOutputStream(fileOutput, 64 * 1024)) {

            bufferedOutput.write(data);

            // 把 Java 应用层缓冲区交给底层文件流。
            bufferedOutput.flush();

            // 请求把文件描述符下游的修改同步到存储设备。
            fileOutput.getFD().sync();
        }
    }
}

这个例子比单纯调用:

flush();

具有更强的持久化语义。

但生产系统还应该考虑:

每次重新打开文件的成本
多线程串行化
记录边界
部分写入
磁盘空间耗尽
I/O 错误
校验和
崩溃恢复
批量提交
目录项持久化

一个更实际的日志协议

可以设计记录格式:

魔数
版本
数据长度
序列号
正文
校验和

写入流程:

构造完整记录
    ↓
一次或循环完整写入
    ↓
批量积累多条记录
    ↓
执行 force 或 sync
    ↓
向调用方确认成功

恢复流程:

从头扫描日志
    ↓
校验长度和校验和
    ↓
遇到不完整尾记录
    ↓
截断或忽略

这种协议比“每条数据执行一次 flush”更加接近真正的可靠存储系统。


十七、怎样做一个可验证的 I/O 实验?

不要通过强制断电直接验证数据可靠性。

可以先使用工具观察各层行为。

1. 对比系统调用次数

普通输出:

strace -f \
  -e trace=write,writev,fsync,fdatasync \
  -c \
  java PlainWrite

缓冲输出:

strace -f \
  -e trace=write,writev,fsync,fdatasync \
  -c \
  java BufferedWrite

重点观察:

write 调用次数
每次写入字节数
fsync 或 fdatasync 次数
总耗时

2. 查看进程文件描述符

ls -l /proc/<PID>/fd

或者:

lsof -p <PID>

3. 查看系统脏页

grep -E '^(Dirty|Writeback|Cached):' /proc/meminfo

持续观察:

watch -n 0.5 \
  "grep -E '^(Dirty|Writeback|Cached):' /proc/meminfo"

4. 查看脏页配置

sysctl vm.dirty_background_ratio
sysctl vm.dirty_ratio
sysctl vm.dirty_background_bytes
sysctl vm.dirty_bytes
sysctl vm.dirty_expire_centisecs
sysctl vm.dirty_writeback_centisecs

5. 观察缓存驻留

使用 pcstat 一类工具观察文件页面是否驻留:

pcstat data.log

但必须记住:

缓存驻留率
≠
脏页比例
≠
持久化状态

十八、几个容易混淆的概念

1. BufferedOutputStream 就是 Page Cache

错误。

BufferedOutputStream
    Java 用户空间缓存

Page Cache
    Linux 内核文件缓存

2. 使用 BufferedOutputStream 就不会调用系统调用

错误。

它只是减少调用次数,最终仍然需要通过底层 I/O 进入内核。

3. Page Cache 占用内存就是内存泄漏

错误。

Linux 会利用空闲内存缓存文件数据,并在出现内存压力时回收适合回收的缓存。

4. 所有 Page Cache 页面都是 4 KiB

不严谨。

4 KiB 很常见,但与架构、内核配置和 folio 管理方式有关。

5. 文件刚创建时,所有页面就是脏页

错误。

只有发生需要写回文件的内容或元数据修改,相关缓存对象才会处于脏状态。

6. flush 就是刷盘

错误。

Java flush() 通常只保证应用缓冲数据下传到操作系统,不保证物理落盘。

7. close 就是 fsync

错误。

关闭资源与请求持久化不是完全相同的语义。

8. write 返回代表数据已经落盘

错误。

Linux 明确不作这个保证。

9. DirectByteBuffer 就是 Direct I/O

错误。

DirectByteBuffer
    原生内存缓冲区

O_DIRECT
    尽量绕过 Page Cache 的文件打开模式

10. mmap 会把整个文件立即加载进内存

错误。

它首先建立虚拟地址映射,页面通常按需装入。

11. MappedByteBuffer.put 每次都会执行 write 系统调用

错误。

映射完成后,普通访问表现为内存读写;缺页和回写由内核在相应时机处理。

12. mmap 完全绕过 Page Cache

通常错误。

普通文件映射仍然与文件缓存和文件支持的页面密切相关。

13. mmap 一定比普通 I/O 快

错误。

是否更快取决于访问模式、文件大小、缺页成本、映射管理和持久化要求。

14. Java NIO 中的 N 表示 Non-blocking

错误。

NIO 最初表示:

New I/O

虽然 NIO 包含 SelectableChannel 等非阻塞网络能力,但普通文件 FileChannel 并不会因为属于 NIO 就自动变成异步文件 I/O。Oracle 将 NIO 定义为围绕 Buffer、Channel、Charset 和可选择通道等构建的新 I/O API。

15. DirectBuffer 位于另一个独立地址空间

错误。

Java 堆和直接内存都位于同一个操作系统进程的虚拟地址空间中。

区别主要是:

是否属于普通 GC 堆
如何分配和释放
JVM 怎样追踪
怎样与本地 I/O 交互

16. 使用 fsync 就不需要应用协议

错误。

fsync 解决持久化顺序中的一部分问题,不能自动提供记录原子性、事务回滚、校验和和多文件一致性。


十九、完整串联一次 Java 文件写入

假设 Java 执行:

bufferedOutput.write(record);
bufferedOutput.flush();
fileOutput.getFD().sync();

完整过程可以抽象为:

业务对象
    ↓
序列化为 byte[]
    ↓
BufferedOutputStream
    ↓
Java 应用层 Buffer
    ↓ flush()
FileOutputStream
    ↓
write() 系统调用
    ↓
CPU 进入内核态
    ↓
根据 fd 找到打开文件描述
    ↓
VFS
    ↓
具体文件系统
    ↓
修改 Page Cache
    ↓
缓存页标记为 Dirty
    ↓
write() 返回
    ↓ sync()
文件系统提交数据与必要元数据
    ↓
块设备层
    ↓
设备驱动
    ↓
设备缓存刷新
    ↓
非易失存储介质
    ↓
sync() 返回

如果没有:

flush()

数据可能仍然停留在 Java 用户空间缓冲区。

如果只有:

flush()

数据可能已经进入 Linux Page Cache,但尚未持久化。

如果调用:

sync() / force()

则是在请求操作系统完成更强的持久化步骤。


二十、总结

Java 文件 I/O 的性能,首先取决于数据经过了多少次小粒度调用。

普通小块写入可能产生大量:

用户态
    ↓
内核态
    ↓
用户态

切换。

BufferedOutputStream 通过应用层字节数组,把多次小写入合并成较少的大写入:

减少系统调用次数
    ↓
降低固定开销占比
    ↓
提高吞吐量

但它没有绕过 Linux Page Cache。

Page Cache 是内核中的文件缓存层:

读取时缓存文件数据
写入时接收修改并形成脏页
稍后由内核回写存储设备

干净文件页可以直接回收,脏页通常需要先完成必要的写回。

Linux 使用:

dirty_background_ratio
dirty_ratio
dirty_background_bytes
dirty_bytes
dirty_expire_centisecs
dirty_writeback_centisecs

等参数参与控制回写与写入节流,不能简单概括成固定的“5 秒或 10%”。

Java 中:

flush

主要负责清空应用程序缓冲区。

FileDescriptor.sync
FileChannel.force
MappedByteBuffer.force
SYNC
DSYNC

用于表达更强的持久化要求。

HeapByteBuffer 的数据通常位于 JVM 堆中。

DirectByteBuffer 的数据通常位于普通 GC 堆外的原生内存中,并适合与本地 I/O 配合,但:

DirectByteBuffer
≠
Linux O_DIRECT

MappedByteBuffer 通过 mmap 把文件区域映射到进程虚拟地址空间。

建立映射后,程序可以通过内存访问文件内容,不需要为每一次 get()put() 显式调用 read()write()

但是:

mmap 不会自动把整个文件读入内存
mmap 通常不会绕过 Page Cache
mmap 不保证一定比普通 I/O 快
mmap 修改后仍需考虑 force 和数据持久化

最后,需要牢牢记住三个不同层次:

应用接受数据
    ≠
内核接受数据
    ≠
存储设备持久化数据

可以用一句话概括整篇文章:

Buffer 解决系统调用粒度,Page Cache 解决设备 I/O 性能,fsync 与 force 解决持久化时机,而真正的数据可靠性还需要应用层协议、崩溃恢复和副本机制共同保证。

0

评论区