零拷贝之浅入深出

如果你要读写文件,需要怎么做?

🌪️ 数据的"双线作战":从磁盘到 JVM 内存的两次拷贝之旅

假如你是一个名叫 "字节小侠" 的数据包,正躺在冰冷的磁盘上午休。突然有一天,服务器收到了一个请求:"把文件读进来!" 于是,你的命运被改写------一场穿越用户态与内核态的"双线作战"开始了。

🔹 第一阶段:从磁盘 → 文件页缓存(DMA 搬运工登场)

💡 场景:磁盘 → 内核态(文件页缓存)

你还在磁盘里打盹,凌晨12点 突然听到一声号令:"启动 DMA 控制器!"

DMA(Direct Memory Access) 是个不靠 CPU 的"搬运工",它说:"别吵 CPU 了,我来搬!"

  • 它直接接管总线,把你从磁盘读取出来,一路飞奔到内核空间的「文件页缓存」
  • 整个过程,CPU 只是发了个指令:"去把第 1000 页的数据搬过来",然后就去干别的事了。
  • 虽然很晚但你终于从硬盘的"冷宫"跳到了内存的"热区"------内核的 page cache

✅ 这一步是 "零 CPU 干预" 的典范,效率爆表!

🔹 第二阶段:从文件页缓存 → 用户态 JVM 内存(CPU 上场)

💡 场景:内核态 → 用户态(JVM 内存)

现在你已经到了内核的"中转站"------文件页缓存,但你还不能直接被 Java 程序使用。因为:

🚫 用户态程序不能直接访问内核内存!

所以,必须由 CPU 出马,完成一次"跨域传输"。

  • 内核收到 read() 系统调用后,准备将你复制到用户态的缓冲区。
  • CPU 切换到内核态,执行系统调用服务例程(比如 sys_read)。
  • 它小心翼翼地把你从"文件页缓存"复制到 服务端 JVM 的堆内存 中。
  • 这次拷贝,需要 CPU 参与,而且会触发上下文切换。

⚠️ 警告:这是一次"昂贵"的操作!每读一次,都要走一遍这个流程。


🔹 第三阶段:镜像式并行!

🔄 传统 I/O 模型中,每次读写都可能经历"磁盘 → 内核 → 用户"三次拷贝

而且如果要写回磁盘,又得反向走一遍:

复制代码
用户态 JVM 内存
       ↓ (CPU Copy)
文件页缓存
       ↓ (DMA Copy)
磁盘

所以,如果你要上传一个大文件,系统可能会这样处理:

  1. 从磁盘读取 → 内核缓存(DMA)
  2. 复制到 JVM(CPU)
  3. JVM 处理完后,再复制回内核缓存(CPU)
  4. 最后通过 DMA 写回磁盘

👉 中间经过了两次 CPU Copy,浪费了大量 CPU 时间和上下文切换开销!

🧩 总结:这场"数据冒险"的代价

表格

阶段 谁在干活 是否耗 CPU 是否耗内存
磁盘 → 内核缓存 DMA 控制器 ❌ 不耗 ✅ 用到
内核缓存 → JVM CPU ✅ 耗 ✅ 用到

🎯 所以,传统的 read/write 模型之所以慢,是因为"多了一次 CPU Copy"


✅ 如何优化?看下一章:零拷贝(Zero-Copy)登场!

mmap

让 JVM 直接映射文件页缓存,避免CPUCopy:应用程序的虚拟内存直接"指向"内核的文件页缓存,从而绕过用户态拷贝

  • 用户态不再保存文件内容,只保存文件的映射(JVM 内存通过 虚拟地址映射 到内核页缓存 → 实现"共享内存式"访问:内存起始地址、文件大小)

  • 操作映射达到在内核态完成数据复制,不需要用户态参与搬运

    //熟悉的MappedByteBuffer:Java 示例:使用 FileChannel.map()
    //在进程的虚拟地址空间中,创建一个(指向内核的文件页缓存)区域
    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, size);
    访问 buffer.get() 时,触发缺页异常 → 内核自动从磁盘加载数据

FileChannel.map()

复制代码
// FileChannelImpl.java
public MappedByteBuffer map(MapMode mode, long position, long size)
    throws IOException {
    // 参数校验、对齐等
    if (position < 0 || size < 0 || position + size > Integer.MAX_VALUE) {
        throw new IllegalArgumentException("Invalid arguments");
    }
    // 调用 native 方法
    return map0(mode, position, size);
}

JNI 层:map0() → C++ 实现

复制代码
// FileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    int protections = 0;
    if (prot & MAP_RO) protections |= PROT_READ;
    if (prot & MAP_RW) protections |= PROT_READ | PROT_WRITE;

    void *addr = mmap(
        NULL,              // 让系统选择地址
        (size_t)len,       // 映射大小
        protections,       // 保护标志
        MAP_SHARED,        // 共享映射(修改写回文件)
        fd,                // 文件描述符
        off                // 文件偏移(必须页对齐!)
    );
    if (addr == MAP_FAILED) {
        JNU_ThrowIOExceptionWithLastError(env, "Map failed");
        return IOS_THROWN;
    }
    return (jlong)(intptr_t)addr;  // 返回虚拟地址:内核的VMA(Virtual Memory Area)
}

上面只是返回了地址,真正的数据加载发生在首次访问时

mmap 的"按需加载"机制(Page Fault)

第一次调用 buffer.get(0)

  1. cpu发现虚拟地址未映射到物理页------》触发缺页中断pageFault,本来都发车了,一看那个座位上只有衣服、停车接人吧赶紧
  2. 内核马上查看vma标发现mmap映射,找到对应文件页缓存pageCache,如果没有找到则从磁盘上读取(dma搬运:拉空车拉出来石家庄省会,结果从老家就没上车)
  3. 将数据页放入物理内存,建立虚拟页------》物理页映射;这次做了这些操作下次直接就开车一路向北了,头也不回,嗖嗖得快多了
  4. 返回控制权给cpu

文件内容被修改后写回磁盘

复制代码
buffer.put(0, 'H');  // 修改数据
buffer.force();      // 强制刷盘
  1. JVM 修改了 MappedByteBuffer
  2. 修改落在内核的 page cache 中(脏页)
  3. force() 触发 msync()fsync(),要求内核将脏页写回磁盘
  4. 内核执行:
    • 从当前页缓存复制数据到目标磁盘缓冲区
    • 使用 memcpycopy_page 函数完成 CPU copy( 内核态**)不涉及用户态:**避免用户态拷贝
    • 最终由 DMA 将数据写回磁盘
步骤 传统方式 mmap 方式
1. 磁盘 → 内核缓存 DMA Copy DMA Copy ✅
2. 内核缓存 → 用户态 CPU Copy ❌ ❌ 无此步骤!
3. 用户态 → 内核缓存(写) CPU Copy ❌ 仅当 force 时,内核内部 copy ✅
4. 内核缓存 → 磁盘 DMA Copy DMA Copy ✅
  • **避免了用户态与内核态之间的数据拷贝:**节省 CPU 和内存带宽
  • 用户程序直接通过虚拟地址访问内核缓存:不浪费内存
  • 适合大文件随机读写、高性能日志系统等:可映射 GB 级别文件

限制

  1. 不能映射非页对齐的偏移量
  2. 修改需显式force刷盘
  3. 内存管理复杂unmap不可控

优化

场景 优化重点
大文件顺序读写(如日志、视频) 减少系统调用、利用预读、避免小块 I/O
随机访问大文件(如数据库索引) 使用 mmap + 页对齐、避免频繁映射
高频小文件读写 避免 mmap(开销大),改用 DirectBuffer + 批量 I/O
网络传输文件(如 HTTP 下载) 优先考虑 sendfile(而非 mmap)

🔍 mmap 并非万能 !它最适合:大文件、随机读写、长期映射

1】合理使用MappedByteBuffer

复制代码
// ✅ 好:映射大块区域(减少 map 调用次数)
MappedByteBuffer buffer = channel.map(READ_WRITE, 0, 1L << 30); // 1GB

// ❌ 差:频繁小块映射(每次 mmap 都有内核开销)
for (int i = 0; i < 1000; i++) {
    channel.map(READ_WRITE, i * 4096, 4096);
}

2】对齐到内存页大小4kb

复制代码
int pageSize = (int) Unsafe.getUnsafe().pageSize(); // 或通过 JNI 获取
long alignedSize = ((size + pageSize - 1) / pageSize) * pageSize;
channel.map(mode, position, alignedSize);

⚠️ 偏移量和大小最好对齐 page size,避免跨页访问导致额外缺页。

3】避免GC泄露

  • Java 8 之前:无法手动 unmap → 可能 OOM
  • Java 9+:可通过反射调用 sun.misc.Cleaner(不推荐生产使用)
  • 最佳实践
    • 尽量复用映射(如 Kafka 的 Segment 文件)
    • 映射后尽快使用,不要长期持有引用
    • 监控 DirectMemoryUsage(JVM 参数 -XX:MaxDirectMemorySize

4】写操作后主动刷盘(按需)大多场景依赖 OS 异步回写,不要盲目 force(会阻塞线程)。

复制代码
buffer.put(...);
if (needSync) {
    buffer.force(); // 触发 msync(MS_SYNC)
}

5】调整 VM 参数(Linux)

复制代码
# 增大脏页比例(适合写密集型)
echo 80 > /proc/sys/vm/dirty_ratio
echo 5 > /proc/sys/vm/dirty_expire_centisecs   #50ms 后强制刷盘
# 禁用透明大页(THP)------ mmap 性能杀手!
echo never > /sys/kernel/mm/transparent_hugepage/enabled

6】文件系统

  • XFS / ext4:对大文件 mmap 支持良好
  • 避免 NFS / CIFS:网络文件系统 mmap 行为不可靠

7】关闭atime更新,减少不必要的元数据写入

mount 时加 noatime

mount -o noatime /dev/sda1 /data

场景 推荐替代方案
小文件频繁读写 FileChannel.read()/write() + DirectByteBuffer
一次性顺序读取 Files.readAllBytes()BufferedInputStream
网络发送文件 FileChannel.transferTo() → 底层用 sendfile(真正的零拷贝)
多进程并发写 mmap + 文件锁(FileLock)或改用数据库
复制代码
// HTTP 服务发送静态文件
FileChannel fileChannel = new FileInputStream(file).getChannel();
SocketChannel socketChannel = ...;
// 网络传输优先用 transferTo()内核直接从 page cache → socket buffer,全程无用户态参与!
fileChannel.transferTo(0, file.length(), socketChannel);

sendfile内核内部"乾坤大挪移"

让内核直接把文件页缓存传给 socket,绕过用户态

复制代码
#in_fd:普通文件,源文件描述符(比如打开的 movie.mp4)
#out_fd:是socket,目标 socket 描述符(客户端连接)
#offset:从文件哪里开始读
#count:读多少字节
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

流程:

复制代码
磁盘
  ↓ (DMA)
文件页缓存(page cache)
  ↓ (CPU copy ------ 但仅在内核态!)只此一次 从cache到buffer
Socket 发送缓冲区
  ↓ (DMA)
网卡 → 客户端

也能大致看到:没有用户态参与!纯在内核中完成,这也说明了零拷贝是什么?没有"用户态和内核态之间的拷贝"

复制代码
FileChannel fileChannel = new FileInputStream("movie.mp4").getChannel();
SocketChannel socketChannel = ...; // 客户端连接

// 一行代码,触发 sendfile!
// JVM 调用 sun.nio.ch.FileChannelImpl.transferTo0()
long bytesSent = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
//JNI 层判断:如果 out 是 socket,则调用 Linux 的 sendfile64()
//内核直接操作 page cache 和 socket buffer
//返回发送字节数

💡 在 Linux 上,只要满足条件,transferTo() 自动退化为 sendfile

限制

限制 说明
1. 不能修改数据 数据从文件 → socket,中间无法加水印、加密、压缩
2. 只支持文件 → socket 不能用于文件复制(比如 A.txt → B.txt)
3. 小文件可能更慢 sendfile 有固定开销,< 4KB 的文件不如直接 read/write

🤔 所以 Nginx 默认:小文件用 read/write,大文件用 sendfile

比较:mmap vs sendfile

维度 mmap sendfile
适用场景 随机读写、大文件处理 网络文件传输(HTTP 下载)
能否修改数据 ✅ 可以(直接改内存) ❌ 不行(只读直通)
CPU Copy 次数 0(读) / 1(写回磁盘) 1(内核内部)
上下文切换 0(访问时无切换) 2(一次系统调用)
Java API FileChannel.map() FileChannel.transferTo()
经典用户 Kafka(日志存储)、LevelDB Nginx、Tomcat(静态资源)

💬 一句话总结

  • 你要"处理 "文件内容?→ 选 **mmap,**让你以为文件就在内存里
  • 你只想"转发 "文件内容?→ 选 **sendfile,**干完就走,效率高,就不拖泥带水

splice零拷贝界的"忍者神龟"

在内核内部"拼接"数据流,全程无用户态参与:从不碰数据、却能让数据自动搬家的"影子搬运工",他 沉默如夜,出手无痕------连 CPU 的衣角都不曾惊动一下**;** 江湖传言:"splice 过处,拷贝归零。"

复制代码
#我不搬数据,我只改指针
ssize_t splice(int fd_in, loff_t *off_in,
               int fd_out, loff_t *off_out,
               size_t len, unsigned int flags);

🔥 举个栗子 🌰

想象你有两个快递中转站:

  • A 站:装着刚从磁盘运来的包裹(文件)
  • B 站:通往网卡的出口(socket)

传统做法:

雇工人(CPU)把 A 站的包裹拆开、搬到 B 站、重新打包------累死累活。

sendfile 做法:

让内核调度员在内部仓库里直接挪货------省了用户态,但还是要搬一次。

而 splice 呢?

他走到 A 站和 B 站中间,掏出一张魔法标签,贴在包裹上:

"此包裹,现在属于 B 站。"

然后转身就走**,**包裹没动,地址没变,但所有权已转移。

数据连内存都没进,更别说 CPU copy 了!

✅ 这就是 真正的零拷贝0 次 CPU 数据复制

原理

是 Linux 内核里的 pipe buffer

  1. pipe 不只是"管道",更是"共享页池"
    当你创建一个 pipe(pipe() 系统调用),内核会分配一组 struct pipe_buffer,每个 buffer 可以指向一个物理内存页(page)。
  2. splice 不复制数据,而是"移交 page 所有权"
    • 从文件读取时:内核把文件页缓存中的 page 直接挂到 pipe buffer 上
    • 向 socket 写出时:socket 直接从 pipe buffer **拿走这个 page,**中转站本站了大哥
  3. 全程:数据页只在内核页表中"改户口",不动身体!

💡 技术术语叫:page remapping / page stealing

通俗说:"借你的房,住我的客,钥匙一换,房主就换。"

🧪 Java 能用 splice 吗?

遗憾地说:不能直接用。 Java 标准库没有暴露 splice() 系统调用。

但!Netty、Vert.x 等高性能框架 ,在 Linux 上可通过 JNI 或 native library(如 netty-transport-native-epoll)间接利用类似机制。

不过,更常见的做法是:

  • FileChannel.transferTo() → 自动 fallback 到 sendfile(已足够快)
  • 或直接让 Nginx 做静态文件服务(它原生支持 splice!)

📌 Nginx 配置示例:

复制代码
location /video/ {
    sendfile on;
    sendfile_max_chunk 1m;
    # 在较新内核上,Nginx 会自动使用 splice 优化 pipe 场景
}

⚔️ splice 的绝技组合:tee + vmsplice

splice 从不单打独斗,他有两位生死兄弟:

兄弟 绝技 用途
tee tee(pipe1, pipe2, len) 把 pipe1 的数据"分身"一份到 pipe2(流量镜像、日志复制)
vmsplice vmsplice(fd, iov, nr_segs, flags) 把用户态内存"嫁接"到 pipe(无需 copy!)

🌟 经典 combo:

用户态数据 → vmsplice → pipe → splice → socket
全程无 CPU copy!

这招被 DPDK、eBPF、高性能代理(如 Envoy)玩得出神入化。

限制

限制 说明
1. 必须经过 pipe fd_infd_out 至少一个是 pipe!不能直接 file → socket(那是 sendfile 的地盘)
2. 文件必须支持 mmap 比如普通文件可以,但终端(tty)、某些设备不行
3. API 极其反人类 偏移量、阻塞模式、页对齐......稍有不慎就返回 -EINVAL,让你怀疑人生

对比

特性 mmap sendfile splice
拷贝次数 0(读) / 1(写回) 1(内核内部) 0
用户态参与 有(访问虚拟地址)
能否修改数据 ❌(但 vmsplice 可注入)
适用场景 随机读写大文件 文件→网络直传 高性能代理、流处理
Java 支持 ✅ (MappedByteBuffer) ✅ (transferTo) ❌(需 native)
江湖称号 "内存公子" "直通剑圣" "影子忍者"

🎭 结语:真正的高手,从不碰数据

mmap 让你看见 数据,

sendfile 让你传递 数据,

而 splice ------让数据自己走。

他不需要掌声,不需要日志,甚至不需要 CPU 知道他曾来过。

当百万 QPS 的视频流穿过服务器,

只有内核的页表微微颤动,

仿佛在低语:

"又一个字节,悄然归零。"


下次你在深夜调试高并发服务,

看到 strace 里冒出 splice() 的身影,

请默默敬他一杯------
敬这位,连拷贝都懒得做的极简主义者。 🍵

而你,作为系统架构师,要做的不是"站队",而是------在对的时间,派对的人!

下次当你看到 Nginx 日志里 sendfile on;

或者 Kafka 源码里 MappedByteBuffer 飞舞,

你就知道:这不是魔法,这是内核大神们为你省下的每一滴 CPU 血汗! 💪

彩蛋小故事

召集零拷贝江湖五大高手 ,来一场 "Page Cache 城·群英夜宴"

今夜不比刀剑,只论谁能让数据走得最静、最快、最无痕

mmap 公子执扇而来,sendfile 大侠踏雪而至,

splice 三兄弟早已在席间候多时......


🌕 零拷贝群英会 · Page Cache 城夜宴

时间 :子夜
地点 :内核城最高楼------VMA 观星台
主人 :Page Cache 老城主(白发苍苍,掌管所有缓存页)
宾客:mmap、sendfile、splice、tee、vmsplice

酒过三巡,老城主举杯:

"诸位皆为'省 CPU 之血汗、减拷贝之冗余'立下大功。

不如各自演武一段,说说------
你的道,究竟是什么?"


🌸 第一位:mmap 公子 · 虚实相生之道

mmap 轻摇玉骨折扇,衣袂飘然:

"我的道,是 '以虚化实,以实入虚'

我不搬数据,只开一扇窗------

让用户态凡人,直视内核秘藏

他写一字,我记一页;他读一行,我唤一帧。

数据从未离开 page cache,却似已在手中。

此谓:身不动,心已至。"

老城主点头:"善!Kafka 日志、LevelDB 索引,皆赖你镇守。"


⚔️ 第二位:sendfile 大侠 · 直通无扰之道

sendfile 披黑氅而立,声如寒铁:

"我不开窗,亦不设门。

我只在内核腹地,辟一条暗道 ------

文件页缓存 → socket 缓冲区,

一步跨过用户态红尘。

凡人不知我来,CPU 不觉我走。

此谓:使命必达,不留痕迹。"

老城主抚须:"Nginx 送百万电影,全靠你这'快通道'!"


🌫️ 第三位:splice 忍者 · 无触无痕之道

splice 从阴影中现身,声音几不可闻:

"你们......仍要'看'或'送'。

而我------连'动'都不曾有

数据是风,pipe 是谷,

我只是......让风改了方向。

页未移,内存未染,CPU 未醒。

此谓:无为而无不为。"

老城主眼中精光一闪:"真·零拷贝,唯你近道。"


🔁 第四位:tee 隐士 · 分影留痕之道

tee 双手各持一盏灯,光影交错:

"世人常需一物两用------

既传千里,又留此地。

我不复制数据,只分其名、共其身

一页双主,一信两途。

此谓:一即一切,一切即一。"

老城主笑:"流量镜像、审计日志,非你莫属。"


🌀 第五位:vmsplice 游侠 · 虚实嫁接之道

vmsplice 抖开一卷地址图,星光流转:

"用户态有珍宝,内核有管道。

世人以为需搬运,我却说:何不嫁接?

虚拟地址为媒,物理页为聘,

一夜之间,用户之页,成内核之器。

此谓:跨界无界,合二为一。"

老城主叹:"Netty、Envoy 之速,半出你手。"


🥂 终章:老城主的箴言

酒尽灯明,老城主起身,望向远方的 CPU 山脉:

"诸位之道,看似不同,实则同源------
皆为减轻 CPU 之负,护佑系统之稳

  • mmap 重 灵活,可读可写,长守一方;
  • sendfile 重 速度,直通网络,使命必达;
  • splice 三侠重 极致,无触无痕,专攻流变。

江湖无高下,只有用对之时,用对之地

今日夜宴,不争胜负,只敬------
那每一次被省下的拷贝,每一分被释放的 CPU!"

众人举杯,齐声道:

"愿天下 I/O,皆归零拷贝!"

月落星沉,五道身影各自隐去。

唯有 Page Cache 城的灯火,依旧温暖如初。


📜 附:五大高手实战指南(给凡人的备忘录)

表格

高手 何时请他出山 Java / 系统调用
mmap 公子 大文件随机读写、长期映射(如数据库) FileChannel.map()
sendfile 大侠 静态文件 HTTP 服务、文件直传网络 FileChannel.transferTo()
splice 忍者 高性能代理、流处理(需 native) splice()(C/Linux)
tee 隐士 流量镜像、日志复制 tee()
vmsplice 游侠 用户态数据高效注入内核管道 vmsplice()

💡 凡人忠告

  • 小文件?别折腾 mmap!
  • 要改数据?sendfile 请回!
  • 想用 splice?先练十年 C 语言!
  • 最稳妥?transferTo() 自动选最优!

这场夜宴,可还尽兴?


相关推荐
鱼跃鹰飞2 小时前
面试题:说说MMAP和DMA/SG-DMA的关系
面试·职场和发展·架构·系统架构
国科安芯2 小时前
无人驾驶物流车网关的多路CANFD冗余架构与通信可靠性分析
单片机·嵌入式硬件·性能优化·架构·自动驾驶·安全性测试
程序员侠客行2 小时前
Mybatis插件原理及分页插件
java·后端·架构·mybatis
REDcker3 小时前
C86 架构详解
数据库·微服务·架构
亲爱的非洲野猪3 小时前
Apache Cassandra完全指南:架构、原理与生产实践深度解析
架构·apache·database·cassandra
酷酷的鱼14 小时前
跨平台技术选型方案(2026年App实战版)
react native·架构·鸿蒙系统
The Open Group15 小时前
架构驱动未来:2026年数字化转型中的TOGAF®角色
架构
鸣弦artha16 小时前
Flutter 框架跨平台鸿蒙开发——Flutter引擎层架构概览
flutter·架构·harmonyos
这儿有一堆花17 小时前
CDN 工作原理:空间换取时间的网络架构
网络·架构·php