Linux-24 虚拟内存:为什么值得付出这么大代价
虚拟内存是操作系统最昂贵的抽象:每一次内存访问都要经过地址翻译。这一篇讲它为什么值得。
先看五个问题:
- 虚拟内存最初解决的是什么问题?为什么值得付出「每次访存都要翻译地址」的代价?
- 分段 vs 分页之争,为什么分页赢了?
- 为什么必须是多级页表?单级不行吗?
- 为什么说 TLB 是真正的性能命门?
- 缺页异常(page fault)是 bug 还是特性?
1. 虚拟内存要解决的问题
1.1 没有虚拟内存的世界
在早期系统里,程序直接使用物理地址。三个致命问题:
问题①:地址冲突
程序 A 编译时假定自己从地址 0x1000 开始,程序 B 也是。
两个程序无法同时驻留内存。
早期的解法是【重定位】:加载时把程序里所有地址加上一个偏移量
-> 需要程序携带重定位表,加载慢
-> 而且程序一旦加载就不能移动(否则所有指针失效)
-> 内存碎片无法整理
问题②:内存不够
物理内存 64KB,但程序想用 1MB。
早期解法是【覆盖(overlay)】:程序员手工把程序切成块,
手动决定什么时候把哪一块换进内存
-> 这是【程序员的负担】,而且极易出错
问题③:没有隔离
任何程序都能读写任何地址 —— 一个程序的 bug 能破坏另一个程序甚至内核。
也没有任何安全边界。
1.2 虚拟内存的核心思路
给每个进程一套独立的、连续的、假的地址空间,由硬件(MMU)在每次访存时翻译成物理地址。
进程 A 的虚拟地址空间 物理内存 进程 B 的虚拟地址空间
+------------------+ +------------------+
| 0x400000 代码 | ---> +---------------+ <---- | 0x400000 代码 |
+------------------+ | 物理页 帧号 5 | +------------------+
| 0x600000 数据 | ---> +---------------+ | 0x600000 数据 |
+------------------+ | 物理页 帧号 9 | <---- +------------------+
| ... | +---------------+ | ... |
+------------------+ | 物理页 帧号 3 | +------------------+
✅ 两个进程可以用【相同的虚拟地址】而互不干扰(问题① 解决)
✅ 虚拟地址空间可以【大于物理内存】,不够时换出到磁盘(问题② 解决)
✅ 没有映射的地址【无法访问】,映射可以标记只读(问题③ 解决)
1.3 它顺带解决了更多问题
虚拟内存真正的价值在于:一旦有了「地址翻译」这个间接层,大量原本困难的事变得可能 —— 这是本篇的主线。
| 能力 | 靠什么实现 | 在本系列哪里出现过 |
|---|---|---|
| 进程隔离 | 各自独立的页表 | — |
| 共享内存 | 两个页表指向同一物理页 | 20 篇的 mmap、PSS 统计(13 篇 2.3) |
| COW | 页表标记只读 + 缺页时复制 | 21 篇 3.2 |
| mmap 文件 | 页表指向 page cache 的页 | 20 篇第 3 章 |
| 按需分页 | 先只建映射不分配物理页 | 13 篇的 VSZ ≫ RSS |
| swap | 页表项标记「在磁盘上」 | 11 篇第 7 章 |
| 栈自动增长 | 访问栈下方触发缺页时扩展 | — |
| 内存保护(NX) | 页表项的不可执行位 | — |
| ASLR | 每次加载用不同的虚拟地址 | 第 6 章 |
| overcommit | 承诺的虚拟内存可以超过物理内存 | 21 篇的 Redis fork |
1.4 代价
① 每次访存都要翻译地址
-> 硬件必须做(软件做不起),所以有 MMU 和 TLB
② 页表本身占内存
-> 一个进程的页表可能有几 MB 到几百 MB(第 3 章算)
-> 这就是 21 篇讲的「fork 的开销与页表规模成正比」
③ TLB miss 的延迟
-> 一次四级页表遍历 = 4 次内存访问,每次约 100ns(第 4 章)
④ 缺页异常的开销
-> 一次上下文切换到内核 + 可能的磁盘 I/O(第 5 章)
为什么值得? 因为问题①③根本没有替代方案(现代多任务系统必须有隔离),而问题②的替代方案(手工 overlay)会把复杂度全部推给程序员。虚拟内存是「用可控的硬件开销换取不可替代的能力」。
2. 分段 vs 分页
2.1 两种切分方式
分段(Segmentation):按【程序的逻辑单位】切
+----------------+
| 代码段 40KB | 每段有独立的基址与长度,长度【可变】
+----------------+ 优点:与程序结构对应,保护粒度自然
| 数据段 128KB | (代码段只读、数据段不可执行)
+----------------+
| 栈段 8KB |
+----------------+
分页(Paging):按【固定大小】切
+------+------+------+------+
| 4KB | 4KB | 4KB | 4KB | 所有页大小相同
+------+------+------+------+ 优点:管理极其简单
2.2 开篇第二问:为什么分页赢了
| 分段 | 分页 | |
|---|---|---|
| 单位大小 | 可变 | 固定(4KB) |
| 外部碎片 | ❌ 严重(变长块的分配必然产生空隙) | ✅ 完全没有(所有页大小相同,任意页可放任意帧) |
| 内部碎片 | ✅ 几乎没有 | ⚠️ 有(最后一页平均浪费 2KB) |
| 分配算法 | ❌ 复杂(首次适配/最佳适配,还要合并空闲块) | ✅ 极简(一个空闲页链表/位图,随便取一个) |
| 交换(swap)粒度 | ❌ 整段(换出一个 128KB 的数据段) | ✅ 单页(只换出真正冷的 4KB) |
| 硬件实现 | ⚠️ 需要比较基址+长度,段表项变长 | ✅ 纯粹的位运算(地址高位查表、低位做偏移) |
| 与程序结构的对应 | ✅ 自然 | ❌ 无关(靠 VMA 在软件层补回来) |
| 地址翻译 | 段号 + 段内偏移(偏移需要越界检查) | 页号 + 页内偏移(页内偏移天然不会越界) |
决定性的三点:
① 外部碎片
分段的变长分配注定产生碎片:内存里剩下 30KB + 40KB 的空隙,
但来了一个 60KB 的段就放不下 —— 除非做内存整理(移动数据,极慢)。
分页彻底消灭了这个问题:任何虚拟页都能放进任何物理帧。
② 交换粒度
「内存不够时换出一部分」是虚拟内存的核心能力。
分段只能整段换出(一个 128MB 的数据段要么全在要么全不在),
而实际的访问局部性是页级的 —— 分页能只保留真正在用的那几页。
③ 硬件实现的简洁性
分页的地址翻译是【纯位运算】:
虚拟地址 = [页号 | 页内偏移],页大小是 2 的幂,所以直接切位
物理地址 = [帧号 | 同样的页内偏移]
而分段要做加法(基址+偏移)和比较(是否越界)—— 更多晶体管、更长的关键路径。
2.3 x86 的历史:段页混合到段被废弃
8086(1978):只有分段(因为要用 16 位寄存器寻址 20 位地址)
物理地址 = 段寄存器 << 4 + 偏移
80286:保护模式的分段(有段描述符、特权级)
80386(1985):分段 + 分页【两层】
虚拟地址 --分段--> 线性地址 --分页--> 物理地址
-> 实际上操作系统几乎都把段设成「覆盖整个地址空间」,
等于把分段这一层【旁路掉】,只用分页
x86-64 长模式(2003):✅ 分段【基本被废弃】
CS/DS/ES/SS 的基址被强制为 0、长度检查被禁用
只保留 FS/GS 的基址(用来做线程本地存储 TLS!)
# 验证 FS/GS 仍在使用(TLS 的实现基础)
# Go 的 goroutine 调度、C 的 __thread 变量、glibc 的 errno 都靠它
sudo cat /proc/self/maps | head -3
# Go 程序里 runtime 用 TLS 存当前 g 的指针(GMP 模型的基础)
# 这就是「历史包袱以最小形态留存」的例子:
# 分段作为地址翻译机制被淘汰,但作为「一个额外的基址寄存器」被保留下来做 TLS
结论:分页赢了,但分段没有完全消失 —— 它退化成了 TLS 的实现手段。这是 18 篇讲的「ABI 只能新增不能删除」在硬件层面的对应。
3. 页表:从单级到多级
3.1 开篇第三问:单级页表的空间灾难
假设用单级页表(一个大数组,虚拟页号做下标):
x86-64 的有效虚拟地址是 48 位
页大小 4KB = 2^12
-> 虚拟页数 = 2^48 / 2^12 = 2^36 = 687 亿个页
-> 每个页表项 8 字节
-> 页表大小 = 2^36 × 8 = 512 GB 💀
而且这是【每个进程】都要一份!
一台跑 100 个进程的机器需要 50TB 的页表 —— 完全不可能。
3.2 多级页表的核心洞察
关键事实:地址空间是极度稀疏的。
# 一个真实进程实际用了多少地址空间
awk '{split($1,a,"-"); sum += strtonum("0x"a[2]) - strtonum("0x"a[1])} END {printf "%.2f GB\n", sum/1024/1024/1024}' /proc/self/maps
# 通常几 MB 到几 GB —— 而地址空间总量是 256TB(2^48)
# 也就是说【绝大部分地址空间是空的】
多级页表利用这个稀疏性:没有映射的区域,对应的中间层页表根本不需要存在。
四级页表(x86-64,48 位地址)
虚拟地址(48 位有效)
+--------+--------+--------+--------+------------+
| PGD 9位 | PUD 9位 | PMD 9位 | PTE 9位 | 偏移 12位 |
+--------+--------+--------+--------+------------+
| | | | |
| | | | +--> 页内偏移(4KB 内)
| | | +--> 页表(Page Table Entry)
| | +--> 中间目录(Page Middle Directory)
| +--> 上层目录(Page Upper Directory)
+--> 全局目录(Page Global Directory)—— CR3 寄存器指向它
每级 9 位 = 512 项,每项 8 字节 = 恰好【一个 4KB 页】✅
这个设计非常精巧:每一级页表本身就是一个标准页
总空间:如果进程只映射了几 MB,
只需要 1 个 PGD + 1 个 PUD + 1 个 PMD + 几个 PTE 页 = 几十 KB
而不是 512GB
3.3 一次地址翻译的完整过程
访问虚拟地址 0x00007f8e_4c3d_2000
① CR3 寄存器 -> PGD 的物理地址
② 取虚拟地址的 47:39 位(9 位)作为 PGD 索引 -> 读一次内存 -> 得到 PUD 地址
③ 取 38:30 位作为 PUD 索引 -> 读一次内存 -> 得到 PMD 地址
④ 取 29:21 位作为 PMD 索引 -> 读一次内存 -> 得到 PTE 地址
⑤ 取 20:12 位作为 PTE 索引 -> 读一次内存 -> 得到【物理帧号】
⑥ 物理地址 = 帧号 | 虚拟地址的 11:0 位
⚠️ 一次翻译要【4 次内存访问】,而每次内存访问约 100ns
-> 如果每条访存指令都这样做,程序会慢 5 倍以上
-> 这就是必须有 TLB 的原因(第 4 章)
3.4 页表项里有什么
x86-64 的 PTE(64 位)关键位:
+----------------------------------------------------------+
| bit 0 P Present ✅ 是否在物理内存中 |
| bit 1 R/W Read/Write ✅ 可写?(COW 就靠清掉它!) |
| bit 2 U/S User/Super 用户态能否访问 |
| bit 3 PWT 写通缓存 |
| bit 4 PCD 禁用缓存(设备内存用) |
| bit 5 A Accessed ✅ 被访问过(页回收算法用) |
| bit 6 D Dirty ✅ 被写过(决定换出时是否要写盘)|
| bit 7 PS Page Size ✅ 是否是大页(2MB/1GB) |
| bit 8 G Global 上下文切换时不刷新(内核页用) |
| bit 12-51 物理帧号 |
| bit 63 NX No Execute ✅ 不可执行(防代码注入) |
+----------------------------------------------------------+
P=0 时,其余位可以被内核【复用】来存别的信息:
- swap 条目(在哪个 swap 设备的哪个偏移)—— swap 的实现基础
- 文件映射的信息
- 「从未分配过」(按需分页)
这张表解释了本系列多个现象:
R/W 位 -> 21 篇的 COW(fork 时清掉它,写入触发缺页)
A 位 -> 25 篇的页回收(LRU 靠它判断冷热)
D 位 -> 脏页回写(04 篇讲的 write() 返回不等于落盘)
PS 位 -> 大页(第 7 章)
NX 位 -> 为什么现代系统上栈溢出难以直接执行注入的代码
P=0 复用 -> swap 与按需分页
3.5 页表的实际开销
# 看一个进程的页表占了多少内存
grep -E 'VmPTE|VmPMD|VmRSS|VmSize' /proc/self/status
# VmSize: 12345 kB 虚拟地址空间
# VmRSS: 4567 kB 物理内存
# VmPTE: 48 kB ✅ 页表占用
# VmPMD: 12 kB
# 找出页表最大的进程(大内存进程的页表可能有几百 MB)
for p in /proc/[0-9]*; do
pte=$(awk '/^VmPTE/{print $2}' $p/status 2>/dev/null)
[[ -n $pte ]] && echo "$pte ${p##*/} $(cat $p/comm 2>/dev/null)"
done | sort -rn | head -5
# 系统整体的页表开销
grep PageTables /proc/meminfo
# PageTables: 123456 kB
# 估算:4KB 页的页表开销约为映射内存的 1/512(0.2%)
# -> 一个 64GB 内存的进程,页表约 128MB
# -> 21 篇讲的「Redis fork 慢」就是要复制这 128MB(而且要遍历所有 VMA)
3.6 五级页表
# Linux 4.14+ 支持五级页表(57 位虚拟地址 = 128PB)
grep -o 'la57' /proc/cpuinfo | head -1 # CPU 是否支持
dmesg | grep -i '5-level' # 内核是否启用
# 为什么需要?因为 48 位(256TB)在超大内存机器上开始不够了
# 代价:翻译多一级 = TLB miss 时多一次内存访问
# 所以内核默认只在需要时(内存 > 64TB)才启用
4. TLB:真正的性能命门
4.1 开篇第四问:为什么它是命门
TLB(Translation Lookaside Buffer)是 CPU 里缓存「虚拟页号 → 物理帧号」的硬件表。
没有 TLB:每次访存 = 4 次页表内存访问 + 1 次真正的数据访问 = 5 倍开销
有 TLB: 命中时【零额外开销】(与 CPU 流水线并行完成)
TLB 命中率通常 > 99%,正是它让虚拟内存的代价变得可接受。
但 TLB 非常小:
典型的现代 x86 CPU(每核):
L1 dTLB:64 项(4KB 页)
L1 iTLB:64~128 项
L2 TLB(统一):1024~2048 项
-> 用 4KB 页时,L2 TLB 能覆盖 2048 × 4KB = 【8MB】内存
-> 一个工作集 1GB 的程序,TLB 完全覆盖不住 -> 频繁 miss
4.2 TLB miss 的代价
一次 TLB miss 的开销:
① 遍历四级页表 = 最多 4 次内存访问
② 这些页表项本身可能不在 CPU cache 里 -> 真的要访存(~100ns 每次)
③ 最坏情况约 300~400ns
对比:
TLB 命中:~0ns(并行完成)
L1 cache 命中:~1ns
内存访问:~100ns
-> 在指针密集、随机访问的负载(数据库、图计算、JVM/Go 的 GC 标记阶段)
TLB miss 可能占总时间的 10%~30%
# 实测 TLB miss(需要 perf 与硬件支持)
sudo perf stat -e dTLB-load-misses,dTLB-loads,iTLB-load-misses ./myapp
# 或者看更详细的 walk 周期
sudo perf stat -e dtlb_load_misses.walk_active,dtlb_load_misses.walk_completed ./myapp
# 一个直观的对比实验:顺序访问 vs 随机访问同一块内存
cat > /tmp/tlb.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N (256*1024*1024/8) // 256MB 的 long 数组
int main(int argc, char **argv) {
long *a = malloc(N*8);
for (long i=0;i<N;i++) a[i]=i;
struct timespec t1,t2; long sum=0;
clock_gettime(CLOCK_MONOTONIC,&t1);
if (argv[1][0]=='s') { // 顺序
for (long i=0;i<N;i++) sum += a[i];
} else { // 随机(每次跳一个页)
for (long i=0;i<N;i++) sum += a[(i*4096/8) % N];
}
clock_gettime(CLOCK_MONOTONIC,&t2);
printf("%s: %.3f s (sum=%ld)\n", argv[1],
(t2.tv_sec-t1.tv_sec)+(t2.tv_nsec-t1.tv_nsec)/1e9, sum);
}
EOF
gcc -O2 -o /tmp/tlb /tmp/tlb.c
/tmp/tlb s; /tmp/tlb r # 随机访问通常慢 5~20 倍(TLB miss + cache miss 叠加)
sudo perf stat -e dTLB-load-misses /tmp/tlb r 2>&1 | grep TLB
4.3 上下文切换与 TLB flush
问题:进程 A 和进程 B 的虚拟地址 0x400000 指向【不同】的物理页。
切换进程时,TLB 里 A 的条目对 B 是【错误】的。
最简单的解法:切换时把 TLB 全部清空(flush)
-> 但那意味着切换后的一段时间里【所有访存都 TLB miss】
-> 上下文切换的真实代价里,TLB 重建往往比保存寄存器贵得多
(这正是 13 篇讲「上下文切换贵在 cache/TLB 而不是保存寄存器」的依据)
硬件的改进:给 TLB 条目打上「地址空间标识」
- x86:PCID(Process-Context Identifier,12 位)
- ARM:ASID
-> 切换时不用 flush,只是让不同 PCID 的条目互不匹配
# 检查 CPU 是否支持 PCID / INVPCID
grep -o -E 'pcid|invpcid' /proc/cpuinfo | sort -u
# pcid
# invpcid
# ⚠️ Meltdown 缓解措施(KPTI)让这件事变复杂了
dmesg | grep -i 'page table isolation'
cat /sys/devices/system/cpu/vulnerabilities/meltdown
# Mitigation: PTI
# KPTI 的机制:给【内核】和【用户态】用两套独立的页表
# -> 每次系统调用进出都要切换 CR3 = 可能的 TLB 影响
# -> 有 PCID 时开销小得多(这就是为什么 PCID 突然变得很重要)
# -> 在系统调用密集的负载上,KPTI 的开销可达 5%~30%
# -> 这也是 18 篇讲「io_uring 想减少系统调用次数」的一个背景原因
4.4 大页如何拯救 TLB
一个 TLB 条目能覆盖多少内存?
4KB 页 -> 4KB
2MB 页 -> 2MB (✅ 512 倍)
1GB 页 -> 1GB (✅ 262144 倍)
2048 项的 L2 TLB:
用 4KB 页 -> 覆盖 8MB
用 2MB 页 -> 覆盖 4GB ← 对大内存应用是决定性的差异
这是大页存在的唯一理由(第 7 章展开它的代价)。
5. 缺页异常:不是 bug,是核心机制
5.1 开篇第五问:三种缺页
ps -o pid,min_flt,maj_flt,comm -p $$
# ^^^^^^^ 次要缺页 ^^^^^^^ 主要缺页
| 类型 | 含义 | 代价 | 是否正常 |
|---|---|---|---|
| Minor fault(次要) | 页表项无效,但不需要读磁盘(页已在内存里,或只需分配一个新页) | 微秒级 | ✅ 完全正常,数量大也没关系 |
| Major fault(主要) | 需要从磁盘读取(文件映射首次访问、swap 换入) | 毫秒级(SSD 几十微秒) | ⚠️ 持续增长才是问题 |
| Invalid fault | 访问的地址根本没有映射 | — | ❌ 这才是 bug → SIGSEGV |
# 观察缺页
ps -eo pid,min_flt,maj_flt,comm --sort=-maj_flt | head -6
sar -B 1 # 系统级:pgpgin/s pgfault/s majflt/s
pidstat -r 1 # 每进程的缺页速率
sudo perf stat -e page-faults,major-faults ./myapp
sudo bpftrace -e 'software:major-faults:1 { @[comm] = count(); }'
5.2 缺页处理流程
CPU 访问虚拟地址 -> MMU 查页表发现 P=0 或权限不符
|
v 触发缺页异常(硬件),CPU 切到内核态,跳到 do_page_fault()
① 拿到出错的地址(CR2 寄存器)与错误码(是读还是写、用户态还是内核态)
|
v
② 在进程的 VMA 树(红黑树/maple tree)里查找这个地址
|
├─ 找不到 VMA -> ❌ 非法访问 -> SIGSEGV(段错误)
| (除了一个例外:栈的自动增长,见 5.3)
|
└─ 找到了 VMA -> 检查权限(VMA 的 flags 允许这次访问吗?)
├─ 不允许(如往只读段写)-> ❌ SIGSEGV
└─ 允许 -> 继续
|
├─ 【匿名页首次访问】-> 分配物理页,清零,建映射(minor)
| 若是【读】-> 甚至可以直接映射到全局的零页(省内存)
|
├─ 【文件映射】-> 查 page cache
| 命中 -> 建映射(minor)
| 未命中 -> 从磁盘读(major)✅ 这就是 20 篇第 3 章的路径
|
├─ 【COW 页】(P=1 但 R/W=0 且 VMA 可写)
| -> 分配新页,复制内容,改页表为可写(minor)
| ✅ 这就是 21 篇 3.2 的实现
|
└─ 【在 swap 里】(P=0,PTE 存着 swap 条目)
-> 从 swap 读回(major)
|
v
③ 更新页表,刷新 TLB,从异常返回,【重新执行那条指令】
5.3 用缺页实现的七种功能
这是本篇最重要的一节:缺页不是「错误处理」,而是内核实现各种内存特性的统一机制。
① 按需分页(demand paging)
malloc(1GB) 立即返回,但【一个物理页都没分配】——
只是建了一个 VMA。访问时才逐页分配。
-> 这就是 13 篇讲的 VSZ ≫ RSS
② COW(21 篇)
fork 时把双方页表都标记只读,写入时触发缺页才真正复制
③ mmap 文件映射(20 篇)
映射时只建 VMA,访问时缺页 -> 从 page cache 取页(或读磁盘)
-> 这让「把 10GB 文件当数组用」成为可能
④ swap 换入(11 篇)
PTE 里 P=0,其余位存着「在哪个 swap 设备的哪个槽位」
⑤ 栈自动增长
栈向下增长,访问栈指针下方的未映射页时,
内核【特殊处理】:如果地址接近栈顶且在 RLIMIT_STACK 内,就扩展栈 VMA
-> 这是 SIGSEGV 判断里唯一的「合法的越界访问」
⑥ 透明大页提升(第 7 章)
khugepaged 后台把连续的 4KB 页合并成 2MB 大页
⑦ userfaultfd —— 把缺页处理【交给用户态】
内核把缺页事件通过一个 fd 通知用户态进程,由它决定怎么填充这一页
# 观察按需分页:malloc 之后 RSS 几乎不涨,写入后才涨
cat > /tmp/demand.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(){
size_t sz = 512UL*1024*1024;
char *p = malloc(sz);
printf("malloc 完成,看 RSS(应该很小): "); fflush(stdout); getchar();
memset(p, 1, sz); // ✅ 现在才真正分配物理页
printf("写入完成,RSS 应该涨到 512MB: "); fflush(stdout); getchar();
free(p);
}
EOF
gcc -o /tmp/demand /tmp/demand.c && /tmp/demand &
watch -n1 "grep -E 'VmSize|VmRSS' /proc/$(pgrep -n demand)/status"
# userfaultfd 的实际用途(这是一个很能说明「机制的威力」的例子)
# ① 虚拟机的【后拷贝迁移】(post-copy migration):
# 先把 VM 切到目标机器运行,内存页按需从源机器拉取 —— 靠 userfaultfd 拦截缺页
# ② CRIU 的进程迁移与恢复
# ③ 分布式共享内存、内存快照
# ④ ⚠️ 也被用于漏洞利用(精确控制缺页时机来放大竞态窗口)
# -> 所以内核加了 vm.unprivileged_userfaultfd 开关
sysctl vm.unprivileged_userfaultfd
6. 地址空间布局
6.1 x86-64 的布局
0xFFFFFFFFFFFFFFFF +---------------------------+
| 内核空间(128TB) | ← 所有进程共享同一份映射
0xFFFF800000000000 +---------------------------+ (但用户态不可访问,U/S=0)
| |
| 非法区域("canonical | ← 48 位地址的中间空洞
| hole",硬件规定) |
| |
0x00007FFFFFFFFFFF +---------------------------+
| 栈(向下增长) | ← ASLR 随机化起始位置
+---------------------------+
| ↓ |
| mmap 区域(向下增长) | ← 共享库、匿名 mmap、
| (libc、堆外内存…) | 大块 malloc
| ↑ |
+---------------------------+
| 堆(brk,向上增长) | ← 小块 malloc
+---------------------------+
| BSS(未初始化数据) |
| Data(已初始化数据) |
| Text(代码,只读+可执行) |
0x0000000000400000 +---------------------------+
| 【不映射】 | ← 空指针解引用 = SIGSEGV
0x0000000000000000 +---------------------------+ 的实现原理
6.2 读懂 /proc/pid/maps
cat /proc/self/maps
# 55d3e8c00000-55d3e8c22000 r--p 00000000 fd:01 1234567 /usr/bin/cat
# ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^ ^^^^^^^^ ^^^^^ ^^^^^^^ ^^^^^^^^^^^^^
# 虚拟地址范围 权限 文件偏移 设备 inode 映射的文件
# ^^^^ r/w/x + p(私有)或 s(共享)
# 55d3e8c22000-55d3e8c4a000 r-xp 00022000 fd:01 1234567 /usr/bin/cat <- 代码段
# 55d3e8c4a000-55d3e8c5c000 r--p 0004a000 fd:01 1234567 /usr/bin/cat <- 只读数据
# 55d3e8c5d000-55d3e8c5e000 rw-p 0005c000 fd:01 1234567 /usr/bin/cat <- 可写数据
# 55d3e9a00000-55d3e9a21000 rw-p 00000000 00:00 0 [heap] <- 堆
# 7f8e4c000000-7f8e4c028000 r--p 00000000 fd:01 2345678 /usr/lib/libc.so.6
# 7ffd8c000000-7ffd8c021000 rw-p 00000000 00:00 0 [stack] <- 栈
# 7ffd8c1fe000-7ffd8c200000 r-xp 00000000 00:00 0 [vdso] <- 见下
# ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
# 更详细的版本(每个区域的 RSS/PSS/脏页)
sudo grep -A 12 '\[heap\]' /proc/self/smaps
# vdso 是什么?—— 内核映射到每个进程的一小段代码
# 用途:让 gettimeofday/clock_gettime 这类调用【不用陷入内核】
# -> 直接在用户态读取内核维护的时间数据
# -> 这是「减少系统调用开销」的一个经典优化(Go 的 time.Now() 就走它)
ldd /bin/ls | grep vdso
6.3 ASLR 与空指针
# ASLR:每次运行时随机化各区域的起始地址
for i in 1 2 3; do awk 'NR==1{print $1}' /proc/self/maps; done
# 三次输出不同 -> ASLR 生效
sysctl kernel.randomize_va_space
# 2 = 完全随机化(代码/堆/栈/mmap) 1 = 部分 0 = 关闭
setarch $(uname -m) -R ./myapp # 临时关闭 ASLR(调试用)
# ⚠️ 空指针为什么是段错误?
# 因为虚拟地址 0 附近【故意不映射】
sysctl vm.mmap_min_addr
# 65536 <- 低于这个地址【禁止映射】
# 为什么需要这个限制?防御一类内核漏洞利用:
# 如果内核里有「解引用 NULL」的 bug,攻击者可以在用户态 mmap 地址 0
# 放上恶意数据,让内核跳到那里执行 -> mmap_min_addr 封死了这条路
7. 大页:收益与代价
7.1 两种大页
| 静态大页(HugeTLB) | 透明大页(THP) | |
|---|---|---|
| 分配时机 | 启动时预留(vm.nr_hugepages) |
运行时自动分配与合并 |
| 应用是否要改 | ✅ 要(用 hugetlbfs 或 MAP_HUGETLB) |
❌ 不用,完全透明 |
| 能否被换出 | ❌ 不能(永久锁在内存) | ⚠️ 需要先拆分成 4KB |
| 延迟可预测性 | ✅ 好(预留好了,运行时无分配) | ❌ 差(见 7.2) |
| 典型用户 | Oracle、PostgreSQL(huge_pages=on)、DPDK、KVM |
默认对所有应用启用 |
# 静态大页
grep -i huge /proc/meminfo
# AnonHugePages: 524288 kB <- THP 实际使用量
# HugePages_Total: 0 <- 静态大页预留数
# HugePages_Free: 0
# Hugepagesize: 2048 kB
sudo sysctl -w vm.nr_hugepages=1024 # 预留 1024 × 2MB = 2GB
# 持久化:/etc/sysctl.d/ 或内核参数 hugepages=1024
# THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
cat /sys/kernel/mm/transparent_hugepage/defrag
# always defer [defer+madvise] madvise never
7.2 THP 的三个代价
这解释了为什么几乎所有数据库都要求关闭它(04 篇 10.x 和 21 篇 3.3 提过):
① 分配延迟不可预测(最主要的问题)
要凑出一个 2MB 的【物理连续】区域,内核可能需要做【内存规整】
(compaction)—— 移动大量页来腾出连续空间。
这个过程会让触发它的进程【同步阻塞几百毫秒】。
-> 表现为「服务偶发的长尾延迟尖刺」,而且极难定位
(因为原因藏在内存子系统里,应用侧看不出任何异常)
② 内存放大
对随机访问的负载:只用到 2MB 里的几 KB,剩下的全浪费。
Redis/MongoDB 这类稀疏访问模式尤其明显。
③ COW 粒度放大(21 篇 3.3)
fork 之后,父进程写一个字节会触发【2MB】的复制,而不是 4KB。
-> Redis BGSAVE 期间内存放大 512 倍的风险
-> 这是 Redis 官方文档明确要求关闭 THP 的首要原因
# 关闭 THP(数据库/延迟敏感服务的标准操作)
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
# 永久:内核参数 transparent_hugepage=never
# 或者用 systemd 服务在启动时设置
# 折中方案:madvise 模式(只对显式请求的应用启用)
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 应用用 madvise(ptr, len, MADV_HUGEPAGE) 主动请求 —— ✅ 机制与策略分离(18 篇)
# 观察 THP 造成的规整开销
grep -E 'compact_stall|compact_fail' /proc/vmstat
# compact_stall 持续增长 = 有进程因为内存规整被阻塞过
sudo bpftrace -e 'kprobe:try_to_compact_pages { @[comm] = count(); }'
8. Go 视角
8.1 为什么 Go 程序的 VSZ 很大
ps -o pid,vsz,rss,comm -C myapp
# ^^^^^ 常常 1~2GB,而 RSS 只有几十 MB(13 篇 2.3 讲过这个现象)
原因:Go runtime 在启动时【预留】大块虚拟地址空间(arena),
用 mmap(PROT_NONE) 只占地址不占物理页。
目的是让堆可以连续增长,简化指针运算与 GC 的元数据管理。
-> 这正好印证 13 篇的结论:VSZ 几乎没有意义,要看 RSS/PSS
8.2 GC、madvise 与 RSS 不降
// Go 的 GC 释放内存后,为什么 RSS 不立刻下降?
// 因为 runtime 用 madvise 告诉内核「这些页我暂时不用了」,有两种方式:
// MADV_FREE(Go 1.12~1.15 默认,Linux 4.5+)
// -> 内核【延迟】回收:页仍在 RSS 里,直到内存压力出现才真正释放
// -> 优点:如果应用又用到这块内存,无需重新缺页(快)
// -> 缺点:RSS 看起来不降,容易被误判为内存泄漏,
// 也容易在容器里撞上 memory.max(因为 cgroup 按 RSS 算)
// MADV_DONTNEED(Go 1.16+ 恢复为默认)
// -> 立即归还,RSS 马上下降
// -> 代价:下次访问要重新缺页(慢一点)
// -> ✅ 换回它的主要原因就是【容器环境下的可观测性与 OOM 风险】
// 手动触发归还
debug.FreeOSMemory()
// 观察
// GODEBUG=madvdontneed=1 ./myapp (Go 1.12~1.15 时期的临时开关)
# 与 GOMEMLIMIT 的关系(13 篇 10.2 提过)
GOMEMLIMIT=1800MiB ./myapp
# 它让 GC 感知「内存上限」,在接近时更积极地回收
# ✅ 必须设:否则 Go 只按 GOGC 的比例增长,容器里很容易被 OOMKilled
# 因为 Go 不知道 cgroup 的限制(13 篇 10.2 的 nproc 同类问题)
# 观察 Go 的内存与缺页
curl -s localhost:6060/debug/pprof/heap > heap.prof
GODEBUG=gctrace=1 ./myapp # 每次 GC 打印堆大小
ps -o min_flt,maj_flt,rss -C myapp # 缺页与 RSS
8.3 Go 与大页
Go 的堆是通过 mmap 匿名映射得来的,所以【会被 THP 影响】:
✅ 收益:大堆(几 GB)时 TLB 命中率显著改善,GC 标记阶段(指针密集遍历)尤其明显
⚠️ 风险:THP 的分配延迟会造成 GC 停顿的长尾;
以及 21 篇讲的 COW 放大(如果程序里有 fork/exec)
实践:
- 延迟敏感的服务:关闭 THP(用 never 或 madvise 模式)
- 大内存吞吐型服务:可以测试开启 THP 的收益
- 无论如何都要设 GOMEMLIMIT + 关注 gctrace 的停顿时间
9. 面试题
Q:虚拟内存解决的最初问题是什么?为什么值得付出每次访存都要翻译地址的代价?
三个原始问题:地址冲突(程序编译时假定固定地址,无法同时驻留;早期靠加载时重定位,程序一旦加载就不能移动,碎片无法整理)、内存不够(早期靠程序员手工 overlay,把「什么时候换入哪一块」的负担交给人)、没有隔离(任何程序能读写任何地址)。
值得的理由是:隔离和保护根本没有替代方案(现代多任务系统的前提),而 overlay 的替代方案是把复杂度全推给程序员。
但真正的价值在于「地址翻译」这个间接层一旦存在,大量原本困难的事变得几乎免费:共享内存(两个页表指向同一物理页)、COW、mmap 文件、按需分页、swap、栈自动增长、NX 保护、ASLR、overcommit —— 这些全部是同一个机制的衍生品。
代价是四项:地址翻译开销(所以必须有 MMU 和 TLB)、页表本身占内存(这是 21 篇「fork 开销与页表规模成正比」的来源)、TLB miss 延迟、缺页异常开销。
Q:分段与分页之争,为什么分页赢了?
三个决定性因素:
- 外部碎片 —— 分段是变长分配,必然产生空隙(剩 30KB+40KB 但来了 60KB 的段就放不下,除非做内存整理)。分页彻底消灭了这个问题:所有页大小相同,任意虚拟页能放进任意物理帧
- 交换粒度 —— 「内存不够时换出一部分」是虚拟内存的核心能力。分段只能整段换出(128MB 的数据段要么全在要么全不在),而实际访问局部性是页级的
- 硬件实现简洁 —— 分页的翻译是纯位运算(页大小是 2 的幂,直接切位,页内偏移天然不越界),而分段要做加法和越界比较
代价是分页有内部碎片(最后一页平均浪费 2KB)和页表开销,但这两项都可控。
有意思的是分段没有完全消失:x86-64 长模式下 CS/DS/SS 的基址被强制为 0、长度检查禁用,但保留了 FS/GS 的基址用来做线程本地存储(TLS) —— Go 的 goroutine 调度、C 的 __thread、glibc 的 errno 都靠它。这是「历史包袱以最小形态留存」的典型。
Q:为什么必须是多级页表?单级为什么不行?
单级页表会吃掉 512GB。 x86-64 有效虚拟地址 48 位、页 4KB,虚拟页数 = 2⁴⁸/2¹² = 2³⁶ ≈ 687 亿,每项 8 字节 → 512GB,而且每个进程一份。
多级页表利用的关键事实是地址空间极度稀疏:一个真实进程通常只映射几 MB 到几 GB,而地址空间总量是 256TB。没有映射的区域,对应的中间层页表根本不需要存在。
四级页表(PGD/PUD/PMD/PTE)每级 9 位 = 512 项 × 8 字节 = 恰好一个 4KB 页 —— 这个设计非常精巧,每一级页表本身就是一个标准页。只映射几 MB 的进程只需要几十 KB 页表。
代价是一次翻译要 4 次内存访问(每次约 100ns),如果每条访存指令都这样,程序会慢 5 倍以上 —— 所以必须有 TLB。
实际开销约为映射内存的 1/512(0.2%):64GB 内存的进程页表约 128MB,可以用 grep VmPTE /proc/<pid>/status 看到,也解释了 Redis fork 慢的原因。
Q:为什么说 TLB 是真正的性能命门?
因为它是让虚拟内存「代价可接受」的唯一原因:TLB 命中时零额外开销(与流水线并行完成),而 miss 时要遍历四级页表、最坏约 300~400ns。命中率通常 >99%。
但 TLB 非常小:典型现代 CPU 的 L2 TLB 只有 1024~2048 项,用 4KB 页时只能覆盖 8MB 内存。一个工作集 1GB 的程序根本覆盖不住 —— 在指针密集、随机访问的负载(数据库、图计算、GC 标记阶段)TLB miss 可占总时间的 10%~30%。
三个相关要点:
- 上下文切换要 flush TLB,切换后一段时间内所有访存都 miss —— 这正是 13 篇讲「上下文切换贵在 cache/TLB 而不是保存寄存器」的依据。硬件的改进是 PCID/ASID(给 TLB 条目打地址空间标识,切换时不用 flush)
- KPTI(Meltdown 缓解)让内核和用户态用两套页表,每次系统调用进出都可能影响 TLB —— 有 PCID 时开销小得多,这也是「减少系统调用次数」(io_uring)的一个背景动因
- 大页是唯一能根本改善 TLB 覆盖率的手段:2048 项 TLB 用 4KB 页覆盖 8MB,用 2MB 页覆盖 4GB
Q:缺页异常是 bug 还是特性?
绝大多数缺页是特性,只有一种是 bug:
- Minor fault(不需读磁盘,只需分配页或建映射)—— 微秒级,数量大也完全正常
- Major fault(需从磁盘读:文件映射首次访问、swap 换入)—— 毫秒级,持续增长才是问题
- Invalid fault(地址根本没映射)—— 这才是 bug,转成
SIGSEGV
缺页是内核实现各种内存特性的统一机制,七种功能全靠它:按需分页(malloc(1GB) 立即返回但零物理页,这就是 VSZ ≫ RSS)、COW(fork 时标记只读,写入时才复制)、mmap 文件(让「把 10GB 文件当数组用」成为可能)、swap 换入(PTE 的 P=0 时其余位复用来存 swap 位置)、栈自动增长(SIGSEGV 判断里唯一合法的越界访问)、THP 提升、userfaultfd(把缺页处理交给用户态 —— 虚拟机的后拷贝迁移、CRIU 靠它)。
Q:页表项里的哪些位解释了本系列讲过的现象?
- R/W 位 → COW:fork 时清掉它,写入触发缺页才复制(21 篇)
- A(Accessed)位 → 页回收的 LRU 靠它判断冷热
- D(Dirty)位 → 决定换出时是否要写盘,也是「
write()返回不等于落盘」的基础 - PS 位 → 大页
- NX 位 → 为什么现代系统上栈溢出难以直接执行注入的代码
- P=0 时其余位被复用 → swap 条目、按需分页的标记
Q:THP 为什么被数据库集体要求关闭?
三个代价:
- 分配延迟不可预测(主因) —— 要凑出 2MB 物理连续区域,内核可能需要做内存规整(compaction),移动大量页,导致触发它的进程同步阻塞几百毫秒。表现为服务偶发的长尾延迟尖刺,而且极难定位(原因藏在内存子系统,应用侧看不出异常)
- 内存放大 —— 随机稀疏访问只用到 2MB 里的几 KB,其余浪费(Redis/MongoDB 尤其明显)
- COW 粒度放大 —— fork 后写一个字节触发 2MB 复制而非 4KB,这是 Redis BGSAVE 内存放大的直接原因,也是官方文档要求关闭它的首要理由
折中方案是 madvise 模式:只对用 madvise(MADV_HUGEPAGE) 主动请求的应用启用 —— 又一个「机制与策略分离」的例子(18 篇)。观察规整开销用 grep compact_stall /proc/vmstat。
Q:Go 程序 GC 之后为什么 RSS 不下降?
因为 runtime 用 madvise 归还内存,有两种语义:
MADV_FREE(Go 1.12~1.15 默认):内核延迟回收,页仍计入 RSS 直到出现内存压力。优点是应用再次使用时无需重新缺页;缺点是 RSS 看着不降,容易被误判为泄漏,而且在容器里容易撞上memory.max(cgroup 按 RSS 算)MADV_DONTNEED(Go 1.16+ 恢复为默认):立即归还,RSS 马上下降,代价是下次访问要重新缺页
换回 MADV_DONTNEED 的主要原因就是容器环境下的可观测性与 OOM 风险。 手动归还可以用 debug.FreeOSMemory()。
另外 Go 的 VSZ 常有 1~2GB 是因为 runtime 启动时用 mmap(PROT_NONE) 预留大块地址空间(只占地址不占物理页),让堆能连续增长 —— 再次印证「VSZ 没有意义,要看 RSS/PSS」。容器里必须设 GOMEMLIMIT,否则 GC 不知道 cgroup 上限,很容易被 OOMKilled。
小结
- 虚拟内存的价值不只是「解决三个原始问题」,而是「地址翻译这个间接层」让 COW/mmap/swap/共享/ASLR/overcommit 几乎免费
- 分页赢在三点:消灭外部碎片、交换粒度细、硬件实现是纯位运算;分段退化成了 TLS 的实现手段
- 单级页表要 512GB → 多级页表利用地址空间的稀疏性;每级 9 位恰好是一个 4KB 页
- 页表开销约为映射内存的 0.2% → 这是 fork 慢的根源
- TLB 是性能命门:只能覆盖 8MB(4KB 页),miss 要 300~400ns;上下文切换的真实代价在 TLB 重建;PCID 与 KPTI 都围绕它
- 缺页是机制不是错误:按需分页、COW、mmap、swap、栈增长、THP、userfaultfd 全靠它
- 页表项的每一位都对应一个我们见过的现象:R/W→COW、A→LRU、D→脏页回写、NX→防注入
- THP 的三个代价(规整延迟、内存放大、COW 粒度放大 512 倍)解释了数据库为什么集体关闭它
- Go 的 VSZ 大是地址预留,RSS 不降是
MADV_FREE;容器里必须设GOMEMLIMIT
下一篇讲 内核如何管理物理内存:buddy 分配器如何对抗外部碎片、slab/slub 为什么要在 buddy 之上再建一层、页回收的 LRU 双链表与 refault 检测、swap 的哲学、以及 OOM Killer 的评分函数为什么这么算。
xingliuhua