虚拟内存 入门

curl 进程碰的每个指针都是虚拟地址。硬件每次访问都 把它翻译成物理地址 —— 每秒几十亿次。5 个 section 把画面搭出来:为啥需要虚拟内存(它解的 3 个问题);页表 + 可交互的 4 级 walk;TLB —— 让翻译可行的小缓存;缺页 —— 翻译不在时发生什么(以及 Linux 拿它做的聪明事); 最后是速查表

01

为啥要有虚拟内存

每个进程看到自己私有的 64 位地址空间,其中 128 TB 可用。 机器里实际上没有 128 TB RAM。这种错觉就是虚拟内存提供的 —— 它一次解 3 个不同的问题。

没有虚拟内存,每个进程直接看物理 RAM。3 件事会坏:

  • 隔离。任何进程能读 / 写属于任何其他进程的任何内存,包括内核。 没办法安全跑不受信任的代码。多租户服务器、容器、跑不受信任 JavaScript 的浏览器 —— 全靠内核和 CPU 强制「一个进程不能碰另一个的内存」。
  • 地址空间规划。如果进程共享一个物理地址空间, 每个程序都要被编译成跑在特定地址,两个程序要相同地址就撞了。 链接器和加载器会疯掉。有了虚拟内存,每个程序都链接成跑在同一个固定虚拟地址 —— 内核把它映射到物理 RAM 里任何空闲的地方。
  • 超额承诺(overcommit)。真实程序分配的虚拟内存远超过实际用到的。 64 GB 机器上 200 GB Java 堆完全正常 —— 虚拟内存让内核只用物理 RAM 支撑 实际被碰到的页,其余靠 swap 支撑(或者干脆不分配)。 这也是 fork() 的 COW 便宜的原因,也让 malloc对巨大尺寸都能立即返回。

一段话讲机制

硬件给每次内存访问加一步。指令不直接指物理地址,而是指虚拟地址, CPU 用每进程的页表(下一节)on-the-fly 翻译成物理地址。 翻译被缓存在叫 TLB(第 3 节)的硬件小表里, 所以常见情况零代价。虚拟地址没有物理支撑时,CPU 触发一次缺页(page fault)(第 4 节),内核决定怎么办 —— 分配一页、从磁盘 swap 一页进来、 或者因为访问未映射内存杀掉进程。

代价

翻译不免费。每次内存访问都带 TLB 查找的开销。 TLB miss 意味着 CPU 走页表(x86-64 上 4 次内存读)再完成实际访问。 内核维护页表,本身消耗内存(进程映射大小的 ~0.2%)。 内核还得保持物理内存池均衡 —— 淘汰、swap、按需分页都是内核在每次缺页时做的决定。

尽管有代价,虚拟内存在任何通用 OS 上都不容商量。 代价不可接受的场景 —— 嵌入式实时系统、GPU shader —— 要么完全避开虚拟内存 (单一可信程序、静态已知内存),要么用简化变体(大页、固定翻译)。

要点。「虚拟内存给每个进程一个私有 128 TB 地址空间的错觉。 它解隔离(进程间不能互碰内存)、地址空间规划(每个程序链接到同样的固定地址)、 超额承诺(分配远超物理 RAM,惰性支撑)。代价是每次内存访问一次 TLB 查找 + 偶尔的页表 walk 和缺页。」

02

页表 —— 硬件读的翻译

内核为每个进程维护一棵树形表,把虚拟页映射到物理帧。 CPU 在每次翻译不在 TLB 里的内存访问上走这棵树。

内存按管理:固定大小、对齐的块,每个现代常见 CPU 上都是 4 KB (Apple Silicon 因性能原因是 8 KB,但同样思路)。 翻译按页粒度工作:虚拟地址的最低 12 位原封不动透传(页内偏移), 高位选择物理页支撑虚拟地址空间的哪个区域。

为啥用树

把每个虚拟页都映射到物理帧的平表需要 236 个 entry (48 位虚拟地址 + 4 KB 页),每进程 64 GB 表。明显荒谬。 解决办法是树:只在需要的地方填充层级。大多数进程最多用几 GB; 它们的页表消耗几 MB。

x86-64 用 4 级树:PML4 → PDPT → PD → PT。每级 512 个 entry (9 位索引)、每个 entry 8 字节、每个表正好装在一个 4 KB 页里。 4 × 9 = 36 位索引加 12 位偏移 = 48 位虚拟地址。 Linux 在 Ice Lake+(2019)上扩展到 5 级, 57 位虚拟地址(128 PB),但只在内核需要这么大时才用。

x86-64 4 级页表 walk —— 48 位虚拟 → 52 位物理Step 0 —— 虚拟地址拆成 5 个字段virtual address — 48 bitsPML40101100109 bitsPDPT0010111009 bitsPD1000010119 bitsPT1101000109 bitsoffset00110010000012 bitsCR3PML4[idx 178]PDPT basePDPT[idx 92]PD basePD[idx 267]PT basePT[idx 418]页帧
x86-64 用 48 位 canonical 虚拟地址(Ice Lake+ 上 5 级分页扩展到 57 位)。拆成 4 × 9 位表索引加 12 位页偏移。9 = 每表 512 个 entry,每个 8 字节 = 4096 字节表 = 正好一页。
1 / 8
每次内存访问都过这个 walk。每级的 entry 指向下一级表的 4 KB 页; 最底层的 entry 给出物理页帧,跟 12 位偏移结合得到最终物理地址。 每次翻译 4 次内存 load —— 这就是为啥 TLB(下一节)那么关键: 没它的话,每次内存访问实际上是 5 次内存访问。

entry 装什么

每个表项 8 字节,装着:下一级表(或叶子上的最终页)的物理地址、 加一小撮权限和状态位。权限位强制读、写、执行(分开,自 x86-64 ~2003 年加 NX 位起)、 用户 vs 内核 —— CPU 在任何违反这些的访问上 fault。状态位包括Present(这个 entry 是否真指着某处)、Accessed(CPU 在任何读时置)、Dirty(任何写时置)。 内核用 Accessed/Dirty 决定淘汰哪些页。

大页 —— 短路 walk

「大页(huge page)」其实就是一个设了 PS(page-size)位的页表 entry, 告诉 CPU 这一级的 entry 已经指向最终页、不用再 walk。 x86-64 上:PDPT entry 设 PS 是 1 GB 页;PD entry 设 PS 是 2 MB 页;否则是普通 4 KB。

大页有两个性能好处:TLB miss 时要 walk 的级少(2 MB 是 3 而不是 4、1 GB 是 2 而不是 4)、 每个 TLB 项覆盖 512× 或 262 144× 多的内存(同样 TLB 容量能 reach 大得多的工作集)。 代价是内部碎片(100 KB 分配舍入到 2 MB 大页浪费 1.9 MB)和更难碎片整理。 Linux 的透明大页(THP)在分配大且连续时透明把 4 KB 分配提升为 2 MB。

切换地址空间

x86 上 CR3 寄存器(ARM64 上 TTBR0_EL1)装着顶级表的物理地址。 改它就改 CPU 看到的整个地址空间。这就是进程上下文切换上发生的事 —— 以及后 Meltdown KPTI 下每次 kernel↔user 切换上发生的事,把每次 syscall 的成本翻倍。

要点。「页表是把虚拟页映射到物理帧的 4 级或 5 级树。 x86-64 把 48 位虚拟地址拆成 4 × 9 位索引 + 12 位偏移。 每级的 entry 指向下一级;entry 也装权限和状态位。 CR3(每进程寄存器)选 CPU 走哪棵树。大页短路 walk 并增加 TLB reach。」

03

TLB —— 为啥翻译很快

没有 TLB,每次内存访问实际上是 5 次内存访问(1 次数据 + 4 次页表 walk)。 TLB 就是让虚拟内存负担得起的小硬件缓存。

Translation Lookaside Buffer 是按内容寻址的缓存,缓存最近使用的 (虚拟页 → 物理帧)映射。命中时,翻译本质上 0 额外时间完成 (~0.5 ns,跟内存访问完全重叠)。未命中时,CPU 走页表 —— x86-64 上 4 次读,最近扩展是 5 次。

TLB 层级

现代 Intel 核有一个小的 L1 dTLB(4 KB 页 ~64 项), 每次 load/store 都直接访问;还有一个更大的 L2 TLB(~1500 项),L1 miss 时查询。两个都 miss 触发硬件 page-walker, 它本身从数据缓存里装着的页表页受益 —— 所以页表已缓存的 TLB miss 是 ~100 周期,未缓存的可能 500+ 周期。

ARM 结构类似。Apple M 系列据说有异常大的 TLB(最近几代 ~3000 个 L2 项), 这是 M 系列 CPU 处理大软件工作集如此从容的部分原因。

TLB 覆盖与工作集悬崖

TLB 项数 × 页大小 = TLB 在 miss 之前能覆盖多少内存。 1500 个 L2 项 × 4 KB 页 = 6 MB 覆盖。随机访问超过 ~6 MB 内存的工作负载, 大多数访问都 TLB miss、付页 walk 代价。

大页显著扩展覆盖。同样的 1500 个 L2 项 × 2 MB 页 = 3 GB 覆盖; × 1 GB 页 = 1.5 TB。这就是为啥数据库(Redis、MongoDB)、JVM、HPC 代码 这么在意大页 —— 它们把 TLB miss 悬崖往外推 500× 到 260 000×。

TLB 刷新

TLB 装着当前进程页表的翻译。任何让翻译失效的改动 —— 页被淘汰、mmap 区被 unmap、写时复制 fault 重写页表 entry —— 都要让对应 TLB 项失效。架构提供具体指令 (x86 的 INVLPG)来刷单项,或者完全刷。

最贵的刷是地址空间切换。在 ASID(Address Space Identifier) 标记之前(Intel 上 Westmere 2010、所有现代 ARM),切到不同进程意味着 炸掉整个 TLB —— 每个翻译都丢。有 ASID 之后,TLB 项带进程 tag, 切换保留每个进程的项、只炸冲突的。这是 2010 年代上下文切换变便宜的最大原因。

测 TLB 压力

Linux 上:perf stat -e dTLB-load-misses,iTLB-load-misses看总 miss 数;除以 dTLB-loads 得 miss 率。 声称在 cache 里的负载 miss 率超过 1%,说明工作集长得超过 TLB 即便它装在 L1/L2 里 —— 这时大页或更小工作集是修法。

要点。「TLB 缓存最近的虚拟到物理翻译,所以常见情况免费。 现代 Intel:~64 个 L1 项 + ~1500 个 L2 项;4 KB 页覆盖 ~6 MB。 工作集大于覆盖会花相当时间在页 walk 上。大页扩展覆盖 500×(2 MB)或 260 000×(1 GB)。ASID 标记的 TLB(Westmere+)跨进程切换保留项, 这是切换变便宜的最大原因。」

04

缺页 —— Linux 拿它做的聪明事

缺页是 CPU 要一个页表没有的翻译时发生的事。Linux 把缺页当作懒分配、 mmap、写时复制、swap 的通用机制。

CPU 遇到 present 位没设的页表 entry(或权限违反,或还没走出来的级)时, trap 到内核。内核从 CR2 读出 fault 的地址、在它的簿记里(进程的 VMA—— virtual memory area —— 表)查那里本该是什么,要么修好情况然后返回, 要么 SIGSEGV 杀掉进程。

按需分页(demand paging)

malloc 1 GB 时,内核实际并不分配 1 GB 物理内存。 它把虚拟地址在你的 VMA 表里标成「允许但还没支撑」,立即返回。 只有你真碰一页时,缺页才触发,内核分配物理帧、清零、挂进页表、恢复代码。 从来没碰过的分配零代价。这就是 overcommit 怎么工作的。

mmap

mmap 创建一个由文件或匿名清零内存支撑的 VMA。 读 memory-mapped 文件就是解引用指针;首次访问的缺页把文件读进物理帧并映射。 后续访问是 cache 命中速度。写到可写映射最终触发对文件的写回 (走 page cache,见 file-system primer)。

mmap 也是共享库怎么被加载的(每个进程 mmap libc.so 为只读共享), 也是进程间共享内存怎么工作的(shm_open + mmap)。

写时复制(COW)

fork() 复制页表但把每一页标成只读,并增加物理帧的引用计数。 父和子看到完全相同的页,直到有人写 —— 那时写触发 fault、 内核分配新帧、拷贝页、更新那个进程的页表、恢复。 从来不写的页永远共享;被写的页每个付一次拷贝。

COW 是 fork() 在多 GB 堆的进程上瞬时的原因。 也是为啥 redis BGSAVE 子进程对一页的小修改, 能用的内存比 redis 数据集大小少很多 —— 只有修改过的页被复制。

Swap

物理内存短缺时,内核淘汰页:用 LRU 启发式挑一个、把它写到 swap (磁盘分区或文件)、更新页表标成 not-present-but-in-swap、释放物理帧。 进程最终再访问那一页时,fault 触发从 swap 读回到物理帧 —— 看起来像普通内存访问的事变成毫秒级延迟。

Swap 是安全阀,不是性能特性。工作集主动往 swap 溢出, 表现为应用程序在几秒的爆发里无响应。生产服务器通常vm.swappiness=0 或低值 + 紧凑内存预算,完全避开 swap。

OOM killer

物理内存 + swap 都耗尽、内核还要更多时,它通过 OOM killer 挑一个进程杀。 打分考虑内存使用、nice 值、oom_score_adj(显式的每进程偏置)。 配错的系统能 OOM 杀数据库或 SSH daemon。 对关键任务进程设 oom_score_adj(或者跑在带内存预留的 cgroup 里)是保护它们的方法。

要点。「缺页不只是错误用的。Linux 把它当作malloc 的懒分配机制、mmap 的按需加载器、fork() 写时复制的引擎、内存短缺时的 swap-in 触发器。 每一个都建在同一个 fault → 内核 handler → 修补 → 恢复的原语上。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。

虚拟内存解了哪 3 个问题?

(1)隔离 —— 进程间不能互碰内存; (2)地址空间规划 —— 每个程序链接到同样的固定虚拟地址,无论物理上加载在哪; (3)overcommit —— 分配远超物理 RAM 的虚拟内存,经缺页在首次触摸时惰性支撑。

走一下每次内存访问发生什么。

虚拟地址进 TLB;命中(良好代码 ~99%+ 时候)时,物理地址 ~0.5 ns 可得、内存访问继续。 miss 时硬件 page walker 启动:读 PML4 entry(CR3 + 9 位)、然后 PDPT、然后 PD、然后 PT —— 4 次 cache line 读。PT entry 给物理页帧;跟 12 位偏移结合;翻译缓存到 TLB;访问继续。

TLB 是什么、多大?

TLB 缓存最近的虚拟到物理翻译。现代 Intel:L1 dTLB ~64 项,L2 TLB ~1500 项。 4 KB 页下,任何访问 miss 前覆盖 ~6 MB。大页(2 MB)扩展覆盖 512×,到 ~3 GB; 1 GB 页扩展 262 144×。

解释 fork() 里的写时复制(COW)。

fork() 复制页表但把每一页标只读、增加每个物理帧的引用计数。 父和子看到完全相同的页,直到有人写 —— 写触发 fault、内核分配新帧、拷贝页、 更新一个进程的页表指向新帧(仍可写)、恢复。从来不写的页永远共享。 这就是为啥 fork() 在多 GB 堆上也快。

mmap 到底干什么?

mmap 创建一个 VMA —— virtual memory area —— 由文件或匿名清零内存支撑。 字节不立即加载;页表标 not-present-but-mapped。首次访问触发 fault, 读数据进物理帧并接好翻译。用途:大文件(读指针而不是 read())、 共享库、进程间共享内存、很多 malloc 实现(大分配绕过 brk直接用 mmap)。

大页什么时候帮、什么时候伤?

帮:大连续工作集 thrash TLB 的场景 —— 数据库(Redis、MongoDB、 Postgres shared buffer)、JVM 堆、HPC 数组。瓶颈在 TLB miss 的负载上, 性能赢 20–40%。伤:小碎片化分配(100 KB 分配舍入到 2 MB 浪费 1.9 MB); 频繁 fork 的负载(大页不能在子页粒度上 COW,所以连小写都拷贝整个 2 MB); 物理内存碎片化的系统(THP 找不到连续帧)。 大多数生产系统启用 THP 但用 madvise-only 模式、按分配 opt-in。

Code review 红旗

  • 不用就预留大量内存。不花物理 RAM 但花页表内存、 减慢访问(整段 TLB 压力)。只映射你会碰的。
  • 反复 mmap 和 munmap 小区域。每次都是一次 syscall 和一次贵的页表修改。 池化分配或者用一块更大的固定 mmap 区域。
  • mlock 把大段内存钉住。把页锁出 swap 之外, 阻止内核均衡内存。用来做安全(防 secret 落 swap),不要为了性能。
  • 最近有大量 write 爆发的进程 fork()。每个最近被写的页都是脏的,父或子下次写时都会被 COW 拷贝。 带大可变工作集的 fork-heavy 服务器,会复制大部分内存。
  • 生产里忽略 vm.overcommit_memory内核默认(1 = 总是允许)让 malloc 对物理 + swap 都满足不了的请求也成功; OOM killer 后面才发火,常杀错进程。内存关键的 host 设 2(严格) 并调 overcommit_ratio