深色模式
02 · 虚拟内存:让每个程序都以为自己独占一台机器
这是 OS 模块里"最反直觉"的一篇。看懂它,你就能理解为什么程序会段错误、为什么内存能"超卖"、为什么数组比链表快。
一句话本质
给每个进程发一套假的、连续的、从 0 开始的地址,由硬件+内核在背后翻译成真实的物理地址。
生活类比:图书馆的索书号
- 虚拟地址:你在检索系统里看到的"索书号 A-001"。你只知道这个号,不知道书实际在几楼哪个架子。
- 物理地址:书真正摆放的位置。
- 页表:检索系统里的对照表(A-001 → 3 楼 7 排)。
- TLB:你手边的小纸条,记着刚才查过的几个号。查过的就不用再翻系统了。
- 缺页中断:你要的书不在架上(在仓库),管理员去取。取的过程中你得等。
- 换出(swap):架子满了,把最久没人看的书搬回仓库。
关键点:每个读者看到的索书号都从 A-001 开始,但实际对应的书完全不同。这就是进程隔离。
核心机制
一、没有虚拟内存会怎样
没有虚拟内存:
物理内存 0x0000 ------------------
0x1000 [程序 A 的代码]
0x2000 [程序 A 的数据] < A 认为自己从 0x1000 开始
0x3000 [程序 B 的代码] < B 也认为自己从 0x1000 开始!
0x4000 ...三个必然的灾难:
- 地址冲突:A 和 B 都往
0x1000写,互相覆盖。 - 无法隔离:A 的野指针能改掉 B 的数据,甚至改掉内核。
- 必须连续:程序需要 100MB 连续内存,但物理内存碎片化,凑不出来 > 明明总空闲 200MB 却分配失败。
二、加了虚拟内存之后
进程 A 的虚拟空间 物理内存 进程 B 的虚拟空间
+--------------+ +--------------+ +--------------+
| 0x0000 代码 |--+ | 0x0000 内核 | +--| 0x0000 代码 |
| 0x1000 数据 |--+-->| 0x1000 A代码|<--+--| 0x1000 数据 |
| 0x2000 堆 |--+-->| 0x2000 B数据| | | 0x2000 堆 |
| ... | | | 0x3000 A数据|<--+ | ... |
| 0xFFFF 栈 |--+ | 0x4000 空闲 | | 0xFFFF 栈 |
+--------------+ +--------------+ +--------------+
假地址 真地址 假地址三个灾难同时消失:
- 两个进程的
0x1000映射到不同物理页 > 不冲突。 - 进程看不到别人的物理页 > 隔离。
- 虚拟地址连续,物理页可以散落 > 不需要连续物理内存。
三、地址翻译的完整路径
关键理解:第 ④ 步是内核在替硬件干活,而 ②③ 是硬件(MMU)在干。这个分工是"快慢"的根源:命中 TLB 是纳秒级,缺页要读磁盘是毫秒级——差 6 个数量级。
四、为什么要多级页表
假设 32 位地址、4KB 页:虚拟地址空间有 2^20 个页,每个页表项 4 字节 > 一张页表要 4MB。
每个进程一张 4MB 页表?100 个进程就是 400MB,全用来存"这个地址不存在"了。而且大部分地址根本没用过。
多级页表解决方式:按需分配。
一级页表(1024 项,4KB)
+- 项 0 > 指向一个二级页表(4KB)
+- 项 1 > 空(这个范围没用到,不分配)
+- 项 2 > 空
+- ...进程实际只用了几个区域(代码、堆、栈),所以只需要分配几条路径上的页表。总占用从固定 4MB 降到几 KB。
代价:多了一次访存(查 4 级就是 4 次访存)。这就是 TLB 存在的理由——缓存住翻译结果,把 4 次访存压回 1 次。
五、页面置换算法
内存满了要选一个页换出,选谁?
| 算法 | 怎么选 | 优点 | 缺点 |
|---|---|---|---|
| OPT | 换出"未来最久不用"的 | 理论最优 | 无法实现(要预知未来),只作基准 |
| FIFO | 换出最早进来的 | 简单 | 有 Belady 异常(页框变多,缺页反而变多) |
| LRU | 换出最久没访问的 | 效果好 | 精确实现太贵(要每次访存都记时间戳) |
| Clock(近似 LRU) | 环形扫描,访问位=1 就跳过并清零,遇到 0 就换出 | 便宜,效果好 | 是近似 |
实际系统用 Clock(也叫 NRU)。原因:LRU 需要硬件支持或每次访存都更新链表,成本不可接受;Clock 只需要一个访问位(硬件自动置 1),扫一圈就能近似"最近没用过"。
六、抖动(Thrashing)
进程太多 > 每个进程分到的页框太少 > 频繁缺页 > 频繁换入换出
> CPU 大部分时间在等磁盘 > CPU 利用率看起来很低
> 操作系统误以为"负载不够",再加进程 > 更糟 > 死循环解法:工作集模型——统计每个进程最近用到的页集合,如果所有工作集之和超过物理内存,就挂起一些进程(降低多道程度),而不是继续换页。
为什么这么设计
反事实 1:如果没有虚拟内存
三个灾难(地址冲突、无法隔离、必须连续)会同时出现。而且程序必须在编译时就知道自己会加载到哪个地址,这对动态链接库是致命的——两个程序都链接同一个 libc,它该放在哪?
反事实 2:如果没有 TLB
每次访存都要查 4 级页表 = 4 次额外访存。程序的实际速度会降到 1/5。TLB 命中率通常在 99% 以上,这是虚拟内存"看起来不慢"的唯一原因。
反事实 3:如果页太大 / 太小
- 页太大(比如 1MB):内部碎片严重。一个只要 1KB 的程序也要占 1MB;换页时一次搬 1MB,慢。
- 页太小(比如 512B):页表项数量爆炸,页表本身占的内存和访存次数都上去了。
- 4KB 是权衡出来的经验值(x86 还支持 2MB/1GB 的大页,用于数据库、JVM 这类需要大内存连续映射的场景)。
面试怎么问
Q:什么是虚拟内存?
三层递进答:① 每个进程有独立的虚拟地址空间,从 0 开始;② 虚拟地址通过页表映射到物理地址;③ 映射关系可以指向"不在内存里的页",访问时触发缺页,由内核按需从磁盘载入。 核心价值是三个:隔离、超卖(虚拟总量可以超过物理内存)、免连续。
Q:缺页中断的完整流程?
按上面第 ④ 步列的五步答,别只说"从磁盘读进来"。要提到脏页要先写回、更新页表后要重新执行那条指令。
Q:为什么数组比链表快?(这题表面考数据结构,实际考这里)
三个原因,越往后越硬: ① 数组内存连续,一条缓存行能装多个元素,一次加载多次命中;链表节点散落,每次跳转都可能缓存未命中。 ② 数组支持硬件预取(CPU 检测到顺序访问会提前加载),链表是随机跳转,预取器无能为力。 ③ 数组不需要额外指针,同样数据量占用更少内存,缓存能装下更多有效数据。
Q:malloc 分配的内存是物理内存吗?
不是。
malloc只是从堆区划一段虚拟地址给你(小内存走brk,大内存走mmap),并没有分配物理页。真正的物理页在你第一次写入时通过缺页中断才分配——这叫按需调页。 所以malloc(1GB)能立刻返回,但真去写这 1GB 时才会变慢甚至 OOM。
追问链:
虚拟内存是什么?
> 页表存在哪?(内存里,MMU 从 CR3 找到基址)
> 页表太大怎么办?(多级页表,按需分配)
> 多级页表多了访存怎么办?(TLB)
> TLB 未命中 + 页不在内存呢?(缺页中断,走磁盘)
> 内存满了换谁?(Clock 近似 LRU)
> 换得太频繁呢?(抖动 > 挂起进程,降多道程度)和其他知识的连接
- < 来自 进程与线程:谁在跑,跑的时候带着什么:进程隔离就是靠"每个进程一套页表"实现的
- > 影响 复杂度与数组、链表:缓存局部性、数组快于链表的根本原因
- > 影响 IO 模型与零拷贝:等设备的时候,CPU 在干什么:
mmap把文件映射进地址空间,是零拷贝的基础 - > 影响 索引与 B+ 树:B+ 树一个节点设计成一页(16KB),就是为了让一次磁盘 IO 读到一个完整节点
- > 影响 事务与 ACID:
fsync强制刷盘,本质是"别让页缓存的延迟骗了持久性"
自测 3 问
Q1:malloc(1GB) 为什么能瞬间返回?
点开看答案
因为它只分配了虚拟地址空间,没有分配物理内存。
大块内存(通常 >128KB)走 mmap,内核只在页表里登记"这段虚拟地址有效",物理页一个都没给。等程序真正写入某个页时,触发缺页中断,内核才分配一个物理页。
这叫按需调页(demand paging)。好处是:申请了但没用的内存不占实际资源;坏处是:程序可能在写入时才突然变慢或被 OOM Killer 杀掉。
Q2:为什么说"程序里的指针值是虚拟地址"?
点开看答案
因为你 printf("%p", ptr) 打出来的,就是虚拟地址。
同一个程序跑两次,同一个变量打印出来的地址通常是一样的(因为虚拟地址空间布局固定,有 ASLR 也只是随机化基址),但对应的物理地址每次都可能不同。
这也解释了为什么"把指针值存到文件里,下次运行再用"是错的——虚拟地址只在当次进程内有效。
Q3:页面置换为什么不用 LRU,而用 Clock?
点开看答案
因为精确 LRU 太贵。
精确 LRU 需要每次访存都更新"最近访问时间",要么在硬件里加时间戳(贵),要么每次访存都陷入内核改链表(不可接受,一次访存变几微秒)。
Clock 只需要一个访问位:硬件在访存时自动置 1,内核换页时环形扫一遍,遇到 1 就清零跳过,遇到 0 就换出。开销极低,效果接近 LRU。
这是典型的工程取舍:用 95% 的效果换 1% 的成本。
类比失效处 / 易错点
- "索书号"类比会让人以为映射是一对一的。实际上多个虚拟页可以映射到同一个物理页(共享内存),一个虚拟页也可以暂时没有映射(未分配)。
- "每个进程从 0 开始"不严谨。32 位 Linux 用户空间是
0x0 ~ 0xBFFFFFFF,内核占高地址0xC0000000以上,所有进程共享这部分内核映射。 - 缺页 ≠ 错误。缺页是正常机制(按需调页),只有访问非法地址(页表项无效且不属于任何合法区域)才是段错误。
- swap 不等于虚拟内存。虚拟内存是地址翻译机制,swap 只是"物理内存不够时的后备存储"。没有 swap 也能有虚拟内存。
- 多级页表的层数:32 位通常 2 级,64 位通常 4 级(x86-64),不是"级数越多越好"。
✅ 验收清单
- 能说出虚拟内存解决的三个具体问题(隔离 / 超卖 / 免连续),每个配一个"不这么做会出的事故"
- 能画出虚拟地址 > TLB > 页表 > 物理地址的完整翻译路径,标出哪几步是硬件、哪几步是内核
- 能完整背出缺页中断的 5 个处理步骤(含"脏页先写回"和"重新执行指令")
- 能解释多级页表为什么能省内存,以及它带来的代价和补偿手段
- 能说清 Clock 算法为什么能替代 LRU(要说出"访问位"和"成本")
- 能解释
malloc(1GB)为什么瞬间返回 - 合上书,用自己的话讲 3 分钟,中途不卡壳