Theory · OS · Page Replacement

页面置换算法

内存不够换谁出去:OPT · FIFO · LRU · LFU · Clock —— 以及 Linux / Redis / MySQL 的工业变体

理论谱系

OPT 定下限(不可实现)→ FIFO 简单但 Belady 异常 → LRU 用局部性逼近 OPT → Clock 用引用位做廉价近似

一个原理

所有算法都在赌局部性:过去被访问的页,未来更可能再被访问——赌对了缺页率就低

工业变体

Linux 双链表 + MGLRU、Redis 采样 LRU/LFU、MySQL Buffer Pool 的 young/old 分区——教科书思想的部署级改造

定位:OS 系列第八篇,上接虚拟内存(回收"换谁出去"的问题)。这条算法线与数据结构的 LRU deck、Redis memory-policy deck 三线交汇。

The Problem · Locality

置换问题与局部性原理

什么时候要置换

进程要访问的页不在内存(缺页),而物理内存已满——必须挑一页"请出去",腾出物理框。挑谁出去就是置换算法的全部内容;挑得不好,刚换出去的页马上又要换回来(缺页率飙升)。

局部性原理:一切算法的赌注

时间局部性:刚访问过的页/数据,不久还会再访问(循环、热点数据);空间局部性:访问某页后,相邻页大概率也被访问(数组、顺序代码)。于是"最近/最频繁使用的页"是"未来会用的页"的好代理——LRU/LFU 都是这个赌注的具体形式

评价指标:缺页率

缺页次数 / 访问次数。major fault(要读盘)的代价是万级访存时间——缺页率差 1% 对延迟都是灾难。置换算法的目标:给定页框数,最小化缺页率

局部性原理示意:热点聚集的访问序列 访问序列示意:时间轴上的页访问呈现两种模式,一段时间内反复访问同一小集合(时间局部性),且相邻访问的页号集中(空间局部性);若置换算法保住这些热页,缺页率就低。 访问序列(页号 · 时间 →) 3 7 3 8 7 3 51 7 8 52 7 8 3 3 蓝色 = 热页反复出现(时间局部性) 页号聚集、偶尔跨界(空间局部性) 置换算法 = 保住蓝页、赶走白页 赶错一次 = 一次 major fault = 磁盘 I/O 量级代价
面试金句:"置换算法不是在'管理内存',是在用历史预测未来——预测模型越好(局部性越真实),缺页率越低。"
先立赌注框架:局部性 = 历史预测未来的正当性。缺页率的代价量级(major fault = 磁盘 I/O)让后面的算法选择有了评判标准。

Optimal Replacement · Belady

OPT:理论下限,不可实现的基准线

规则

缺页时置换未来最长时间不再被访问的页——"预知未来"的算法,缺页率是所有算法的理论下限(Belady 1966 提出)。

为什么不可实现

需要知道未来的完整访问序列——等于要预知程序将来的每一步。真正的用途:离线模拟的基准线,其他算法用"缺页率 / OPT 缺页率"衡量逼近程度。

示例感受一下

页框 3,序列 7,0,1,2,0,3,0,4:访问 2 缺页时,内存 {7,0,1}——0 马上要用、1 还会出现,7 最久不再出现 → 换 7。OPT 每一步都"换得刚刚好"。

栈式算法(引出 Belady)

OPT 满足包含性质:n 个页框的驻留集 ⊆ n+1 个页框的驻留集——这类"栈式算法"缺页率随页框数单调下降。FIFO 不满足,于是有了著名反例(下一页)。

算法信息来源可实现性
OPT未来访问序列否(理论基准)
LRU过去的访问序列可(代价:精确计时/栈维护)
FIFO进入内存的先后可(最便宜)
Clock1 位引用位可(硬件成本最低)
面试金句:"OPT 是物理定律级的下限:不能实现但必须记住——面试任何置换题,先报 OPT 作为最优解,再谈现实算法逼近它的程度。"
变体认知:"OPT + 预知半步" 在存储系统里有现实版:MySQL 预读(知道线性扫描的下一页)、文件系统 readahead——已知"接下来要读什么"时,提前加载胜过任何置换。
OPT 的定位(不可实现的基准线)+ 栈式算法概念为 Belady 异常铺垫。readahead/OPT 变体是"理论照进现实"的加分点。

FIFO · Belady's Anomaly

FIFO:最便宜,却藏着一个著名反例

规则与特点

换出最早进入内存的页:维护一个 FIFO 队列,命中不动、缺页时队头出队。实现 O(1)、零硬件支持。问题:进入早 ≠ 不再用——全局变量、循环体这类"老而热"的页会被冤枉换出。

Belady 异常(必背反例)

序列 1,2,3,4,1,2,5,1,2,3,4,53 个页框缺页 9 次,4 个页框反而 10 次——内存变多,缺页率反而升高!原因:FIFO 不是栈式算法,页框数变了,换出的"历史顺序"整个错位。

异常的意义

它证明了 FIFO 的行为反直觉、不可靠——这也是现实系统几乎不用纯 FIFO 的原因(Redis/CDN 缓存用它时都打了补丁,如随机驱逐或按 TTL)。LRU/OPT 这类栈式算法可证明无此异常。

Belady 异常:3 页框 9 次缺页,4 页框 10 次缺页 两组对照:同一访问序列 1,2,3,4,1,2,5,1,2,3,4,5 在 3 个页框下 FIFO 缺页 9 次,在 4 个页框下反而缺页 10 次——内存增大缺页率上升的反直觉现象。 3 页框 F F F F F F F · · F F · 缺页 9 次 4 页框 F F F F · · F F F F F F 缺页 10 次 —— 内存更多,缺页反而更多 (步进表:每步标出 F 或命中) 序列:1,2,3,4,1,2,5,1,2,3,4,5 F=缺页 · 记号演示,面试要能手推 3 框的 9 次
手推模板(3 框):1F 2F 3F → 4F 换1 → 1F 换2 → 2F 换3 → 5F 换4 → 1✓ 2✓ → 3F 换1 → 4F 换2 → 5✓ = 9 次。答题时现场画这张表最有说服力。
Belady 异常是本篇最高频考点:序列背下来 + 会手推。"老而热的页被冤枉"给 FIFO 判了现实死刑。栈式 vs 非栈式是异常的机理解释。

Least Recently Used

LRU:局部性的直接实现,与它的 O(1) 工程

规则与理论地位

换出最长时间未被访问的页。它用"过去"逼近 OPT 的"未来"——局部性成立时是现实可实现算法中缺页率最优的一档;且是栈式算法,无 Belady 异常。

为什么 OS 内核不直接用 LRU

精确 LRU 每次内存访问都要更新顺序(哈希 + 双向链表 O(1) 也不行——那是软件缓存的奢侈,硬件访存路径上多一跳都嫌贵)。内核只在缺页时才有软件介入机会,所以只能用硬件留的一枚引用位 A(每次访问硬件置 1)做近似——这就是 Clock 算法的由来(第 7 页)。

软件缓存里的 LRU

应用层(Redis/Memcached/进程内缓存)每次访问本来就要走代码路径,维护哈希表 + 双向链表的精确 LRU 才划算——哈希表 deck 的 LRU 实现题(LeetCode 146)就是它。

// 软件层精确 LRU:哈希 + 双向链表 O(1)
// 146 题范式(Go 版骨架)
type LRUCache struct {
    m map[int]*node  // key → 链表节点
    head, tail *node // 哨兵:head=最新
    cap int
}
// Get: 链表摘下节点挪到 head
// Put: 已存在则更新+挪头;
//      不存在则插头;超容删 tail.prev
// 全部操作 O(1),靠双向链表 O(1) 摘除
内核 vs 应用的分层对照(面试亮点):"同一语义,两套实现——内核在访存路径上只能拿 1 位引用位(Clock 近似),应用在代码路径上可以维护精确 LRU。实现形态由介入时机决定。"
LRU 的软肋:一次全量扫描(备份、全表扫描)把热页全冲走——"缓存污染"。工业界用分区(MySQL old 区)、采样(Redis)、衰减(LFU)打补丁,第 10 页展开。
LRU 页的深水区:"为什么内核不用精确 LRU"(访存路径上只有 1 位引用位可用)——这是 Clock 算法的存在理由。软件/内核分层对照是本篇差异化答案。缓存污染埋钩子。

LFU · Cache Pollution

LFU:按频率下注,以及两种算法各自的坑

规则

换出访问次数最少的页。赌注从"最近用过"(时间维度)换成"用得最多"(频率维度):对长期稳定的热点(配置、元数据、热商品)比 LRU 更稳。

LFU 的两个大坑

历史包袱:曾经的热点(已冷)频率值居高不下,赖着不走——需要定期衰减/老化(周期减半)才有意义;② 新页入场即最低频,新热点还没攒够次数就被换出——需要"新页保护期"。工程实现(如 Redis LFU)用对数计数器 + 时间衰减一并解决。

LRU 的坑:缓存污染

一次性批量扫描(全表扫描、备份、日志聚合)让每个冷页都"被访问过一次",把真热页全部挤出 LRU——污染后热请求集体缺页。解法三件套:分区(新页先进小隔离区,二次访问才转正)、采样(近似替代精确)、频率加权(LRU-K/LFU 混合)。

对比LRULFU
赌注最近用过的会再用用得多的会再用
擅长短周期热点、顺序局部性长周期稳定热点
怕什么批量扫描污染热点转移(历史包袱)
实现要点哈希 + 双链 O(1)计数器 + 衰减
典型部署MySQL/内核近 LRURedis LFU 模式
LRU-K(混合思想):用"倒数第 K 次访问的时间"替代"最近一次"——既看频率又看新鲜度,天然抗扫描污染。数据库缓冲区(PostgreSQL ring buffer 思路)常见其变体。
面试金句:"LRU 输给一次扫描,LFU 输给一次热点迁移——补丁的形状也对应:LRU 加'分区/二次准入',LFU 加'衰减/新页保护'。"
LFU 两坑(包袱/新页)与 LRU 一坑(污染)配对讲。LRU-K 作为混合思想收束。这些坑名在第 10 页工业变体里全部对号入座。

Clock · Second Chance · Enhanced

Clock:硬件只给 1 位时的优雅近似

时钟置换算法的环形扫描与第二次机会 环形页表示意:指针顺时针扫描,遇到引用位为 1 的页将其清零并放行(第二次机会),遇到引用位为 0 的页即选为受害者换出。下方注明改进 Clock 结合访问位与脏位四象限的淘汰优先级。 P0A=1 P1A=1 P2A=0 P3A=1 P4A=1 P5A=0 扫描指针(时钟针) 规则(第二次机会) 1 · 指针扫到 A=1: 清零并放行(第二次机会) 2 · 扫到 A=0: 选为受害者,换出新页 3 · 新页装入,A 置 1 直觉:A=1 = "最近被用过" = 给一次活命机会;清零后 再被扫到即淘汰 —— 折衷 FIFO(结构)与 LRU(语义) 改进 Clock(A + M 脏位):优先淘汰 (A=0,M=0) 未动页 → 次选 (A=0,M=1) 先写回再换 —— 省一次磁盘写,代价是多扫几圈
Clock = FIFO 的环形结构 + LRU 的引用位语义。改进 Clock 用脏位省磁盘写(写回成本 > 多扫描),"A=0,M=0 优先"的四象限顺序要能说清。

Linux active/inactive · MGLRU

Linux 的置换:不是算法题,是回收子系统

经典方案:双链表近似 LRU

每 zone/节点维护 active 与 inactive 两组 LRU 链表(文件页/匿名页各一套):缺页访问把页提升 active(置引用位),active 冷却后降级 inactive,内存紧张时从 inactive 链表尾部回收——"二次机会"以链表形式落地,硬件引用位+A/D 位做辅助证据。

问题与进化:MGLRU

大内存机器上双链表扫描成本高、分代粗。Linux 6.1 引入 MGLRU(多代 LRU):按访问年龄分代,代际间做批量判断,减少扫描与锁竞争——大规模内存 + 轻负载场景收益明显,逐步在新发行版默认启用。

与页框回收的关系

置换算法在内核里长在 kswapd / 直接回收流程里:候选页按"文件页/匿名页 × 冷热"出队,脏页写回、匿名页进 swap——第 7 篇的回收流水线,就是本页算法的执行现场。

层次机制对应教科书概念
硬件PTE 的 A / D 位引用位 / 脏位(Clock 的原料)
链表层active / inactive 双链第二次机会 / 近似 LRU
现代层MGLRU 分代(6.1+)分代 LRU(LFU 思想相邻)
执行层kswapd / 直接回收缺页与换页的调度现场
为什么没有"纯 LRU/FIFO":内核不能为每次访存插指针操作(第 5 页的论点),只能在"缺页/回收"两个软件介入点用引用位 + 链表攒证据——教科书算法是语义,内核实现是工程
面试口径:"Linux 用 active/inactive 双链近似 LRU、引用位防误伤,6.1 后大内存机器看 MGLRU——答到这一层就超过 90% 的候选人了。"
把教科书映射到内核四层:A/D 位 → 双链 → MGLRU → kswapd。MGLRU(6.1+)是版本敏感加分项,不写"必然默认开启"保持准确性。

Thrashing · Working Set

抖动与工作集:置换算法失效时的系统病

抖动 Thrashing

页框不足以容纳进程的活跃页集合:换出马上要用的页 → 马上缺页换回 → 再换出别的热页……CPU 几乎全耗在换页 I/O 上,利用率上升但有效吞吐暴跌——典型症状:CPU 空闲(等 I/O)但系统慢爆,si/so 疯涨。

工作集模型

进程在窗口 Δ 内实际访问的页集合 = 工作集 W(t, Δ)。要流畅运行:Σ 所有进程的工作集 ≤ 物理内存。调度器据此做"工作集准入":内存不够装下新进程的工作集就先别切给它 CPU(swapping 挂起整个进程)——把"页级置换"升级成"进程级换出"。

工程教训

① 单机并发进程/服务数量要按内存预算算,不是按 CPU 核数;② 容器 memory limit 卡在热数据边缘 = 人造抖动(监控 cgroup 的 pgmajfault);③ 抖动苗头:缺页率高 + CPU 低 + si/so 高 → 减负载或加内存,调算法没用。

抖动曲线:CPU 利用率先升后崩 多进程并发度与 CPU 利用率的关系曲线:并发度增加时利用率上升,超过内存容量对应的临界点后,系统陷入缺页换页循环,利用率断崖式下跌——抖动区域。 CPU 利用率 多道程序并发度 → 临界点:内存装不下工作集总和 抖动区 越忙越换页,越换页越忙 对症下药:减进程 / 加内存 / 挂起进程,而不是换算法
面试金句:"抖动是局部性被内存预算击穿的系统性崩溃——工作集模型给的是容量判据,不是更好的换页技巧。"
抖动曲线(利用率先升后崩)+ 工作集准入。容器 limit 卡热数据边缘 = 人造抖动,是云原生语境的高频翻车点。解法是容量/准入,不是算法。

Redis · MySQL in Production

工业变体:Redis 与 MySQL 怎么改造教科书

Redis:采样 LRU 与对数 LFU

逐出不用精确 LRU:随机采样 N 个 key(默认 5)逐出其中最久未用的——省维护链表的成本,效果接近 LRU;可调 maxmemory-samples 提升精度。LFU 模式(4.0+):8 位对数计数器(越大概率递增,防止计数溢出与存储放大)+ 分钟级时间衰减——一次治好 LFU 的"历史包袱"。

MySQL Buffer Pool:young/old 分区

InnoDB 把 LRU 链表切 5/8 young + 3/8 old,新页必须先进 old 区头部(midpoint insertion),在 old 区停留超过 innodb_old_blocks_time(默认 1s)再被访问才升 young。专治两病:预读失效(预读页未及使用就被换出→ 先在 old 区冷置)与全表扫描污染(扫描页只在 old 区自转,不冲掉 young 热页)。

共性思想

都拒绝"教科书直出":近似换精确(成本)、分区防污染(准入)、衰减抗老化(时效)——三个补丁方向对应第 6 页的三种坑。

系统方案治什么
Redis采样 LRU(N=5)精确 LRU 的维护成本
Redis LFU对数计数 + 衰减频率包袱、计数爆炸
InnoDB5/8+3/8 分区 + midpoint预读失效、扫描污染
InnoDBold 区驻留 1s 才转正瞬时访问冒充热点
Linux双链 active/inactive + MGLRU访存路径不可介入的替代
面试金句:"Redis 和 MySQL 都给 LRU 加了'二次准入':新数据先隔离观察,证明自己再转正——这是对抗缓存污染的工业共识。"
深挖链接:Redis 淘汰细节见 内存淘汰 deck;精确 LRU 的结构与手撕见 LRU 缓存 deck
工业页把第 6 页的坑一一对号:采样治成本、对数+衰减治 LFU、分区+驻留时间治污染。maxmemory-samples=5 与 innodb_old_blocks_time=1s 是可背的默认值。

Interview QA

高频追问:算法与工程

1 · 常见置换算法有哪些?各自优劣?

OPT/FIFO/LRU/LFU/Clock

OPT 最优但需预知未来(基准);FIFO 便宜但换"最老"不是"最冷"且有 Belady 异常;LRU 贴局部性但实现贵、怕扫描污染;LFU 抗短期扰动但怕热点转移;Clock 用 1 位引用位做 LRU 廉价近似。

2 · 什么是 Belady 异常?举例。

9 vs 10

页框增多缺页率反而升高。序列 1,2,3,4,1,2,5,1,2,3,4,5:3 框缺页 9 次、4 框 10 次。根因:FIFO 非栈式算法;LRU/OPT 栈式,无此异常。

3 · LRU 怎么实现 O(1)?内核为什么不用?

哈希+双链

哈希表定位 + 双向链表维护顺序,访问/换出都 O(1)——但那是软件缓存的实现。内核每次访存路径上只能靠硬件置 1 位引用位,无法维护完整顺序,所以用 Clock / active-inactive 双链近似。

4 · Clock 算法的流程与直觉?

第二次机会

环形扫指针:A=1 清零放行(给它第二次机会),A=0 淘汰装入新页。直觉 = FIFO 的结构 + LRU 的语义;改进版加脏位:优先淘汰 (A=0,M=0),(A=0,M=1) 写回后再换,省磁盘写。

5 · 什么是抖动?怎么发现和解决?

工作集 > 内存

活跃页集合超出物理内存,CPU 时间耗在换页 I/O 上,利用率高但吞吐崩。发现:缺页率高 + si/so 高 + CPU 等待高。解决:减并发/加内存/挂起进程(工作集准入),调置换算法无效。

6 · Redis 逐出为什么不用精确 LRU?LFU 怎么解决计数膨胀?

采样 + 对数

精确 LRU 要全局链表,成本高且多线程争抢——随机采样 5 个 key 逐出最旧,效果足够。LFU 用 8 位对数计数器(概率递增)+ 定期衰减,既省空间又抗"历史包袱"。

7 · InnoDB 为什么把 LRU 链表分区?

防扫描污染

新页进 3/8 的 old 区,驻留超 1s 再访问才升 young 区——预读但没用的页在 old 区自然淘汰,全表扫描的冷页不冲 young 热页。一招治"预读失效 + 扫描污染"两个 LRU 经典病。

8 · 缺页率和 TLB miss 有什么关系?

两级缓存问题

独立的两级:TLB miss 是"翻译慢"(页表遍历,微秒内),缺页是"数据不在"(磁盘 I/O,毫秒级)。但治理同源:局部性好,TLB 命中率高、工作集小,两者都少——这就是为什么大数据系统痴迷数据布局与预取。

八题覆盖全篇:算法谱系、Belady、LRU 实现、Clock、抖动、Redis/MySQL 变体、缺页与 TLB 的关系辨析。第 8 题防概念混淆,是收尾亮点。

Related & References

相关知识点与参考

OS 系列(本分类)

虚拟内存 →(缺页与回收的机制土壤)
进程内存布局 →(物理内存侧的分配:伙伴/Slab)
CPU 调度 →(工作集准入 = 进程级"置换")

跨领域联动

哈希表 →(LRU 的 O(1) 载体:LC 146)
Redis 内存淘汰 →(采样 LRU / LFU 的完整实现)
LRU 缓存(手撕实现)→(哈希 + 双链表的 O(1) 结构)

参考来源(本 deck 结论可溯源至下列一手材料)

OSTEP ch.22(Beyond Physical Memory)置换策略谱系与 Belady 异常分析
CSAPP ch.9.4 / OSTEP ch.19(TLB)引用位、Clock 的硬件语境
kernel Documentation/admin-guide/mm(multigen_lru)active/inactive 双链与 MGLRU(6.1+)
redis.io topics/lru-cache(maxmemory-samples / LFU)采样数默认 5、对数计数器与衰减参数
MySQL 8.0 Manual: InnoDB Buffer Pool(innodb_old_blocks_time)midpoint insertion 与 1s 驻留阈值
Denning, 1968(Working Set Model)工作集与抖动的原始论文
收尾:Denning 1968 是工作集原始出处,Redis/MySQL 手册是工业参数依据。总页数 12。下一篇:进程内存布局(换到"内存怎么分给谁"的正面)。