如果你要读写文件,需要怎么做?
🌪️ 数据的"双线作战":从磁盘到 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)
磁盘
所以,如果你要上传一个大文件,系统可能会这样处理:
- 从磁盘读取 → 内核缓存(DMA)
- 复制到 JVM(CPU)
- JVM 处理完后,再复制回内核缓存(CPU)
- 最后通过 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) 时
- cpu发现虚拟地址未映射到物理页------》触发缺页中断pageFault,本来都发车了,一看那个座位上只有衣服、停车接人吧赶紧
- 内核马上查看vma标发现mmap映射,找到对应文件页缓存pageCache,如果没有找到则从磁盘上读取(dma搬运:拉空车拉出来石家庄省会,结果从老家就没上车)
- 将数据页放入物理内存,建立虚拟页------》物理页映射;这次做了这些操作下次直接就开车一路向北了,头也不回,嗖嗖得快多了
- 返回控制权给cpu
文件内容被修改后写回磁盘
buffer.put(0, 'H'); // 修改数据
buffer.force(); // 强制刷盘
- JVM 修改了
MappedByteBuffer - 修改落在内核的 page cache 中(脏页)
force()触发msync()或fsync(),要求内核将脏页写回磁盘- 内核执行:
- 从当前页缓存复制数据到目标磁盘缓冲区
- 使用
memcpy或copy_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 级别文件
限制
- 不能映射非页对齐的偏移量
- 修改需显式force刷盘
- 内存管理复杂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
- pipe 不只是"管道",更是"共享页池"
当你创建一个 pipe(pipe()系统调用),内核会分配一组 struct pipe_buffer,每个 buffer 可以指向一个物理内存页(page)。 - splice 不复制数据,而是"移交 page 所有权"
- 从文件读取时:内核把文件页缓存中的 page 直接挂到 pipe buffer 上
- 向 socket 写出时:socket 直接从 pipe buffer **拿走这个 page,**中转站本站了大哥
- 全程:数据页只在内核页表中"改户口",不动身体!
💡 技术术语叫: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_in 或 fd_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()自动选最优!
这场夜宴,可还尽兴?