页面置换算法与实战机制:从 LRU 到内存映射文件
引言
前面我们建立了虚拟内存的框架:程序不需要全部装入内存,缺页时动态从磁盘装入。但物理页框的数量是有限的------当所有页框都被占满、又发生了新的缺页中断时,操作系统必须做出选择:把哪个正在内存中的页换出去,为即将装入的页腾出位置?
这就是页面置换算法(Page Replacement Algorithm)要解决的问题。如果选择得当------淘汰一个以后很长时间不会再被访问的页------系统的缺页率就低、性能就好。如果选择不当------淘汰掉一个马上又要被访问的页------刚换出又得换入,系统就会频繁缺页,性能崩溃。
📌 核心要点
- OPT(最优置换)算法作为不可实现的理论基准,提供缺页率下界;LRU 是理论上最接近 OPT 且可实现的算法,但硬件的完全 LRU 实现开销太大。
- CLOCK(NRU)和改进型 CLOCK 是操作系统中实际使用的页面置换算法------用一个访问位 A 和一个修改位 M 近似模拟 LRU 的淘汰顺序,硬件实现极其简单。
- Belady 异常只发生在 FIFO 算法中:增加分配给进程的物理页框数,缺页次数反而可能增加。LRU 和 OPT 属于"栈算法"(Stack Algorithm),不受此影响。
- 工作集(Working Set)模型用窗口参数 Δ 来定义"最近 Δ 次访问中涉及的页面集合";当系统中所有进程的工作集总和超过物理内存时,系统进入抖动(Thrashing)状态。
页面置换算法(一)------从理论到实践
为方便说明,我们用以下经典题型中的访问串来演示各算法:
假设系统为进程分配了 3 个物理页框,页访问串为:
7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
最优置换(OPT / Belady's Optimal Algorithm)
策略:淘汰在未来最长时间内不会再被访问的页面。
对于访问串 7 0 1 2 ...:初始连续装入 7、0、1(三次缺页)。第四次访问 2 时,三个页框都满了,需要置换。此时查看 7、0、1 在未来哪一页最晚才会被用到------7 在第 18 次访问才会重新出现,0 在第 5 次就会出现,1 在第 14 次重新出现。显然该淘汰 7。
OPT 的缺页次数是这个访问串在给定页框数下能达到的理论最小值。对于上述序列和 3 个页框,OPT 产生 9 次缺页(含最初的 3 次强制缺页)。
OPT 不可实现 ,因为它要求预知未来的访问序列------这在实际系统中不可能做到。但它的价值巨大:它提供了一个下界(Lower Bound),让其他算法的缺页数可以与之比较------距 OPT 越近,算法越好。它也是证明"一个算法是否出现 Belady 异常"的参照。
先进先出(FIFO)
策略:淘汰最早进入内存的页面------在内存中驻留时间最长的页先出去。可以用一个 FIFO 队列实现。
对于上述序列和 3 个页框:
| 时刻 | 访问 | 页框 0 | 页框 1 | 页框 2 | 缺页? |
|---|---|---|---|---|---|
| 1 | 7 | 7 | - | - | ✓ |
| 2 | 0 | 7 | 0 | - | ✓ |
| 3 | 1 | 7 | 0 | 1 | ✓ |
| 4 | 2 | 2 | 0 | 1 | ✓ (淘汰 7) |
| ... | ... | ... | ... | ... | ... |
FIFO 在这个序列上产生 15 次缺页------远多于 OPT 的 9 次。
FIFO 的优点是实现极其简单:一个队列搞定。缺点是与程序的实际访问模式毫无关联------一个进程可能在运行初期装入了一个初始化函数所在的页面,从此再也不访问了,但 FIFO 会把它保留到最后,因为它"进来得早"。
最近最久未使用(LRU, Least Recently Used)
策略:淘汰最近最长时间未被访问的页面。直觉上这模拟了"最优"------如果过去很久没用,未来大概也不会马上用到。
LRU 有两种硬件实现方案:
实现方案一:计数器(Counter)
CPU 内部为每个页关联一个时间戳计数器。每发生一次内存访问,当前时钟值就写入被访问页面关联的计数器中。需要置换时,遍历所有页,选择计数器值最小的那个(即最久未被访问的)。
缺点:每次内存访问都要更新计数器,置换时需要遍历全表比较------开销不可忽略。而且计数器会溢出,需要定期重置。
实现方案二:栈(Stack)
用一个特殊的栈结构:每访问一个页,就把该页号从栈中弹出(如果它在栈中)再压入栈顶。栈顶始终是"最近刚被访问"的页,栈底是"最久未被访问"的页。需要置换时,直接淘汰栈底的页。
每次访问需要更新栈(查找和移动页号),开销同样不小。但栈方案比计数器更直观------不需要做"比较计数器值"这个步骤。
对于上述序列和 3 个页框,LRU 产生 12 次缺页------多于 OPT 的 9 次,但远好于 FIFO 的 15 次。
LRU 在数学上与 OPT 共享一个重要特性:它们都属于栈算法 (Stack Algorithm)。栈算法的定义是:对于同样的访问序列,给 N 个页框时的内存页面集合总是包含在给 N+1 个页框时的内存页面集合之中。这个特性的直接推论是:它们不会出现 Belady 异常------多给页框只会让缺页更少或不变,绝不会更多。FIFO 不属于栈算法,所以它有可能出现 Belady 异常。
页面置换算法(二)------CLOCK 系列的工程智慧
完全的 LRU 需要每次访问都更新硬件数据结构------计数器或栈。这在 1970 年代的硬件上开销过于昂贵。实际操作系统需要一种近似 LRU 但硬件开销极小的方案。
简单 CLOCK(NRU, Not Recently Used)
CLOCK 算法也被称为最近未使用算法 (NRU),核心操作依赖一个硬件支持的访问位(Access Bit / Reference Bit)。每当一个页面被访问时,硬件自动将该页的访问位设为 1。操作系统可以读取和清零这个位。
算法用一个循环指针(像时钟的指针一样绕圈),每次需要置换时:
- 从指针当前位置开始扫描。
- 如果当前页的访问位 = 0:选择该页淘汰。
- 如果当前页的访问位 = 1:将访问位清零("再给一次机会"),指针继续前进。
- 重复步骤 2-3,直到找到一个访问位 = 0 的页。
如果所有页的访问位都是 1(全部被访问过),指针会绕一整圈,把所有访问位清零,然后回到最初的位置------这次的访问位已经是 0 了,淘汰之。
CLOCK 的行为近似于 LRU:最近被访问过的页(访问位 = 1)获得了一次"豁免",只有"最近未被访问过"的页(访问位在被清零后仍为 0)才会被淘汰。而这个"豁免"是通过一个 1-bit 的访问位实现的------硬件成本几乎为零。
改进型 CLOCK(Enhanced CLOCK / Second Chance with Modify Bit)
简单 CLOCK 只看"是否被访问过",但没有考虑"是否被修改过"。如果被淘汰的页是脏页(M=1,有写入),就需要先写回磁盘------增加一次磁盘 I/O。如果淘汰的是干净页(M=0),可以直接覆盖------省掉一次写回。
改进型 CLOCK 同时考虑访问位 A 和修改位 M,将页面分为四个优先级(优先级越高,越优先被淘汰):
| 优先级 | (A, M) | 含义 | 淘汰策略 |
|---|---|---|---|
| 第一优先(最优淘汰) | (0, 0) | 最近未访问、未被修改 | 直接淘汰,零额外 I/O |
| 第二优先 | (0, 1) | 最近未访问、但被修改过 | 淘汰前需要写回磁盘(一次 I/O) |
| 第三优先 | (1, 0) | 最近被访问、但未被修改 | 可能很快又会被访问,推迟淘汰 |
| 第四优先(最后淘汰) | (1, 1) | 最近被访问、且被修改过 | 最不该淘汰的页 |
算法流程:
- 从指针当前位置开始,第一轮扫描:寻找 (0, 0) 的页。找到即淘汰,不修改任何访问位。
- 若第一轮没找到,第二轮扫描:寻找 (0, 1) 的页。扫描过程中将经过的页的访问位清零。找到后淘汰,但需要先写回磁盘。
- 若前两轮都没找到,所有页的访问位都已被清零(M 保持原值),此时重新执行第一、第二轮的逻辑------最终一定能淘汰一个页。
改进型 CLOCK 的工程价值在于:用一个 2-bit 的 (A, M) 组合就实现了对 LRU 的良好近似 同时 把"写回磁盘"的次数最小化。从 Linux 2.6 内核的页面回收算法,到现代 BSD 系统的页面置换,改进型 CLOCK 的变体广泛存在于真实的操作系统内核中。
各算法缺页率直观对比(以上述 7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1 序列,3 页框)
| 算法 | 缺页次数 | 缺页率 | 特征 |
|---|---|---|---|
| OPT | 9 | 45% | 理论下界,不可实现 |
| LRU | 12 | 60% | 最接近 OPT 的可实现算法 |
| CLOCK | 约 13 | 约 65% | 取决于顺序,接近 LRU |
| FIFO | 15 | 75% | 简单但与访问模式无关 |
CHART: 页面置换算法缺页次数对比 → charts/02-page-replacement-comparison.svg
Belady 异常------FIFO 才有的反直觉现象
Belady 异常是 408 统考中一定会出现的考点。它的定义是:对于 FIFO 页面置换算法,增加分配给进程的物理页框数,缺页次数反而可能增加。
直觉上,更多的页框意味着更多的页可以同时驻留在内存中,缺页应该更少。这个直觉对 LRU 和 OPT 是正确的(因为它们属于栈算法),但对 FIFO 不成立。
经典例题 :对于页访问串 1 2 3 4 1 2 5 1 2 3 4 5:
- 分配 3 个页框 → FIFO 缺页 9 次
- 分配 4 个页框 → FIFO 缺页 10 次(多了!)
这就是 Belady 异常------多给了一个页框,缺页反而多了一次。(具体的手工推演过程是 408 真题的经典题型,建议读者自行画一遍,体会 FIFO 的非栈特性。)
栈算法(如 LRU、OPT)满足包含性:N 个页框时的内存页面集合 ⊆ N+1 个页框时的内存页面集合。因此增加页框数不会引入新的缺页------这就是为什么 LRU 和 OPT 不受 Belady 异常影响。FIFO 不满足包含性------加了一个页框之后,原有的一些页面被淘汰的时机发生了变化,有时反而更不利。
💡 实践建议:408 考题中判断"某算法是否会出现 Belady 异常"的捷径:看它是否为栈算法。LRU 和 OPT 一定不会出现;FIFO 和其他非栈算法可能出现。考试中如果出现"增大页框数,缺页反而增加"的描述,直接指向 FIFO。
页面分配策略------每进程该拿多少页框
在多道程序环境中,物理页框需要在多个进程之间分配。这是一个全局决策:给进程 A 多些页框意味着进程 B 必须少些------而每个进程的缺页率都受分配数量的影响。
三个维度的组合
固定分配 vs 可变分配:
- 固定分配:每个进程在生命周期中被分配固定数量的物理页框,不变。
- 可变分配:进程的页框数可以根据其运行情况和系统负载动态调整。
局部置换 vs 全局置换:
- 局部置换:发生缺页时,只能从该进程自己的页面中选择淘汰页------不影响其他进程。
- 全局置换:发生缺页时,可以从系统中所有进程的页面中选择淘汰页------可能会减少其他进程的页框数。
两者的交叉组合产生三种可行方案:
| 方案 | 分配 | 置换 | 特点 |
|---|---|---|---|
| 固定分配 + 局部置换 | 固定 | 局部 | 简单但缺少灵活性。若分配过少则缺页频繁;若分配过多则浪费。 |
| 可变分配 + 全局置换 | 可变 | 全局 | 系统中各进程的页框数随缺页率动态调整。实现最灵活,但可能引发进程间的"无辜牵连"。 |
| 可变分配 + 局部置换 | 可变 | 局部 | 每个进程的页框数独立调整,置换也独立。是 Linux 等现代 OS 的常用策略。 |
⚠️ 固定分配 + 全局置换是无意义的组合------如果页框固定不变,整个系统的页框总数被锁死,"全局"置换就失去了意义:你无法从进程 A 拿一个页框给进程 B 而不改变 A 的页框数。
工作集模型与抖动------当大家都在抢内存
工作集(Working Set)
1968 年,Peter Denning 提出了工作集模型。定义:进程在最近 Δ 次(或 Δ 时间内)的页面访问中所涉及的页面集合,称为该进程的工作集 W(t, Δ)。其中 Δ 是工作集窗口(Working Set Window)。
工作集的大小反映了进程在当前的"活跃内存需求"。如果进程当前的工作集可以完全驻留在物理内存中,该进程处于正常状态。如果系统分配给进程的页框数小于它的工作集大小,该进程就会频繁缺页。
工作集模型的一个直接应用:操作系统可以监控每个进程的工作集大小,据此动态调整页框分配。如果一个进程的工作集变大,给它更多页框;如果工作集缩小,回收多余页框给其他进程。
抖动(Thrashing)
抖动是虚拟内存系统中最严重的性能灾难。它的成因链是:
- 系统中的并发进程太多,每个进程分到的页框太少。
- 每个进程的工作集都无法完全驻留在内存中 → 缺页率飙升。
- 每个缺页都触发磁盘 I/O → 进程排队等待 I/O。
- CPU 利用率骤降(因为所有进程都在等磁盘)。
- 操作系统调度器检测到 CPU 利用率低,以为并发度不够 → 再调入更多进程。
- 回到第 1 步,循环恶化。
在这个恶性循环的终点,系统进入抖动状态:大量时间花在页面换入换出上,CPU 实际有效的计算时间趋于零。
PFF(Page Fault Frequency)------缺页率驱动的自适应分配
防治抖动的一种有效策略是监控每个进程的缺页频率(Page Fault Frequency):
- 如果进程的缺页率高于设定的上界(说明页框太少、工作集装不下)→ 给它分配更多页框。
- 如果进程的缺页率低于设定的下界(说明页框太多、浪费)→ 回收一些页框分给更需要它们的进程。
- 如果系统中所有进程的缺页率都高、且没有空闲页框可分配 → 系统进入抖动,应该挂起某些进程(将其交换出内存)。
页框回收与内存映射文件
页框回收
页框回收与页面置换是一体两面的问题。置换算法决定"淘汰谁",页框回收决定"什么时候淘汰"和"回收多少"。现代 Linux 内核的页框回收由 kswapd 内核线程和直接回收(Direct Reclaim)机制共同驱动,使用基于 LRU 链表(active list / inactive list)的方案,本质上是改进型 CLOCK 的链表化变体。
内存映射文件(Memory-Mapped Files, mmap)
内存映射文件是一种特殊的 I/O 机制:操作系统将一个文件的内容直接映射到进程的虚拟地址空间中。之后,进程对这片内存区域的读写操作,会被操作系统透明地转换为对文件的读写------不再需要使用 read() / write() 系统调用。
在 Linux 上,通过 mmap() 系统调用实现:
c
// 将文件 data.bin 映射到进程的地址空间
int fd = open("data.bin", O_RDWR);
void *addr = mmap(NULL, file_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// 此后 addr[100] = 0xFF 等同于直接写入文件的偏移 100 处
mmap 相比传统 read/write 有两大优势:
- 减少数据拷贝次数:read/write 需要"磁盘 → 内核缓冲区 → 用户缓冲区"两次拷贝;mmap 直接映射内核页缓存到用户空间,省掉一次拷贝。
- 操作系统负责 I/O 调度和缓存管理:脏页由内核统一写回,不需要应用程序手动管理刷盘时机。
但这也有代价:对于顺序只读的小文件,read/write 的预读(Readahead)机制通常更高效;mmap 在处理随机访问的大文件(如数据库存储引擎)时优势更明显------这也是为什么 MongoDB(WiredTiger)和 LMDB 等数据库广泛使用 mmap。
⚠️ 注意事项:mmap 的错误处理比 read/write 更微妙------写入映射区后程序可能"以为"写成功了(因为没有返回错误码),但实际 I/O 错误(如磁盘满)可能在稍后才以 SIGBUS 信号的形式暴露。这是工程中使用 mmap 需要额外处理的边界情况。
FAQ:常见问题速查
Q: CLOCK 算法为什么比真正的 LRU 更实用?
真正的 LRU 需要每次访存都更新硬件数据结构(计数器或栈),这在现代 CPU 上意味着每次内存访问都附带额外的寄存器和比较操作------性价比太低。CLOCK 只需要一个 1-bit 的访问位,由硬件在页表遍历时自动设置,软件只需在置换时读取和清零------硬件成本趋近于零。改进型 CLOCK 用两个位(A + M)实现的效果已经非常接近 LRU。
Q: Belady 异常在考试中如何快速判断和计算?
先判断算法类型:如果题目中出现 FIFO → 可能存在 Belady 异常;如果是 LRU 或 OPT → 一定没有。具体验证方法:手工建表,给定页访问串,分别计算 N 和 N+1 个页框的缺页次数。若缺页次数不降反升,则 Belady 异常成立。考试中典型的验证从 3 个页框开始,分别算 3 和 4 个页框的结果即可。
Q: mmap 有什么风险?什么时候不应该用它?
mmap 的主要风险:一是文件大小不可变时映射会失败或产生 SIGBUS(所以更新文件大小时需要 fallocate);二是错误检测被延迟(写入时不报告 I/O 错误);三是映射大文件可能导致页表占用过多的内存(因为每个映射页都需要页表项)。不适合用 mmap 的场景包括:流式顺序读(read 的预读更高效)、频繁扩展缩小的文件、网络文件系统上的文件(因为 page cache 一致性语义的差异)。
Q: 如何从系统行为判断是否进入抖动?
典型信号是磁盘持续高 I/O 但 CPU 利用率很低------说明 CPU 在干等磁盘完成页面换入换出。工具层面:Linux 上 vmstat 1 查看 si(swap in)和 so(swap out)列,如果持续高位且 us(user CPU)很低,很可能正在抖动。iostat 也可以辅助确认磁盘 I/O 是否是瓶颈。
总结
从 OPT 的理论基准到 CLOCK 的工程实践、从 Belady 异常到工作集模型、从页面置换到页框回收------这一篇走完了虚拟内存管理中最核心也是考点最密集的部分。页面置换算法的选择直接影响系统的缺页率和吞吐量;工作集模型为动态页框分配提供了理论框架;抖动防治关系到多道程序系统的稳定性。而 mmap 作为虚拟内存在 I/O 领域的延伸,至今仍然是高性能存储系统的核心机制。
至此,OS 第三章「内存管理」的五篇文章全部完成。从最基础的地址绑定开始,到连续分配、分页、分段、虚拟内存、页面置换算法,再到页框回收与内存映射文件------希望这个系列能帮助你在 408 统考中建立起一套完整的、可以自述的内存管理知识体系。
INTERNAL-LINK: OS 操作系统系列总览 → 408 操作系统全部已发布章节
📚 延伸阅读
- 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 Kernel: Memory Management, kernel.org
- Understanding the Linux Kernel, 3rd Edition, Chapter 8: Memory Management, O'Reilly
- mmap() man page, Linux Programmer's Manual