在接触 Linux 内存管理时,我们反复听到虚拟内存、缺页中断、写时复制(COW)和 Page Cache 这些概念。而
mmap正是将这些零散知识点串联起来的绝佳案例。本文用一张思维导图的脉络,通俗拆解 mmap 的底层原理与使用细节,让你真正"学透"而不仅仅是"背过"。
前置知识回顾
在进入 mmap 之前,先快速过一遍背景:
- 虚拟内存:每个进程看到的是独立的、连续的地址空间,背后映射到不连续的物理页框。
- 缺页中断:当进程访问的虚拟地址没有对应物理页时,触发缺页异常,内核分配物理页并填充数据。
- COW(写时复制):多个进程共享同一物理页,一旦有进程需要写,就复制一份私有副本,保证隔离。
- Page Cache:内核用于缓存文件数据的物理内存区域,读写文件实际上优先与 Page Cache 交互。
mmap 正是建立在这一套基础设施之上的上层机制。
一、mmap 到底是什么?
mmap 在进程虚拟地址空间创建一段内存区间,和磁盘文件建立映射关系;访问这段虚拟地址时,由缺页异常按需加载文件数据到内核页缓存,进程直接操作页面,**省去传统 read/write 中内核缓冲区到用户缓冲区的数据拷贝;磁盘 IO 依然会在缺页时发生,**优势是简化数据拷贝路径,并且可以减少频繁读写场景下的系统调用**。
三个核心关键字
- 虚拟地址映射
mmap调用时,内核只是在进程的页表中建立"虚拟地址 → 文件偏移"的逻辑绑定 ,并不会立刻把文件内容读进物理内存。 - 缺页中断
当你第一次真正访问这段虚拟地址时,CPU 发现没有对应物理页,触发缺页中断。内核会分配物理内存,并从磁盘读取一页数据(通常 4KB)到物理内存的 Page Cache 中,然后更新页表。 - 按需加载
你访问哪一页,内核才加载哪一页,而不是一次性把整个文件读进来。这就实现了懒加载,节省内存与时间。
mmap vs read/write 的本质区别
传统 read() 流程:
磁盘 → 内核 Page Cache → CPU拷贝 → 用户缓冲区(用户态内存)
- 两次数据拷贝:①磁盘到 Page Cache(DMA),② Page Cache 到用户缓冲区(CPU 拷贝)。
- 每次读写都要通过
read/write系统调用,频繁切入内核态。
mmap 流程:
磁盘 → 内核 Page Cache (用户进程直接通过虚拟地址读写这块物理内存)
- 减少一次 CPU 拷贝:数据从磁盘到 Page Cache 后,用户空间直接就能访问,无需再拷贝到用户缓冲区。
- 减少系统调用 :之后读写就像操作普通内存指针,无需反复
read/write。 - 天然适合随机访问:想读文件任意偏移处的数据,直接指针偏移即可,内核只加载用到的页。
- 总结
- mmap 仅在初始化映射时执行一次 mmap 系统调用。
- 当目标文件页面已经载入 PageCache:进程通过虚拟地址访问内存,MMU 完成地址翻译,全程用户态运行,无内核切换。
- 如果页面尚未加载,访问内存触发硬件缺页异常被动进入内核加载数据;这和主动调用 read/write 系统调用不是一回事。
- read/write 模式,每一次 IO 操作都需要程序主动发起系统调用,并且额外存在一次 PageCache 到用户缓冲区的 CPU 拷贝,这也是 mmap 性能优势来源。
二、mmap 怎么用?文件映射与匿名映射
mmap 主要分为两大类,背后的载体截然不同。
1. 文件映射(绑定磁盘文件)
通过 mmap 将一个普通文件映射进虚拟地址空间,底层依赖的就是 Page Cache。也就是说,被映射的文件数据会以页为单位缓存在 Page Cache 里,进程直接读写这些页。
2. 匿名映射(MAP_ANONYMOUS)
不关联任何磁盘文件,内核直接分配空白物理页 。数据没有文件背景,不经过 Page Cache 。
glibc 中,当 malloc 申请的内存大于 128KB 时,底层就会改用匿名 mmap,而不是调整堆的 brk。
🔍 深入对比:文件映射与匿名映射的缺页和页面回收
文件映射(带 fd 普通 mmap)
mmap(fd, offset, len)
- **创建阶段:**虚拟区间逻辑绑定「磁盘文件 + 文件偏移」;没有物理内存。
- **首次访问触发缺页:**内核根据记录的磁盘位置,读取数据到 PageCache,生成文件页;虚拟页 ↔ 物理文件页。
- **页面回收:**可以直接丢弃,不需要 Swap;再次访问重新从磁盘文件加载。
匿名映射(MAP_ANONYMOUS)
mmap(NULL, len, prot, MAP_ANONYMOUS | MAP_SHARED/MAP_PRIVATE, -1, 0)
- **创建阶段:**虚拟区间没有绑定任何磁盘文件,内核只标记这是一段匿名映射区域;依旧不分配物理内存。
- **首次访问触发缺页:**内核不会去磁盘读取任何文件,直接分配一块清零的物理内存页 → 匿名页;虚拟页 ↔ 匿名页。
- **页面回收:**匿名页没有对应的磁盘原始文件!内存不足时必须把页面内容写入 Swap 分区;下次访问缺页,从 Swap 读回内存。
MAP_SHARED vs MAP_PRIVATE(重点理解 COW)
这两个标志决定了多个进程之间 以及与文件之间的交互方式。
MAP_SHARED(共享映射)
- 多个进程映射同一个文件时,它们共享同一份 Page Cache 物理页。
- 任何一个进程对内存的修改,其他进程立刻可见。
- 修改产生的脏页最终由内核异步刷回磁盘 (或调用
msync()强制同步)。
MAP_PRIVATE(私有映射 + 写时复制)
- 初始状态:同样共享底层文件的物理页(Page Cache)。
- 一旦某个进程尝试写 → 触发 写时复制(COW):内核为当前进程分配一个全新的物理页,复制原页内容,然后修改页表指向新页。后续的写入操作都在这个私有副本上进行。
- 修改不会回写到原文件,对其他进程不可见。
💡 与 fork 的联动 :
fork()创建子进程时,父子进程的私有内存也是通过 COW 共享同一物理页。这背后的硬件机制(页表项写保护位、缺页陷阱)与MAP_PRIVATE完全一致。
即:MAP_PRIVATE相当于在文件映射上实现了类似fork的 COW 行为。
三、mmap 常用在哪些场景?
1. 大文件随机访问
例如日志解析、简单的数据库存储引擎。利用 mmap 可以将随机读请求转化为直接内存访问,热数据留在 Page Cache,无需频繁 lseek + read。
2. 进程间通信(共享内存 IPC)
使用 MAP_SHARED | MAP_ANONYMOUS 创建共享内存区域,多个进程可直接读写同一块物理内存,吞吐高、延迟低。
⚠️ 但内核不提供任何同步机制,需要用户自己用互斥锁、信号量或原子操作来避免竞态。
3. 动态库加载
Linux 加载动态链接库(.so)时,底层就是用 mmap 将库文件映射到进程地址空间,且通常使用 MAP_PRIVATE (修改不写回文件)或 MAP_SHARED(代码段只读)。
4. 大块匿名内存分配
如前所述,glibc 的 malloc 对于超过 MMAP_THRESHOLD(默认 128KB)的请求直接使用匿名 mmap,避免堆碎片化并方便独立释放。
四、为什么 mmap 性能好?三点优势总结
- 减少系统调用
映射建立后,读写变成内存访问,无需反复read/write。 - 减少一次 CPU 数据拷贝
从磁盘到用户空间,只需一次 DMA 到 Page Cache,用户态可直接使用。 - 按需加载 + 页缓存复用
只有真正访问到的页才会加载,且 Page Cache 可被多个映射共享,节省物理内存。
五、注意事项
- 不要用于小文件或一次性顺序读写
mmap 本身有系统调用和页表建立的固定开销,对于很小的文件或只需要顺序读一次的场景,传统read更快。 - 文件截断风险(SIGBUS)
如果文件被外部truncate截断,而进程还在访问已映射但超出文件大小的地址,会收到 SIGBUS 信号,直接崩溃。必须通过信号处理或预先保护。 - MAP_SHARED 不是线程安全 / 进程安全的
多个进程同时写共享映射区,必须自己用锁或原子操作保证同步,否则数据会错乱。 - 缺页风暴
如果映射了超大区域,又进行完全随机的访问,可能导致大量缺页中断,性能不升反降。
六、总结
mmap 能够把磁盘文件映射至进程虚拟地址空间,将文件 I/O 转变为直接内存访问。它依靠缺页中断完成按需加载,核心收益是减少数据拷贝与系统调用开销。
该机制广泛用于大文件随机读写、进程间共享内存、动态链接库加载、大容量内存分配场景。
同时存在诸多使用限制:不适用于小规模顺序 I/O;文件截断可能触发 SIGBUS;MAP_SHARED 模式要求上层实现同步;纯粹随机访问会引发频繁缺页,造成性能损耗。
七、极易混淆的两个补充知识点
- 文件映射的缺页 vs 匿名映射的缺页
- 文件映射缺页:内核从磁盘文件读取数据到 Page Cache,然后映射给进程。
- 匿名映射缺页:内核分配一个空白物理页(通常全零填充),物理内存紧张时会被换出到 Swap 分区。
- 文件映射页回收与 Swap 无关
当内存紧张需要回收 Page Cache 中的文件映射页时,如果页是干净的(未修改),直接丢弃;如果是脏页,先写回文件再释放。
它绝不会被换出到 Swap 。这是因为文件映射的数据本身就有文件作为后备存储,缺页时重新从文件读取即可。
而匿名页(无文件背景)没有后备存储,只能暂存到 Swap。