虚拟内存:从局部性原理到请求分页
虚拟内存:从局部性原理到请求分页
引言
前几篇文章中,我们一直在讨论"进程所有部分必须先装入内存才能运行"这个前提下的各种方案。但这个前提本身值得反思:进程真的需要全部装入内存后才能开始执行吗?
考虑一个典型的程序。它可能包含几十个功能模块,但用户在一次使用中通常只用到其中一小部分。大量的错误处理代码可能永远不会被执行,很多高级功能对大多数用户来说是"沉睡代码"。如果操作系统可以在程序运行时只装入当前实际需要的那部分——甚至可以在运行过程中根据需要动态装入和淘汰页面——那程序的逻辑地址空间就可以远大于物理内存。
这就是虚拟内存的核心思想。
📌 核心要点
- 虚拟内存的三个特征:多次性(程序分多次装入)、对换性(运行中可换入换出)、虚拟性(逻辑地址空间可以远大于物理内存)。传统存储管理的两个局限——一次性和驻留性——被同时消解。
- 缺页中断是唯一一种可以在指令执行期间(Instruction Boundary 之内)而非指令之间发生的内部中断,这是它与一般 I/O 中断最关键的区别。
- EAT = (1-p) × 内存访问时间 + p × 缺页处理时间。当 p 为 10⁻⁶ 量级时,缺页处理时间的量级优势足以让 EAT 恶化数倍——缺页率即便极低,也不能被忽视。(408 统考大纲,2026)
常规存储管理的两个局限
在引入虚拟内存之前,传统的内存管理方式有两个隐含假设:
一次性(Residence in Full)
进程必须一次性全部装入内存才能运行。如果物理内存装不下整个进程,它就根本不能启动。这个限制在早期的单道程序系统中可能还能接受,但在多道程序并发环境下,物理内存的竞争变得激烈——你想同时运行浏览器、邮件客户端和 VS Code,但三者加起来远大于你的 8GB 物理内存。一次性假设让这一切变得不可能。
驻留性(Permanent Residence)
进程一旦被装入内存,就一直驻留到运行结束。即便进程的某一部分已经执行完毕且再也不会被访问,它依然占据着宝贵的内存空间。这就好比你家的书桌上堆满了已经翻过的资料,而正在看的书却找不到地方放——只有等整个项目做完,才能把所有东西一起收走。
这两个局限放到一起,给传统存储管理画了一道硬上限:最大进程数 × 每进程所需最小内存 ≤ 物理内存总量。多道程序并发度的上限被物理内存大小死死地锁住了。虚拟内存的诞生不只是在解决"程序可以比内存大"——更本质的是在解决"如何让更多进程同时在内存中运行"这个并发度问题。
局部性原理——虚拟内存能工作的根本原因
虚拟内存看起来像一个魔术:你给了程序一个比物理内存大得多的地址空间,程序以为自己拥有它的全部,但实际上操作系统可能只把其中一小部分真正装在物理内存中。程序怎么不会崩溃?
答案藏在程序的访问模式中。1968 年,Peter Denning 正式将这种模式命名为"局部性原理"(Principle of Locality)。
时间局部性
如果程序访问了某个指令或数据,那么它在不久的将来很可能再次访问。这不是玄学——你代码里的循环体、频繁调用的工具函数、反复使用的计数器变量,全都是时间局部性的体现。程序的绝大部分执行时间花在极少数的"热代码"上。
空间局部性
如果程序访问了某个内存位置,那么它附近的地址也很可能被访问。顺序执行的指令流是空间局部性的最强表现——程序计数器 PC 大多数时候只是 +1。而数组的线性遍历、栈帧中相邻局部变量的访问,同样展现出强烈的空间局部性。
局部性如何支撑虚拟内存
既然程序的访问天然是聚集在少数区域的,那就不需要把所有代码和数据一次性装入内存。只需要保证"当前正在被访问的区域"在物理内存中,其他的可以留在磁盘上。当程序访问到不在内存中的部分时,再由操作系统动态地从磁盘装入。因为局部性的存在,这种"按需装入"的触发频率很低——这也是为什么缺页率可以低至 10⁻⁶ 量级。
虚拟内存的定义与三个特征
虚拟内存(Virtual Memory)是指:在操作系统的管理下,系统向用户程序提供一个比实际物理内存大得多的、统一的逻辑地址空间。用户程序面对的是一个完整的、从 0 到 N-1 的逻辑地址空间,而操作系统在幕后将逻辑地址映射到物理内存和磁盘之间。
虚拟内存有两个主要实现方式:
- 请求分页(Demand Paging):在基本分页基础上增加页的换入换出能力——最主流的方式,也是 408 考研的考察重点。
- 请求分段(Demand Segmentation):在基本分段基础上增加段的换入换出能力——因分段本身的外部碎片和复杂性,实际应用较少。
虚拟内存有三个区别于传统存储管理的根本特性:
- 多次性:进程可以分多次装入内存,不再要求一次性全部装入。
- 对换性:进程在运行过程中,允许页或段在内存和磁盘之间换入换出。
- 虚拟性:进程的逻辑地址空间可以远大于物理地址空间。
这三个特征不是独立存在的——多次性是对换性的前提(你得先允许"分次装入"才能谈"运行时换入换出"),而对换性是虚拟性的基础(因为可以换,才能用有限的物理内存模拟出更大的逻辑空间)。
请求分页——虚拟内存的主力实现
请求分页(Demand Paging)是在基本分页的基础上,为页表项增加若干控制字段,使操作系统能够在运行时动态地将需要的页从磁盘装入、将暂时不用的页换出到磁盘。
扩展的页表项
在基本分页的页表项只包含"页框号"的基础上,请求分页增加了四个关键字段:
| 字段 | 符号 | 含义 |
|---|---|---|
| 状态位 | P | P = 1:该页已在物理内存中;P = 0:该页不在(未分配 或 在磁盘上)。访问 P = 0 的页触发缺页中断。 |
| 访问字段 | A | 记录该页在最近一段时间内被访问的次数或是否被访问过,供页面置换算法参考。 |
| 修改位 | M | M = 1(脏页)表示该页被修改过,换出时需写回磁盘;M = 0(干净页)可被直接覆盖,无需写回。 |
| 外存地址 | — | 记录该页在磁盘交换区中的存储位置,用于缺页时将页的内容从磁盘读入内存。 |
修改位的存在有巨大的工程价值:换出一个干净页只需要"放弃"对应的页框(页面内容在磁盘上已经有副本),换出一个脏页则需要一次磁盘写入操作——时间开销在毫秒 vs. 微秒的量级差异。这就是为什么 Linux 内核中有专门的脏页回写(Writeback)机制来管理这一过程。
缺页中断——请求分页引擎的核心
当 CPU 试图访问一个 P = 0 的逻辑页时,硬件触发缺页中断(Page Fault)。操作系统的缺页中断处理程序执行以下步骤:
- 保护 CPU 现场:保存被中断指令的地址、各寄存器状态。
- 分析原因:确认是"页不在内存"而非越界访问或保护违例。如果是后者,直接终止进程。
- 寻找空闲页框:从空闲页框链表找一个空闲页框。若没有空闲的,运行页面置换算法淘汰一个页。
- 从磁盘读入页面:根据页表项中的外存地址,执行一次磁盘 I/O 操作,将页面内容读入所选页框。
- 更新页表:将该页表项的页框号设为新分配的页框号,P = 1。
- 恢复 CPU 现场:返回到被中断的指令,重新执行——这次页表项中的 P = 1,可以正常完成地址变换。
⚠️ 注意事项:缺页中断与一般 I/O 中断有一个关键区别,这恰是 408 的高频考点:
一般中断(如时钟中断、键盘中断)发生在完好的指令执行之后(指令边界之间),中断处理结束后返回到下一条指令继续执行。
缺页中断发生在一条指令的执行过程之中——当这条指令试图访问一个不在内存中的页时,指令尚未执行完毕。缺页处理完成后,返回的是被中断的那条指令处重新执行,而不是下一条。这意味着缺页中断保存的"返回地址"是被中断指令的地址,而非下一条指令的地址。
[CHART: 请求分页地址变换完整流程 → charts/03-page-fault-flowchart.svg]
Effective Access Time——缺页率的性能放大效应
请求分页的根本性能代价是:如果访问的页不在内存中,就需要一次磁盘 I/O。内存访问是纳秒级(~50ns),磁盘 I/O 是毫秒级(~8ms)——差了五个数量级。
EAT 公式
设:
- p = 缺页率(Page Fault Rate),p ∈ [0, 1]
- ma = 内存访问时间(Memory Access time)
- pft = 缺页处理时间(Page Fault overhead Time),主要包括磁盘读写时间 + 进程调度开销
EAT = (1-p) × ma + p × pft
这个简单的公式背后承载着一个非直觉的结论。假设 ma = 100ns,pft = 8ms = 8,000,000ns:
- p = 0(从不缺页)→ EAT = 100ns
- p = 10⁻⁶(百万分之一)→ EAT = 0.999999×100 + 0.000001×8,000,000 = 99.9999 + 8 = 约 108ns
- p = 10⁻⁴(万分之一)→ EAT = 99.99 + 800 = 约 900ns——性能下降 9 倍
这个计算揭示了虚拟内存设计中的一个关键约束:缺页率不需要"很高"就能显著影响性能。因为内存和磁盘的绝对速度差距太大,即便 10,000 次访问中只有 1 次触发缺页,性能也会下降近 10 倍。这就是为什么页面置换算法的好坏——哪怕只把缺页率从 10⁻⁴ 降到 10⁻⁵——都有巨大的工程价值。
虚拟内存的工程特征
地址翻译的额外开销
虚拟内存在每次访存时都引入了地址翻译流程:TLB 查找 →(未命中)→ 查页表(甚至多级页表)→(P = 0)→ 缺页处理 → 磁盘 I/O。这条链路上每一步都有代价,但 TLB 的高命中率和高缺页门槛使得 99.99% 以上的访存只走"TLB 命中"这条快路径。
64 位虚拟地址空间的实际使用范围
理论上 64 位地址空间是 2^64 = 16EB(Exabyte),但现代 CPU 实际只使用其中一部分。在 x86-64 架构中,当前实现的虚拟地址是 48 位(256TB),物理地址可以达到 52 位(4PB)。这 256TB 的虚拟地址空间对于绝大多数程序来说"等于是无限的"——没有哪个单机程序在可预见的未来会用满它。
Overcommit 与 OOM Killer
Linux 支持"内存过量使用"(Memory Overcommit)——允许多个进程申请的虚拟内存总和超过物理内存总量,赌的就是它们不会同时使用各自申请的所有内存。当这种乐观估计落空时,OOM Killer(Out-Of-Memory Killer)会强制终止某些进程来释放内存。这是虚拟内存在实践中最极端的"容错"案例——但考研不考 OOM Killer,知道即可。
FAQ:常见问题速查
Q: 缺页中断和普通 I/O 中断有什么本质区别?
缺页中断是内部中断(由 CPU 执行指令时自己检测到并触发),发生在指令执行期间;处理完成后返回的是被中断的指令本身(重新执行)。普通 I/O 中断是外部中断(由 I/O 设备控制器通过中断信号线通知 CPU),发生在两条指令之间;处理完成后返回到下一条指令。408 考题经常让你判断"某中断处理后应返回被中断指令还是下一条指令"。
Q: 虚拟内存可以让一个程序使用的内存超过物理内存吗?
可以——超出部分存储在磁盘的交换区中。程序运行时,操作系统只把当前正在使用的活跃页面驻留在物理内存,其余页面放在磁盘上。当程序访问不在内存中的页面时,通过缺页中断动态装入。但要注意,“逻辑地址空间可以比物理内存大"不等于"同时活跃使用的页面可以比物理内存多”——如果活跃工作集超过了物理内存,就会陷入频繁缺页的抖动(Thrashing)状态,下一篇会详细讨论。
Q: 408 统考的 EAT 计算题容易在哪里出错?
最常见两个坑:一是把缺页率乘以纯磁盘访问时间,忘记缺页处理还包括内存访问部分(如更新页表时的一次或多次访存);二是当题目把"缺页率"表述为"每 N 次访问缺页一次"时,p = 1/N,而非 N。另外,题干中"每次访存"和"每次指令取操作数"是有区别的——一条指令可能涉及多次内存访问(取指令 + 取操作数),每种访问都独立走地址变换流程。
总结
虚拟内存将"地址空间"和"物理内存"解耦:进程面对一个统一、完整、从零开始的逻辑地址空间,操作系统在背后将其中活跃的页面映射到物理内存。局部性原理保证这种映射的触发频率极低,而请求分页的缺页中断机构保证在需要时可以透明地从磁盘装入。
但缺页的频率——那个小小的 p——控制着整个虚拟内存系统的性能命脉。这就引出了下一个核心问题:当多个进程争抢有限的物理页框时,该把谁的页换出去?而什么样的页面置换策略才能让 p 尽可能小?这些内容我们下一篇全部讲清楚。
[INTERNAL-LINK: 页面置换算法与实战机制——从 LRU 到内存映射文件 → 第三章第 5 篇]
📚 延伸阅读
- Operating System Concepts, 10th Edition, Chapter 10: Virtual Memory
- Denning, P.J., “The Working Set Model for Program Behavior”, Communications of the ACM, 1968
- Linux Memory Management Documentation, kernel.org
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)