Theory · OS · Virtual Memory
每个进程的 128TB 假象:地址翻译 · 多级页表 · TLB · 缺页 —— OS 最精巧的一层间接
隔离(进程间互不可见)、扩展(地址空间可大于物理内存)、灵活(按需加载 / 共享 / 写时复制)
VA → MMU 查页表(TLB 加速)→ PA;多级页表按需分配,4KB 页 + 9×4 位索引是 x86-64 的标准答案
惰性分配、COW、mmap 文件、swap——四种"故意缺页"撑起了现代内存管理的全部弹性
Why Virtual Memory
每个进程有独立虚拟地址空间:A 进程的 0x400000 和 B 进程的 0x400000 指向不同物理页。没有页表校验,进程之间互相踩内存、内核也无处藏身——多进程模型的前提。
程序可用地址空间不再受物理内存限制:暂时不用的页换到磁盘(swap),需要时再调入——用磁盘扩展 RAM,配合局部性原理,"小内存跑大程序"成立。
同一套页表机制支撑:按需加载(demand paging,不碰不装)、写时复制(fork 秒级)、共享库映射(多个进程映射同一份 libc 物理页)、mmap 文件 I/O、内存整理——全是"页映射关系"的花样。
| 对比 | 物理寻址(无 VM) | 虚拟寻址(有 VM) |
|---|---|---|
| 地址含义 | 直接是内存芯片位置 | 逻辑空间,经页表翻译 |
| 进程隔离 | 无,互相踩踏 | 天然隔离 |
| 内存超卖 | 不可能 | swap + 按需加载 |
| 重定位 | 编译时定死(换机器失效) | 链接到固定 VA 即可 |
VA → PA Translation Pipeline
Paging · Multi-level Page Table
x86-64 虚拟地址 48 位、4KB 页:每个进程要 2^36 个页表项 × 8B = 512GB 页表——比大多数机器内存还大。而进程实际只碰地址空间的一小角,98% 的页表项是浪费。
把页号切成 4 段 9 位做四级索引(PGD→PUD→PMD→PTE,最后 12 位是页内偏移):顶级页表常驻,下级节点用到才分配。稀疏地址空间只挂几个节点,页表开销从 512GB 降到 KB 级。
物理页框号 + 控制位:present(在不在内存)、RW(可写?)、US(用户态可访问?)、A(访问过,置换算法用)、D(脏,写回用)——后面缺页、置换、COW 全靠这些位。
地址不够用了:Linux 4.17 起 x86-64 支持 5 级页表(57 位虚拟地址,共 128 PiB,用户/内核各 64 PiB),有 la57 硬件才启用,默认仍 4 级。
// x86-64 4 级页表的地址切分(48 位 VA) // [47:39] PGD ← 9 位:顶级索引 // [38:30] PUD ← 9 位 // [29:21] PMD ← 9 位 // [20:12] PTE ← 9 位 // [11:0 ] offset ← 12 位 = 4KB 页内偏移 // 每级页表 4KB = 512 × 8B 项 // 2^9 = 512 → 每段正好 9 位
TLB · PCID · Huge Pages
MMU 内部的页表项缓存(VA→PA 的映射快表):小而快(L1 各几十项、L2 上千项)。命中省掉整个页表遍历——没有 TLB,每次访存都要多付 4 次内存访问。
TLB 只能缓存少量映射,靠程序的时间/空间局部性维持高命中率:顺序访问数组轻松 ~100%;指针追逐(链表、随机跳转)频繁 miss——这就是"缓存友好代码"的 TLB 侧解释。
换页表后 TLB 里的旧映射全部作废——进程切换贵就贵在这。缓解:PCID/ASID 给 TLB 项打上进程标签,切换不刷(现代 x86 默认开启);内核单独一套全局映射(global bit)。
页从 4KB 变 2MB/1GB:同样 TLB 项数覆盖地址范围扩大 512/262144 倍,miss 骤降。数据库/大内存服务(Redis、MySQL Buffer Pool)常开 THP 或 hugetlbfs。代价:分配需连续物理块、易碎片化、COW 粒度变粗(fork 后写放大 512 倍)——THP 对 Redis 的内存膨胀恶名正是后者。
| 手段 | 原理 | 代价 |
|---|---|---|
| TLB(硬件) | 缓存映射,命中免遍历 | 容量小,靠局部性 |
| PCID/ASID | TLB 项带进程标签,切换不刷 | 几乎免费,必开 |
| 2MB 大页 (THP) | 一项覆盖 512 倍范围 | 碎片 + COW 放大 |
| 1GB 大页 (hugetlbfs) | 数据库/大页专用预留 | 需预留,管理僵硬 |
| prefetch / 布局优化 | 提升局部性补命中率 | 改代码/数据结构 |
THP=never——fork + COW 时 2MB 大页让"写一个字节复制 2MB"的放大效应制造内存膨胀与延迟尖刺(Redis 持久化 deck 联动)。
Segmentation vs Paging
| 维度 | 分段 | 分页 |
|---|---|---|
| 切分单位 | 逻辑单元(代码段/数据段/栈),大小可变 | 固定大小(4KB 页) |
| 对程序员 | 可见、有语义(段 = 模块) | 透明,纯实现细节 |
| 碎片类型 | 外部碎片(段间空隙难利用) | 内部碎片(最后一页填不满) |
| 地址形式 | 段号 + 段内偏移(查段表得基址+限长) | 页号 + 页内偏移(查页表得框号) |
| 共享/保护 | 按逻辑段共享,粒度自然 | 按页共享(也是现代共享库的方式) |
| 现状 | 基本退场(x86-64 段基址强制 0) | 绝对主流 |
先分段(逻辑保护/共享),段内再分页(消外部碎片):VA → 段表 → 线性地址 → 页表 → PA。x86 32 位时代真实用过;64 位时代段被架空,只留 FS/GS 做线程本地存储(TLS)。
段基址全部归 0,分段退化成"合法性检查":FS 段基址指向 TLS/线程数据是唯一常见用途(Go runtime 用它定位 g 结构)。面试答"现代 OS = 纯分页 + 段仅剩 TLS 残留"即可。
分段 → 外部碎片(空闲块总量够、不连续没法用);分页 → 内部碎片(页内浪费,平均 ≤ 半页)。伙伴系统/Slab 是内核对物理内存分配的碎片治理(下一篇展开)。
Page Fault · The Universal Mechanism
Reclaim · Swappiness · OOM
文件页(读过的文件缓存):干净页直接丢弃(有文件兜底)、脏页先写回再回收;匿名页(堆/栈):没有文件兜底,必须写到 swap(或 zram 压缩内存盘)才能释放。回收顺序与代价不同,这就是 swappiness 的意义:0–100 调节倾向(默认 60,越低越不愿意 swap 匿名页;设 0 只是"极不愿意",不是禁用)。
每 zone 三条水位线(min/low/high):free 低于 low 唤醒 kswapd 后台回收直到 high;低于 min 则触发直接回收(进程自己边分配边回收,延迟飙升——内存毛刺的来源)。
回收谁?用近似 LRU(active/inactive 链表)挑"最久没用"的页——细节是下一篇页面置换 deck 的主角。
回收+swap 都救不回来时,内核按 oom_score(占内存多 × 加权,可用 oom_score_adj 干预)挑一个进程杀掉,释放内存保系统。Go 服务被 OOM 杀不会触发 recover——是信号级终止。
| top 指标 | 含义 | 常见误读 |
|---|---|---|
| VIRT | 虚拟地址空间总大小(含保留未用) | 大 ≠ 内存问题(Go 进程天然大) |
| RES | 实际驻留物理内存 | 判断真实占用的主指标 |
| SHR | RES 中可共享部分(共享库等) | 多进程共享库会重复计数到各自 RES |
| Swap | 已换出的匿名页 | 非 0 未必坏(长驻冷数据) |
Linux Internals & Tooling
页表太细,内核用 VMA(虚拟内存区域)管理地址空间布局:代码段、数据段、堆、mmap 区、栈各是一个 VMA(连续地址 + 统一权限 + 后备对象)。缺页时先查 VMA 定合法性,再下探页表。/proc/<pid>/maps 列出全部 VMA。
vmstat 1:si/so(swap 进出)与 cs;sar -B:majflt/s 缺页速率;ps -o min_flt,maj_flt -p PID:进程累计缺页;cat /proc/pid/smaps_rollup:Rss/Pss/Anonymous 细分(Pss 把共享页按比例分摊);free -h:buff/cache 与 available。
① free 显示内存耗尽但系统不卡:buff/cache 是"借给缓存的免费内存",看 available 才准;② 程序启动后 RES 缓慢爬升:demand paging 逐页触发 minor fault——正常惰性分配,不是泄漏。
# 进程地址空间布局(VMA 一览) $ cat /proc/self/maps | head -6 55... r--p app # 代码段(只读) 55... rw-p app # 数据段 7f... r--p libc.so # 共享库(文件映射) 7f... rw-p [anon] # mmap 匿名区/堆扩展 7ff... rw-p [stack] # 栈(向下生长) # 看真实内存压力 $ vmstat 1 | awk '{print $7,$8}' # si so $ sar -B 1 # majflt/s $ cat /proc/meminfo | egrep 'Swap|Anon'
Go × Virtual Memory
刚启动的 Go 服务 VIRT 就有几个 GB,RES 却只有几十 MB——监控常误报。原因:Go runtime 启动时按 arena 预留大片虚拟地址(mmap PROT_NONE 保留),只是地址空间预约,不占物理内存。
① 预留 + 按需提交:arena 64MB 一块,用前 mprotect 变可读写——教科书 demand paging 的手动版;② goroutine 栈:匿名内存,2KB 起步、增长靠复制,溢出前有 guard page 兜底;③ 归还:GC 后用 madvise(MADV_FREE/DONTNEED) 把冷页还给 OS——RES 缓慢下降的来源。
① 监控看 RES/PSS 而不是 VIRT;② GODEBUG=madvdontneed=1(或新版默认行为变化)影响归还语义——容器里 RES 回收观感差异的经典来源;环境内存受限用 GOMEMLIMIT(1.19+)让 GC 主动向 OS 少要内存。
| Go 行为 | 虚拟内存机制 |
|---|---|
| arena 预留大 VIRT | mmap PROT_NONE 保留地址空间 |
| 堆按需增长 | mprotect 提交 + 缺页填物理页 |
| goroutine 2KB 栈 | 匿名页 + stackguard0 软件检查 + 栈复制扩容 |
| GC 后 RES 回落 | madvise 归还冷页 |
| GOMEMLIMIT | 软内存上限,控制向 OS 的索取 |
Interview QA · Part 1
给每个进程一套独立虚拟地址空间,经 MMU 页表翻译到物理内存。三层价值:进程隔离(保护)、地址空间可超物理(swap + 按需加载)、机制复用(COW/共享库/mmap)。
单级页表按整个地址空间铺开要 512GB/进程;多级页表是稀疏树——顶级常驻、下级按需分配,实际使用只付实际开销。代价:遍历 4 次访存,由 TLB 弥补。
TLB 缓存 VA→PA 映射,命中省去页表遍历。切换进程=换页表,旧映射语义作废所以要刷;PCID/ASID 给 TLB 项打进程标签后可以不刷,显著降低切换成本。
CPU 触发 fault → 内核查 VMA 判合法(非法发 SIGSEGV)→ 按类型处理:文件页调入(Page Cache 有=minor,需读盘=major)、匿名页惰性分配、COW 复制、swap in。区别 = 要不要磁盘 I/O。
分段按逻辑单元切(大小可变、外部碎片无解);分页固定大小(内部碎片可控、任意页放任意框)。现代 OS 纯分页,x86-64 段退化到仅 TLS(FS/GS 基址)。
大页让单个 TLB 项覆盖更大范围,降低 miss;代价是 COW 粒度变粗。Redis fork 后子进程写内存触发复制,2MB 大页意味着"写一个字节复制 2MB"——内存膨胀 + 延迟尖刺,所以要 THP=never。
Interview QA · Part 2
不。malloc 只登记 VMA(地址空间预留),首次写入触发缺页才分配物理页(demand paging);Linux 还有 overcommit 超卖,所以"malloc 4TB 成功、写到一半 OOM"是常态而非 bug。
① 回收文件页(干净弃、脏写回);② 匿名页换出到 swap/zram(kswapd 按水位线后台做,耗尽则前台直接回收);③ 都不够则 OOM killer 按 oom_score 杀进程。代价逐级升高,si/so 与 majflt 是观测点。
VIRT 是虚拟地址空间(含 PROT_NONE 预留),RES 是真实驻留物理内存,SHR 是其中可共享部分。Go runtime 预留大 arena,VIRT 天然巨大——正常;监控与 OOM 判断看 RES/PSS。
数据库/低延迟服务常 swap 很小甚至关(怕 major fault 尖刺),配合 GOMEMLIMIT/资源上限控内存;通用服务保留少量 swap + 低 swappiness,回收极端时有缓冲。关 swap 后内存耗尽直接 OOM——用容量换确定性。
不能优雅处理:OOM killer 发的是 SIGKILL(不可捕获),进程直接消失。系统层留日志(dmesg 里的 oom-killer 记录);预防靠 RES 监控告警 + GOMEMLIMIT/资源限额,把"被杀"变成"主动收缩"。
看三处:sar -B 的 majflt/s(每秒主缺页——读盘加载文件页)、vmstat 的 si/so(匿名页换入换出)、/proc/meminfo 的 Dirty 与 Writeback(脏页写回挤占 I/O)。majflt 高=文件页冷读或内存被挤;si/so 高=物理内存真的不够。
Related & References
页面置换算法 →(内存不够时"换谁出去")
进程内存布局与堆分配 →(地址空间的完整地图与 malloc)
进程线程协程 →(fork + COW 的机制基础)
操作系统总览 →(缺页异常属于"异常"这一门类)
Go 内存分配与逃逸分析 →(arena 预留/提交/归还的 runtime 版)
Redis 持久化 →(fork + COW + THP 的真实代价)
LRU 缓存(手撕实现)→(哈希 + 双链表结构与 O(1) 实现)
参考来源(本 deck 结论可溯源至下列一手材料)
| OSTEP ch.13–23(地址空间与分页机制) | 虚拟化目标、多级页表、TLB、换页的完整理论框架 |
| CSAPP ch.9(Virtual Memory) | 地址翻译、页表、mmap 与动态内存分配的衔接 |
| Intel SDM Vol.3 ch.4 / AMD64 Manual | 4 级页表 9-9-9-9-12 切分、PTE 位定义、la57 |
| kernel Documentation/admin-guide/mm(THP / oom) | THP 语义、水位回收、OOM 评分 |
| go.dev doc: GOMEMLIMIT / runtime/metrics | Go 内存上限与 madvise 归还语义 |
| xiaolincoding.com《图解系统》vmem / linux_mem / mem_reclaim | 虚拟内存与回收的中文系统讲解 |