虚拟内存让每个进程都以为自己独占一片连续内存,物理页实际可以分散、共享、甚至暂时不存在。本文走完一条完整链路:MMU 如何逐级查页表把虚拟地址翻译成物理地址,缺页异常如何按需建立映射,fork 为什么靠写时复制瞬间返回,mmap 能把什么搬进地址空间,以及内存耗尽时 OOM Killer 按什么逻辑挑进程。

它解决什么问题

程序想要的是连续、独占、足够大的内存;硬件只有有限且被所有进程共享的物理内存。没有中间层时,程序地址写死、互相踩踏、物理内存用满即崩。虚拟内存用一层地址翻译调和这对矛盾:进程发出的地址不必对应真实位置,隔离、保护、按需分配、共享映射等能力全部从这层翻译生长出来。

核心原理:一次地址翻译的完整路径

虚拟地址由虚拟页号和页内偏移组成,翻译时偏移原样保留,页号负责查表。以 x86-64 四级分页为例,48 位虚拟地址按 9+9+9+9+12 位拆分:CR3 寄存器锚定顶级页表 PGD 的物理地址,MMU 依次用各段 9 位索引查 PGD、PUD、PMD,最终在 PTE 拿到物理页帧号,拼上 12 位偏移得到物理地址。每级 512 项、每项 8 字节,一张表恰好占满一个 4 KB 页。

页表项不只存页帧号,还带 present、读写、用户/内核、可执行、访问位、脏位等标志。翻译过程同步完成权限检查:用户态碰内核页、往只读页写,都会在这一步被拦下并触发异常。

四级查表意味着一次访存背后最多藏着四次访存,TLB 是化解之道:它缓存最近的翻译结果,命中即跳过页表遍历。切换进程后旧映射失效,PCID/ASID 机制给 TLB 项打上地址空间标签,避免每次切换都全量刷掉。

VA: [PGD idx | PUD idx | PMD idx | PTE idx | offset]
9bit 9bit 9bit 9bit 12bit
CR3 -> PGD -> PUD -> PMD -> PTE -> 物理页帧 + offset = PA
(TLB 命中时直接给出物理页帧,跳过逐级查表)

深入机制

缺页异常的处理流程

PTE 的 present 位为 0,或权限检查不通过,CPU 便触发缺页异常进入内核。处理顺序:

  1. 内核检查目标地址是否落在合法 VMA(虚拟内存区域)内、访问类型是否被允许;野指针、越权写这类非法访问,最终给进程发 SIGSEGV。
  2. 合法则按映射类型建立页面:匿名页首次访问时分配零页,文件映射从 Page Cache 或磁盘读入,被换出的页从 Swap 调回。
  3. 没有空闲物理页时先回收:干净文件页直接丢弃,脏文件页先写回,匿名页写入 Swap。
  4. 更新页表与 TLB,返回用户态重新执行刚才那条指令——这次就能正常访问了。

不需要磁盘 I/O 的算次缺页(minor fault),要读盘的算主缺页(major fault),后者慢几个数量级,线上排查时 majflt 飙升远比 minflt 值得警惕。

写时复制:fork 为什么快

传统 fork 要复制整个地址空间,代价巨大。写时复制(COW)让父子进程暂时共享同一批物理页:两边页表项统一标为只读,fork 本身只复制页表等元数据,几乎瞬间返回。此后任一方写某页,触发缺页,内核复制这一页并解除共享,双方各拿到可写副本。

这把复制成本从 fork 时刻推迟并摊薄到真实写入上;shell 里 fork 紧跟 exec 的场景,甚至连复制都完全省掉。代价同样真实:Redis 做 RDB 快照时 fork 子进程,父进程写入越多被复制的页越多,内存峰值可能接近翻倍。

mmap 的机制与用途

mmap 在进程地址空间登记一段 VMA 并关联到文件(或匿名内存)。调用本身不搬数据,首次访问某页才经缺页按需加载——这正是按需调页的体现。主要用途有三类:

  • 文件按内存访问:读写文件变成读写地址,省掉 read/write 那次内核到用户缓冲的拷贝;MAP_SHARED 的修改对其他映射者可见并可回写文件,MAP_PRIVATE 建私有 COW 映射,改动不外传。
  • 共享内存 IPC:多个进程映射同一对象,天然共享物理页,是最快的进程间通信路径之一。
  • 匿名映射:glibc 的 malloc 对大块申请走匿名 mmap,释放时能真正还给内核。

mmap 并非免费:建拆映射、处理缺页都有固定成本,小文件随机访问未必赢过 read;映射的文件被其他进程截断后再访问,会收到 SIGBUS。

OOM Killer 的选择逻辑

Linux 默认允许 overcommit:malloc 成功只代表地址空间与 commit 检查通过,物理页未必就绪。当回收缓存、写回脏页、Swap 换出全部榨干仍不够用时,内核触发 OOM Killer,必须杀进程腾地方。

选择围绕 oom_score 打分:主要按进程占用内存(RSS、页表、Swap 等)占可用内存的比例计分,分高者更易被杀——思路是”杀一个大的解决问题”,而不是乱杀一片。管理员可用 /proc/<pid>/oom_score_adj(-1000 到 1000)人工干预:调负保护 sshd、监控 agent 等关键进程,调正优先牺牲不重要的任务;设为 -1000 则豁免。容器场景还会先触发 cgroup 级 OOM:宿主机内存充裕,某个容器撞上 memory.max 也会被局部制裁。

工程实践

  • 排查先分 fault 类型:minflt 高增长多半无害,majflt 飙升才说明在真实读盘;配合 vmstat 的 si/so 与 PSI 判断是否抖动。
  • fork 型服务留足余量:Redis 等依赖 fork+COW 做快照的服务,写高峰内存余量要按”可能复制近一倍”规划。
  • 给关键服务调 oom_score_adj:避免 OOM 时误杀系统组件,批处理类任务可以调高。
  • RSS 高不等于泄漏:先用 /proc/<pid>/smaps_rollup 确认内存落在匿名页、文件页还是共享映射,再定排查方向。

对比与权衡

机制 解决的问题 关键收益 主要代价
多级页表 + TLB VA→PA 翻译 稀疏空间省内存、翻译快 页表遍历与 TLB 失效开销
缺页异常 按需调页 启动快、内存按需占用 主缺页读盘,延迟毛刺
COW fork / 私有映射 共享期间零复制 写入时复制,峰值难预估
mmap 文件即内存 少一次拷贝、可共享 缺页成本、SIGBUS 风险
OOM Killer 内存耗尽兜底 牺牲少数保全系统 可能误杀,需 score 调节

面试视角

虚拟地址到物理地址怎么走?

MMU 以 CR3 找到顶级页表,用虚拟地址各段索引逐级查 PGD/PUD/PMD/PTE,拿到物理页帧号后拼上页内偏移;每级同时校验权限。TLB 命中则直接跳过查表。

缺页异常的处理流程?

先查 VMA 判断访问是否合法:非法发 SIGSEGV;合法则按映射类型处理——分配零页、读文件页或从 Swap 换入,必要时先回收旧页,最后更新页表并重试指令。不读盘是 minor fault,读盘是 major fault。

fork 为什么快?

靠写时复制:fork 只复制页表等元数据,父子共享全部物理页并标为只读;谁先写谁触发缺页,内核只复制被写的那一页,成本被推迟并摊薄到真实写入上。

mmap 和 read 读文件差在哪?

read 要把数据从内核缓冲拷进用户缓冲;mmap 建立映射后,用户态直接访问 Page Cache 的同一批物理页,省一次 CPU 拷贝,还能跨进程共享。但 mmap 按页触发缺页,小文件或一次性顺序读未必占优。

OOM Killer 怎么选进程?

按 oom_score 排序,主要依据进程内存占用占可用内存的比例,倾向”杀一个大的解决问题”;可用 oom_score_adj 人工干预,-1000 表示豁免。容器中可能先触发 cgroup 级 OOM。

小结

  • 地址翻译 = 逐级页表 + 权限检查 + TLB 加速,CR3 锚定整个地址空间。
  • 缺页异常是按需调页、COW、mmap 共同的底层引擎,minor 与 major 的代价天差地别。
  • COW 让 fork 从全量复制变为按写复制,mmap 让文件进入统一的地址抽象。
  • OOM Killer 是 overcommit 的最后兜底,理解 oom_score 才能避免关键进程被误杀。