虚拟内存与TLB:分页、换页算法深度解析
虚拟内存:操作系统最精妙的设计——从分页机制到页面替换算法的完整拆解
虚拟内存不是「用硬盘冒充内存」那么简单。它是现代操作系统最重要的抽象层——让每个进程以为独占整台机器,让操作系统在 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(物理页帧号)+ offset3.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 指针在循环链表上移动:
- 查看当前指向的页面
- 如果
ref=0:踢掉它 - 如果
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-ihuge /proc/meminfo# 查看 TLB 大小(Intel)cpuid-1|grep-itlb七、动手实验
# 查看进程的虚拟内存映射cat/proc/self/maps# 查看缺页统计ps-omin_flt,maj_flt,cmd-p$$# 查看某个进程的页表大小cat/proc/<pid>/status|grep-ivm# 查看 swap 使用swapon--showfree-h八、总结
虚拟内存是一个三层抽象:
- 地址空间隔离——每个进程活在自己的世界里
- 分页——只映射实际用到的部分
- 交换——物理内存不够时用磁盘当后备
支撑这三层抽象的关键机制:
- 多级页表——用稀疏的数据结构解决页表膨胀问题
- TLB——用高速缓存解决翻译性能问题
- 缺页中断 + 惰性分配——用「声明不等于兑现」节省物理内存
- 页面替换算法——用启发式规则在有限物理内存中做出最优选择
- Huge Pages——用更大的粒度解决大数据时代的 TLB 瓶颈
理解了这套体系,你就理解了一个操作系统是如何在「理想(无限内存)」和「现实(有限物理内存)」之间搭建起一座让所有应用都满意的桥梁。
