Skip to content

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  ...

三个必然的灾难:

  1. 地址冲突:A 和 B 都往 0x1000 写,互相覆盖。
  2. 无法隔离:A 的野指针能改掉 B 的数据,甚至改掉内核。
  3. 必须连续:程序需要 100MB 连续内存,但物理内存碎片化,凑不出来 > 明明总空闲 200MB 却分配失败。

二、加了虚拟内存之后 ​

  进程 A 的虚拟空间          物理内存              进程 B 的虚拟空间
  +--------------+      +--------------+      +--------------+
  | 0x0000 代码  |--+   | 0x0000 内核  |   +--| 0x0000 代码  |
  | 0x1000 数据  |--+-->| 0x1000  A代码|<--+--| 0x1000 数据  |
  | 0x2000 堆    |--+-->| 0x2000  B数据|   |  | 0x2000 堆    |
  | ...          |  |   | 0x3000  A数据|<--+  | ...          |
  | 0xFFFF 栈    |--+   | 0x4000 空闲  |      | 0xFFFF 栈    |
  +--------------+      +--------------+      +--------------+
      假地址                 真地址                 假地址

三个灾难同时消失:

  1. 两个进程的 0x1000 映射到不同物理页 > 不冲突。
  2. 进程看不到别人的物理页 > 隔离。
  3. 虚拟地址连续,物理页可以散落 > 不需要连续物理内存。

三、地址翻译的完整路径 ​

← 左右滑动看完整图 →TLB 命中:1 个周期,直接拿到页框号② TLB 快表虚拟地址0x1234① 页号 + 偏移0x1 | 0x234③ 查页表0x1 → 0x7A⑤ 物理地址0x7A234页大小 4KB,低 12 位是页内偏移④ 缺页中断(内核处理)分配页框 · 脏页写回 · 读盘 · 改页表 · 重执行指令存在位=0
拆地址、查 TLB、走页表都是 MMU 在硬件里干的,纳秒级;只有「存在位=0」才掉进内核的缺页处理,那一步要读盘,慢 6 个数量级。同一次翻译里,快慢的分界线就画在 ③ 和缺页之间。

关键理解:第 ④ 步是内核在替硬件干活,而 ②③ 是硬件(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)
            > 换得太频繁呢?(抖动 > 挂起进程,降多道程度)

和其他知识的连接 ​

自测 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 分钟,中途不卡壳