1. 为什么需要虚拟内存
1.1 三个原始动机
早期程序直接操作物理地址,带来三类问题:
- 地址冲突:两个程序都想用物理地址 0x1000,无法共存。
- 缺乏保护:一个程序写越界会破坏另一个程序甚至内核。
- 内存不足:程序总大小超过物理内存就无法运行。
虚拟内存用一个中间层解决全部问题:每个进程看到独立的、连续的虚拟地址空间,由 MMU 在运行时翻译成实际物理地址。
1.2 虚拟内存带来的能力
- 隔离:进程 A 无法寻址进程 B 的物理页,除非显式共享。
- 超额分配:虚拟地址空间可以远大于物理内存,靠磁盘换页支撑。
- 简化链接与加载:每个程序都从固定虚拟地址(如 0x400000)开始,链接器不用关心实际落点。
- 共享:多个进程把同一物理页映射到各自地址空间,实现共享库与共享内存。
2. 地址空间与地址转换
2.1 虚拟地址的组成
在分页机制下,虚拟地址被切成两部分:
虚拟地址 VA = [ 虚拟页号 VPN | 页内偏移 Offset ]
↑ 查页表 ↑ 直接透传
物理地址 PA = [ 物理页框号 PFN | 页内偏移 Offset ]
关键性质:页内偏移不变,只有页号被翻译。页大小通常 4KB(x86-64 也支持 2MB/1GB 大页)。
2.2 MMU 的角色
MMU(内存管理单元)是 CPU 内部的硬件,负责:
- 收到 CPU 发出的虚拟地址。
- 查页表(经 TLB 加速)得到物理页框号。
- 拼接偏移得到物理地址。
- 检查权限位,越权则触发异常。
CPU ──VA──▶ MMU ──PA──▶ 内存
│
└─ 查 TLB → 命中直接翻译
TLB 未命中 → 查页表(可能多次访存)
页表项无效 → 触发缺页异常
3. 分页机制
3.1 分页 vs 分段
| 维度 | 分页 | 分段 |
|---|---|---|
| 划分依据 | 固定大小 | 逻辑单元(代码段/数据段) |
| 碎片类型 | 内部碎片 | 外部碎片 |
| 地址 | 一维 | 二维(段号 + 偏移) |
| 现代使用 | 主流 | x86 保留但多被扁平化 |
x86-64 实际上把分段「关掉」了(所有段基址为 0),完全依赖分页。
3.2 页表结构
页表是从虚拟页号到物理页框号的映射表,每个进程一张。最简单的单级页表:
虚拟页号: 0 1 2 3 ...
页表项: [PFN=5][无效][PFN=9][PFN=2] ...
单级页表的问题:32 位地址、4KB 页时页表有 2^20 项,每项 4 字节,一个进程就要 4MB 页表,且大部分是空的。64 位地址下完全不可行。
4. 多级页表与 TLB
4.1 多级页表
把页号再切分成若干级索引,只为用到的区域分配子表:
x86-64 四级页表(4KB 页)
VA: [47:39] [38:30] [29:21] [20:12] [11:0]
PGD PUD PMD PTE Offset
9 位 9 位 9 位 9 位 12 位
一次地址翻译需要最多 4 次内存访问(外加最后一次取数据,共 5 次)。这显然太慢——TLB 就是为此而生。
4.2 TLB
TLB(Translation Lookaside Buffer)是页表项的高速缓存,通常几十到几百项,全相联或组相联。
- 命中:1 个周期完成翻译。
- 未命中:走页表遍历(page walk),硬件自动完成(x86)或由软件处理(MIPS)。
- 命中率:得益于空间局部性,实际可达 99% 以上。
# 观察 TLB 未命中导致的性能问题
perf stat -e dTLB-load-misses,dTLB-loads ./app
4.3 大页优化
2MB 或 1GB 的大页可显著减少页表级数与 TLB 压力:
# 查看透明大页状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 显式申请大页
echo 20 > /proc/sys/vm/nr_hugepages
数据库(如 PostgreSQL、Oracle)常手动启用大页来降低 TLB miss。
5. 页表项与权限
5.1 关键标志位
| 位 | 含义 |
|---|---|
| P 存在位 | 页是否在物理内存中 |
| R/W 读写位 | 只读还是可写 |
| U/S 用户位 | 用户态可否访问 |
| A 访问位 | 是否被访问过(供置换算法) |
| D 脏位 | 是否被写过(决定是否需写回磁盘) |
| NX 执行位 | 是否可执行(防代码注入) |
5.2 写时复制 COW
fork() 不复制物理内存,而是把父子进程的页表都标记为只读并共享同一物理页。任一进程写入时触发保护异常,内核此时才复制一份:
fork 前: [父] ──▶ 物理页 P (R/W)
fork 后: [父] ──▶ 物理页 P (只读) ◀── [子]
子进程写 P → 缺页/保护异常 → 内核复制 P 为 P' → 子进程指向 P' (可写)
这使 fork 从「复制整个地址空间」降为「复制页表」,是 shell 与容器高效创建进程的基石。
6. 缺页处理
6.1 完整流程
① CPU 访问 VA → MMU 查 TLB 未命中 → 查页表
② 页表项 P=0 → 触发缺页异常(Page Fault)
③ 陷入内核,保存现场,进入缺页处理程序
④ 判断缺页类型:
- 首次访问匿名页 → 分配物理页,清零
- 页被换出到 swap → 从磁盘读回
- 映射文件页 → 从文件读入(或按需置零)
- 写只读页(COW) → 复制新页
- 非法访问 → 发送 SIGSEGV,终止进程
⑤ 更新页表项,置 P=1
⑥ 返回用户态,重新执行触发缺页的那条指令
注意最后一步:异常处理完是重试原指令,不是跳过,因为指令尚未执行完成。
6.2 缺页的成本
一次主缺页(major fault)需磁盘 IO,耗时约 1~10 毫秒,相当于几百万条指令。**次要缺页(minor fault)**只需分配物理页,微秒级。
# 统计进程缺页次数
/usr/bin/time -v ./app # 看 Minor/Major page faults
ps -o min_flt,maj_flt -p <pid>
7. 页面置换算法
7.1 常见算法
- OPT(最优):淘汰未来最久不用的页,理论最优但不可实现,用作基准。
- FIFO:先进先出,简单但可能淘汰热点页,且存在 Belady 异常(页框增多缺页反而增加)。
- LRU:淘汰最久未使用,效果好但需要精确记录时间戳,硬件开销大。
- Clock(二次机会):LRU 的近似实现,用环形链表 + 访问位扫描,Linux 实际采用类似思想。
- LFU:按访问频率,易受历史累积干扰。
7.2 工作集与抖动
工作集(Working Set)是进程在某个时间窗口内实际访问的页集合。当所有进程的工作集之和超过物理内存时,系统陷入抖动(thrashing):CPU 大量时间用于换页而非计算,吞吐断崖式下降。
物理内存不足 → 缺页率飙升 → 换页 IO 饱和 → 有效计算时间骤降 → 抖动
应对:减少并发进程数、增加内存、优化访存局部性。Linux 的 OOM Killer 是最后的兜底手段。
7.3 伪代码
// Clock 算法
while true:
if page[hand].referenced == 0:
evict(page[hand]); break
else:
page[hand].referenced = 0 // 给第二次机会
hand = (hand + 1) % n
8. mmap 与共享内存
8.1 mmap 的三种用法
#include <sys/mman.h>
// 1. 匿名映射:大块内存分配,绕过 malloc 的 brk
void *p = mmap(NULL, 1 << 20, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 2. 文件映射:把文件当内存读写,由内核负责回写
int fd = open("data.bin", O_RDWR);
void *f = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 3. 共享内存:多进程映射同一文件或匿名区
// MAP_SHARED 使修改对其他映射者可见,是最高效的 IPC
8.2 为什么 mmap 高效
- 零拷贝:数据不经用户态缓冲区,直接由页缓存映射到进程地址空间。
- 按需加载:只有真正访问的页才从磁盘读入。
- 内核统一管理:回写、换出、缓存一致性都由页缓存机制处理。
工程应用:数据库索引文件、日志文件(如 Kafka 的索引)、动态库加载(ld.so 全靠 mmap)。
9. 常见陷阱
- 认为 malloc 立即分配物理内存:多数分配只是建立虚拟映射,首次触碰才真正分配(lazy allocation)。
- 忽视大页配置:TLB 密集负载未开大页会白丢性能。
- 混淆 RSS 与 VSZ:虚拟大小 VSZ 含未映射区域,实际占用看 RSS。
- 在缺页处理中睡眠过久:缺页路径应尽量快,引入复杂逻辑会放大延迟。
- 过度使用 swap:SSD 上频繁 swap 会加剧写放大并缩短寿命,服务器常设
vm.swappiness=1。 - 忘记 munmap 导致地址空间泄漏:长期运行进程反复 mmap 而不释放会耗尽 vm.max_map_count。
参考文章
- 内存管理 — 分配器与物理内存管理
- 进程与线程 — 地址空间与上下文切换
- 文件系统与 IO — 页缓存与文件映射的关系
- 缓存一致性 — TLB 与多核缓存的一致性维护
- IO 模型 — mmap 在 IO 编程中的定位
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。