虚拟内存:操作系统最精妙的设计——从分页机制到页面替换算法的完整拆解

虚拟内存不是「用硬盘冒充内存」那么简单。它是现代操作系统最重要的抽象层——让每个进程以为独占整台机器,让操作系统在 64KB 到 64GB 之间无缝伸缩。


一、没有虚拟内存的世界是什么样

假设你回到 1980 年代,用 MS-DOS 写程序。你的程序直接操作物理地址:

// 假想的裸机代码
char *video_memory = (char *)0xB8000;  // VGA 显存的物理地址
*video_memory = 'A';                     // 直接在屏幕上写字符

这带来了三个致命问题:

问题 1:地址冲突。 你写了一个程序,假设自己能占用 0x1000 到 0x5000。另一个程序也假设自己能占用这个范围。两个程序怎么同时运行?不可能——它们会互相覆盖。

问题 2:内存碎片。 经过多次加载和卸载,物理内存会变成这样:

[程序A: 4MB] [空闲: 2MB] [程序B: 8MB] [空闲: 6MB] [程序C: 3MB]

总空闲 = 2 + 6 = 8MB,但如果你要加载一个 5MB 的程序——装不下,因为空闲内存不连续。

问题 3:安全性。 程序 A 可以读写程序 B 的内存。一个恶意程序可以偷走你的密码。

虚拟内存就是用来解决这三个问题的。


二、虚拟内存的三层抽象

2.1 第一层:地址空间隔离

虚拟内存的核心思想:每个进程看到的不再是物理地址,而是一个「虚拟地址空间」——从 0 开始,连续,独占。

进程 A 眼中的世界:          进程 B 眼中的世界:
┌─────────────┐ 0           ┌─────────────┐ 0
│   代码段      │              │   代码段      │
├─────────────┤ 4KB          ├─────────────┤ 4KB
│   数据段      │              │   数据段      │
├─────────────┤ 8KB          ├─────────────┤ 8KB
│   堆          │              │   堆          │
│     ↓        │              │     ↓        │
│              │              │              │
│     ↑        │              │     ↑        │
│   栈          │              │   栈          │
└─────────────┘ 4GB           └─────────────┘ 4GB

实际物理内存:
┌──────┬──────┬──────┬──────┬──────────────┐
│ A代码 │ B代码 │ A堆  │ B数据 │    空闲      │
└──────┴──────┴──────┴──────┴──────────────┘

进程 A 以为自己独占 4GB 连续空间,进程 B 也以为自己独占 4GB。实际上它们被打散在物理内存的各个角落。虚拟地址 → 物理地址的映射由 CPU + 操作系统协作完成,进程无感知。

2.2 第二层:分页——把连续变成稀疏

不要把所有虚拟地址一次性映射到物理内存——只映射真正用到的部分。

虚拟地址空间(大)                    物理内存(小)
┌──────────┐                        ┌──────────┐
│  代码页   │───────────────────────→│  代码页    │
├──────────┤                        ├──────────┤
│  数据页   │───────────────────────→│  数据页    │
├──────────┤                        ├──────────┤
│  堆 页1   │───────────────────────→│  堆 页1    │
├──────────┤     ✗ 未映射           ├──────────┤
│  堆 页2   │──── (没用到)          │  空闲     │
├──────────┤                        ├──────────┤
│  栈 页    │───────────────────────→│  栈 页    │
└──────────┘                        └──────────┘

映射的单位是页(page),通常是 4KB。一个 4GB 的地址空间有 100 万个页,但一个普通进程实际只用到其中的几百到几千页。

2.3 第三层:交换——当物理内存不够时

如果所有进程加起来需要的物理内存超过了实际内存,操作系统可以把一些不常用的页临时写到硬盘上(swap),把物理内存腾出来给更需要的进程。

这就是大家常说的「虚拟内存 = 内存 + 硬盘」——但这只是虚拟内存的一个子功能(交换),不是全部。


三、页表:虚拟到物理的翻译官

3.1 你写 int x = 5 时发生了什么

int *p = malloc(sizeof(int));  // 返回 0x7fff1234(这是个虚拟地址)
*p = 5;                        // CPU 要把 5 写到这个地址

CPU 拿到 0x7fff1234,它不能直接去物理内存找——物理内存里根本没有「0x7fff1234」这个位置。CPU 需要先查页表,找到这个虚拟地址对应的物理地址。

虚拟地址 0x7fff1234 的翻译过程:

虚拟地址 = 0x    7fff      1234
                 │         │
            VPN(虚拟页号)  offset(页内偏移)
                 │
                 ▼
            ┌─────────┐
            │  页表    │
            │ VPN → PFN │
            └────┬────┘
                 │
                 ▼
物理地址 = PFN(物理页帧号)+ offset

3.2 多级页表:解决页表太大的问题

一个 4GB 的地址空间,如果每个页表项 4 字节,4KB 一页,总共有 100 万个页。如果用一个平坦的页表:

页表大小 = 4 字节 × 100 万 = 4MB(每个进程!)

100 个进程就要 400MB 内存只存放页表——这是不可接受的。

解决方案:多级页表。

x86-64 使用四级页表:

虚拟地址(48-bit):
┌──────┬──────┬──────┬──────┬──────────┐
│PML4(9)│PDPT(9)│PD(9) │PT(9) │offset(12)│
└──────┴──────┴──────┴──────┴──────────┘

查表过程:
PML4 ──→ PDPT ──→ PD ──→ PT ──→ 物理页 + offset

省在哪? 一个进程不会用完整个 256TB 的地址空间。未使用的部分……根本不分配页表。大部分进程实际只需要 PML4 的少数几个表项,整条表链的大部分节点都是空的。

3.3 TLB:翻译加速缓存

查四级页表太慢了——每次内存访问要额外做四次内存读取(查四次表)才能拿到真正的数据。这相当于每次内存访问慢了 4 倍。

CPU 内部有一个专门的缓存:TLB(Translation Lookaside Buffer,地址转换后备缓冲器)。它缓存最近的翻译结果:

CPU                          内存
┌──────────┐               ┌──────┐
│   TLB    │               │ 页表  │
│ VPN→PFN  │   TLB miss    │      │
│ 缓存     │◄──────────────│      │
│ (几十条)  │   才去查      │      │
└──────────┘               └──────┘

TLB 命中率通常在 99% 以上。这意味着绝大多数内存访问只需要一次 TLB 查找,不需要查完整的四级页表。


四、页面替换算法:物理内存满了怎么办

当需要加载新页面但物理内存已满时,操作系统必须选择一个页面踢出去(可能写到 swap)。选哪个? 这就是页面替换算法要解决的问题。

4.1 最优算法(OPT,Belady 最优算法)——也不可能实现的理论最优

策略: 踢掉将来最远才被使用的页面。

这是理论上最优的方案(缺页次数最少),但无法实现——你无法预知未来哪个页面会先被用到。它的价值在于给其他算法提供一个对比基准。

4.2 FIFO——简单但有致命缺陷

策略: 谁先来的,先踢谁。

页面访问序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5
物理页帧数:3

初始:[1] [2] [3]    缺页 3 次
访问4:[4] [2] [3]    踢 1,缺页 1 次
访问1:[4] [1] [3]    踢 2,缺页 1 次
访问2:[4] [1] [2]    踢 3,缺页 1 次
访问5:[5] [1] [2]    踢 4,缺页 1 次
...

Belady 异常: FIFO 有一个著名的反直觉现象——增加物理页帧数,反而可能增加缺页次数。这不是 bug,是 FIFO 算法本身的性质。

4.3 LRU(最近最少使用)——实际中最好的近似

策略: 踢掉最长时间没有被访问的页面。

直觉: 如果一个页面过去很久没被用过,那它将来也不太可能马上被用到(局部性原理)。

页面访问序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5
物理页帧数:3

初始:[1] [2] [3]    缺页 3 次
访问4:[4] [2] [3]    踢 1(最久未用),缺页 1
访问1:[4] [1] [3]    踢 2,缺页 1
访问2:[4] [1] [2]    踢 3,缺页 1
访问5:[5] [1] [2]    踢 4,缺页 1
访问1:[5] [1] [2]    命中
访问2:[5] [1] [2]    命中
访问3:[5] [3] [2]    踢 1,缺页 1
访问4:[4] [3] [2]    踢 5,缺页 1
访问5:[4] [3] [5]    踢 2,缺页 1

总缺页:10 次(FIFO 相同序列约 12 次)

LRU 的问题: 实现成本高。每次内存访问都要更新「最近使用时间」——要么用硬件支持的访问位,要么每次访问都产生一次时间戳写入。对于高频访问的页面,这个开销不可接受。

4.4 Clock 算法(第二次机会)——Linux 的实际选择

Linux 使用 Clock 算法的变体(基于 LRU 的近似)。核心思想:用一个**访问位(reference bit)**代替 LRU 的精确时间戳。

循环链表(Clock):
         ┌──┐
       ┌─┤B │  ref=1
       │ └──┘
    ┌──┐  │
    │A │◄─┘
    │ref=0│
    └──┘
       │
    ┌──▼┐
    │C │  ref=1
    └──┘
        ...

Clock 指针在循环链表上移动:

  1. 查看当前指向的页面
  2. 如果 ref=0:踢掉它
  3. 如果 ref=1:把 ref 清零,给第二次机会,指针移到下一个

这相当于 LRU 的近似:最近被访问过的(ref=1)不会被踢,长时间没被访问的(ref 被清零后没再设回 1)被踢掉。开销极小——只需要一个 bit。

4.5 Linux 的实际做法:双链表 LRU

Linux 使用两个 LRU 链表:

Active List(活跃链表)          Inactive List(不活跃链表)
┌──┬──┬──┬──┐                 ┌──┬──┬──┬──┐
│A │B │C │D │  ← 经常访问     │E │F │G │H │  ← 不常访问
└──┴──┴──┴──┘                 └──┴──┴──┴──┘
     ↑    ↓                          ↑    ↓
     └────┴────────── 晋升 ──────────┘    │
     ┌────┴────────── 降级 ───────────────┘
  • 页面首次加载进入 Inactive list
  • 如果在 Inactive list 中被第二次访问,晋升到 Active list
  • 内存紧张时,优先从 Inactive list 的尾部回收页面。

这套机制通过 kswapd 内核线程在后台持续运行,不需要等到内存完全耗尽才回收。


五、缺页中断:当虚拟地址没有对应的物理页

5.1 缺页的三种情况

当 CPU 访问一个虚拟地址,发现页表里没有对应的物理页帧(PTE 的 present 位为 0),触发缺页中断(page fault)。操作系统此时有三种处理:

情况 原因 处理
小缺页(minor fault) 页面在物理内存中但未映射到当前进程的页表(比如共享库) 建立页表映射,极快
大缺页(major fault) 页面不在物理内存中,需要从磁盘加载 从文件或 swap 读取,慢(ms 级)
段错误(segfault) 访问了无效地址(未分配、越界) 发送 SIGSEGV,进程终止

5.2 mmap 和缺页的配合——惰性分配

当你调用 mmap() 分配大量内存时,操作系统并不立即分配物理页:

// 分配 1GB 内存——但此时不分配任何物理页
void *ptr = mmap(NULL, 1UL << 30, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

// 第一次访问时才触发缺页中断,分配物理页
// 1GB 空间里你只用了 10MB → 只分配了 10MB 物理内存
((char *)ptr)[0] = 'A';

这是操作系统最精妙的设计之一:惰性分配(lazy allocation)。你声明 1GB,系统说「好」,但其实什么都没分配。只有当你真正触碰那 1GB 中的某个字节,才分配对应的物理页。这让你可以在 8GB 内存的机器上跑需要 100GB 地址空间的应用(只要实际使用的超过物理内存的部分不多)。


六、Huge Pages——当 4KB 的页太小了

一个现代数据库进程可能有几十 GB 的地址空间。按 4KB 一页算:

40GB / 4KB = 1000 万个页表项
TLB 条目数 = 约 64-256 条

1000 万个页 vs 256 条 TLB → TLB 命中率会急剧下降,性能严重受损。

解决方案:大页(Huge Pages)。

页大小 覆盖同样内存需要的页表项
4KB(普通页) 1000 万个
2MB(大页) 2 万个
1GB(巨页) 40 个

Linux 支持两种大页机制:

  • Transparent Huge Pages (THP):内核自动尝试使用 2MB 页,应用无感知。但碎片化可能导致分配失败。
  • Hugetlbfs:应用显式申请大页,更可控但不是透明的。
# 查看大页使用情况
grep -i huge /proc/meminfo

# 查看 TLB 大小(Intel)
cpuid -1 | grep -i tlb

七、动手实验

# 查看进程的虚拟内存映射
cat /proc/self/maps

# 查看缺页统计
ps -o min_flt,maj_flt,cmd -p $$

# 查看某个进程的页表大小
cat /proc/<pid>/status | grep -i vm

# 查看 swap 使用
swapon --show
free -h

八、总结

虚拟内存是一个三层抽象:

  1. 地址空间隔离——每个进程活在自己的世界里
  2. 分页——只映射实际用到的部分
  3. 交换——物理内存不够时用磁盘当后备

支撑这三层抽象的关键机制:

  • 多级页表——用稀疏的数据结构解决页表膨胀问题
  • TLB——用高速缓存解决翻译性能问题
  • 缺页中断 + 惰性分配——用「声明不等于兑现」节省物理内存
  • 页面替换算法——用启发式规则在有限物理内存中做出最优选择
  • Huge Pages——用更大的粒度解决大数据时代的 TLB 瓶颈

理解了这套体系,你就理解了一个操作系统是如何在「理想(无限内存)」和「现实(有限物理内存)」之间搭建起一座让所有应用都满意的桥梁。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐