高级 #os#memory#virtual-memory#page-replacement

虚拟内存与页面置换

虚拟内存(Virtual Memory)让程序用比物理内存更大的地址空间——不常用的数据放在磁盘上,需要时再换进来,就像大学宿舍放不下的行李寄存到仓库

8GB 内存能跑需要 16GB 的游戏吗?

可以——只要你不在乎一点卡顿。

虚拟内存(Virtual Memory) 让物理内存的容量不再是硬限制。不常用的数据被挪到磁盘上(Swap),需要时再换回来。

🏫 类比:宿舍行李箱 你的宿舍很小(物理内存),但你有 4 个行李箱(数据和程序)。你只把最常用的东西放在宿舍里——当季的衣服、课本、笔记本。换季的衣服和旧教材存在家里的仓库(磁盘)。

想穿羽绒服了(需要访问不常用的数据)→ 回家取一趟(从磁盘换入)。 宿舍放不下了 → 把不穿的衣服送回仓库(换出到磁盘)。

虚拟内存的工作原理

缺页异常(Page Fault)

当程序访问的页不在物理内存中时,MMU 触发缺页异常(Page Fault)

程序访问虚拟地址 0x7f001234


MMU 查页表 → PTE.Present = 0(此页不在内存)


CPU 触发缺页异常(Page Fault #14)


操作系统缺页异常处理程序:
    ① 找一个空闲的物理页框
    ② 从磁盘读取需要的数据到这个页框
    ③ 更新页表(写入物理页框号,Present=1)
    ④ 重新执行引发异常的指令


程序继续执行(不知道刚才发生了 Page Fault)
; Page Fault 的触发——在 CR2 中记录访问的地址
page_fault_handler:
    mov  rax, cr2          ; CR2 = 引发缺页的虚拟地址
    ; 查磁盘上对应的数据位置
    ; 分配物理页框
    ; 读磁盘 → 物理页框
    ; 更新页表
    iretq                   ; 返回用户程序重试

💡 缺页异常的处理非常昂贵——读一次磁盘大约 5-10ms,而 CPU 每秒能执行几十亿条指令。所以缺页越少,程序越快。

页的三种状态

┌─────────────────────────────┐
│  虚拟页的状态                │
├─────────────┬───────────────┤
│  在物理内存  │ 在磁盘上      │
│  Present=1  │ Present=0     │
│  可以直接访问 │ 访问会触发缺页 │
├─────────────┴───────────────┤
│  不存在                      │
│  未分配,访问会触发段错误      │
└─────────────────────────────┘

页面置换算法

物理内存满了,需要一个”牺牲页”换出去——选哪个?这就是页面置换算法

1. FIFO(先进先出)

最早进入内存的页先被换出。

请求页序列:1, 2, 3, 4, 1, 2, 5
内存(3 页):
1 → [1]
2 → [1, 2]
3 → [1, 2, 3]
4 → [2, 3, 4]  ← 1 被换出
1 → [3, 4, 1]  ← 2 被换出
...

问题Belady 异常——增加页数反而导致更多缺页(FIFO 独有的反直觉问题)。

2. LRU(最近最久未使用)

淘汰最长时间没被访问的页——基于”局部性原理”。

请求页序列:1, 2, 3, 4, 1, 2, 5
内存(3 页):
1 → [1]
2 → [1, 2]
3 → [1, 2, 3]
4 → [2, 3, 4]  ← 1 被换出(1 是最近最久没用的)
1 → [3, 4, 1]  ← 2 被换出
2 → [4, 1, 2]  ← 3 被换出
5 → [1, 2, 5]  ← 4 被换出

缺点:完美 LRU 需要记录每页的访问时间或维护一个按访问时间排序的链表——硬件实现成本太高。

💡 实际系统用 近似 LRU——比如利用页表项的 Accessed 位,定期扫描清除 Accessed 位,保留还为 1 的是最近用过的。

3. 时钟算法(Clock / Second Chance)

LRU 的实用近似——把页排成环形,用”指针”和”使用位”决定换出谁。

         ┌─→ P1(使用位=1 → 置0,继续)
         │   P2(使用位=0 → 换出它!)
         │   P3(使用位=1 → 置0,继续)
         │   P4(使用位=0 → OK)
         └─── 指针循环扫描

流程:

  1. 检查指针指向的页的使用位(Accessed)
  2. 如果使用位 = 1 → 置为 0,指针移向下一个
  3. 如果使用位 = 0 → 换出这一页
  4. 如果所有使用位都是 1→扫一圈全部清 0→换出扫到的第一页

💡 时钟算法是操作系统中应用最广的置换算法——Linux、Windows 都使用其变种。

4. LFU(最不经常使用)

淘汰访问次数最少的页。

问题:一个页在程序启动时被大量访问(使用次数高),之后再也不用了——按 LFU 它就永远留在内存中。

算法对比

算法核心思想实现难度性能
FIFO按进入顺序极简差(Belady 异常)
LRU最近没用的大概率以后也不用优(理论最优近似)
时钟LRU 的近似,用使用位
LFU按使用频率一般

页面分配策略

物理内存有限,给每个进程分配多少页框?

策略分配方式优点缺点
固定分配每个进程固定页框数公平无法应对不同需求
可变分配根据缺页率动态调整灵活实现复杂

抖振(Thrashing)

抖振是虚拟内存最严重的性能问题——分配的物理页太少,导致频繁缺页,CPU 大部分时间在处理缺页而不是真正执行。

物理内存不够 → 频繁缺页 → 频繁 I/O 读盘

CPU 利用率降低 → OS 认为 CPU 空闲 → 调入更多进程

每个进程分到的页框更少 → 缺页更频繁 → ...

                         系统崩溃(几乎不干活)

💡 抖振的检测:当 CPU 利用率下降而磁盘 I/O 急剧增加时,系统很可能在抖振。

Thrashing 的解决方案

工作集模型(Working Set)

一个进程在一段时间内实际访问的页集合称为它的工作集。只要分配给进程的页框数 >= 工作集大小,缺页率就会低。

时间轴 →
访问的页: [1, 2, 3, 1, 2, 4, 5, 1, 2, 3, ...]
              └─────────┘
          工作集(最近 5 次访问的页)
          = {1, 2, 3, 4, 5}

缺页频率控制(Page Fault Frequency, PFF)

缺页率太高 → 给进程多分配页框
缺页率太低 → 从进程回收多余页框

小结

概念要点
虚拟内存用磁盘空间扩展”虚拟”的物理内存
缺页异常访问不在内存中的页时触发,OS 从磁盘加载
页面置换内存满了要腾空间,选择换出哪一页
LRU换出最近最久未用的页(理论最优近似)
时钟算法LRU 的实用实现
抖振页框太少→频繁缺页→系统几乎停摆

为什么先学这个? 虚拟内存解释了为什么你的电脑可以跑比物理内存更大的程序。但你可能会好奇——页表查询需要多次内存访问,怎么加速?答案是TLB(Translation Lookaside Buffer)