虚拟内存 入门
你 curl 进程碰的每个指针都是虚拟地址。硬件每次访问都 把它翻译成物理地址 —— 每秒几十亿次。5 个 section 把画面搭出来:为啥需要虚拟内存(它解的 3 个问题);页表 + 可交互的 4 级 walk;TLB —— 让翻译可行的小缓存;缺页 —— 翻译不在时发生什么(以及 Linux 拿它做的聪明事); 最后是速查表。
为啥要有虚拟内存
每个进程看到自己私有的 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 和缺页。」
页表 —— 硬件读的翻译
内核为每个进程维护一棵树形表,把虚拟页映射到物理帧。 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),但只在内核需要这么大时才用。
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。」
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+)跨进程切换保留项, 这是切换变便宜的最大原因。」
缺页 —— 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 → 修补 → 恢复的原语上。」
速查表
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。