内存管理
虚拟内存的核心承诺:每个进程都拥有独立的、几乎无限的地址空间,物理内存不够时用磁盘兜底。它解决三个痛点:进程间内存互相踩踏(隔离)、物理内存太小装不下大程序(扩展)、内存碎片化(紧凑管理)。代价是访问内存多一层地址转换,以及"慢速磁盘冒充内存"的性能风险。
提示
虚拟内存是"抽象"思想的极致:程序眼里地址从 0 开始的连续空间是假的,真实的物理页可能分散在内存各处,甚至被换到了磁盘上。操作系统负责维持这个假象。
为什么要虚拟内存
没有虚拟内存的世界有两个问题。隔离:进程 A 直接访问物理地址 1000,进程 B 也访问物理地址 1000,两者互相踩踏,一个程序的 Bug 能毁掉整个系统。扩展:物理内存只有 4GB,一个需要 8GB 的程序直接装不下。
虚拟内存给每个进程一张独立的页表(地址翻译的"地图"),进程 A 的地址 1000 和进程 B 的地址 1000 翻译到不同的物理页——隔离达成。物理内存不够时,把不常用的页换到磁盘,需要时再换回来——扩展达成。
分页机制
物理内存按固定大小切成页框(通常 4KB),虚拟地址空间切成同样大小的页。地址转换:虚拟地址 = 页号 + 页内偏移,页号查页表得到物理页框号,拼上偏移就是物理地址。
一次完整的地址转换
以 32 位系统、页大小 4KB(212)为例,虚拟地址 0x12345678 的转换过程:
拆分的依据是 212 页偏移占低 12 位(0x678),高 20 位是页号(0x12345);查表得到页框号 0x2A 后,物理地址 = 0x2A << 12 | 0x678 = 0x2A678。这个拼接由硬件一步完成,程序员感知不到。
MMU 与 TLB
地址转换发生在每次内存访问时,由 MMU(内存管理单元)硬件完成;TLB 是页表的硬件缓存,缓存最近用过的页号映射。TLB 命中时地址转换不额外花时间,TLB 缺失才需要查内存中的页表。这也是"切换进程慢"的原因之一——进程切换后 TLB 中的映射大多失效,要重新逐条填充。
局部性救场
TLB 能工作的前提是局部性原理:程序倾向于反复访问少量页面(比如循环体所在的页),所以很小的 TLB(几十条映射)就能覆盖 99% 的访问。这是"小缓存解决大问题"的经典案例。
多级页表
64 位系统的页表如果是一张大表,内存开销不可接受——单级页表需 252 个表项、每项 8 字节,合计 255 字节(32 PB),远超物理内存。多级页表让"空页不占空间":顶层表只有少量表项,指向二级表,二级表按需分配。以 4 级页表为例,地址拆分变成"4 级页号 + 偏移",只有实际使用的路径才分配表项——空地址空间不花内存。代价是转换多查几级,但 TLB 命中后多级查询的开销被抵消,实际性能影响很小。
页面置换
物理内存不够时,缺页中断要把磁盘上的页换入,必须先把某个页换出——选哪个页换出由置换算法决定:
| 算法 | 策略 | 评价 |
|---|---|---|
| FIFO | 换出最先进入的页 | 实现简单,但可能换掉正在用的页 |
| LRU | 换出最久未使用的页 | 命中率最优近似,但实现成本高 |
| 时钟算法 | FIFO + 访问位标记 | 工程实现(Linux)——LRU 的近似,成本低 |
FIFO 的 Belady 异常
直觉上"内存越大命中率越高",但 FIFO 违反这个直觉:给 3 个页框时缺页 9 次,给 4 个页框反而缺页 10 次——这就是 Belady 异常,原因是 FIFO 不看使用频率,可能把高频页面换出去。LRU(栈式算法)不会出现该异常;时钟算法作为 FIFO 的近似变体,理论上同样不排除 Belady 异常(它无栈性质),但实际命中率远好于纯 FIFO。
时钟算法是工程折中:页面排成环形,每个页有访问位;缺页时指针扫过页面,访问位为 1 的页清零继续找,为 0 的页换出。它不记录"多久没用",只记录"最近用过没有"——实现成本接近 FIFO,命中率接近 LRU。
抖动与性能
换页比内存访问慢几个数量级(磁盘毫秒级 vs 内存纳秒级)。进程工作集超过物理内存时,系统陷入"频繁换页"的抖动状态:刚换入的页立刻又要换出,CPU 大部分时间在等磁盘,表现为 CPU 空转、程序突然卡死。解决方向:加内存、减少进程占用、或调整应用的内存模型。
分段与分页之争
分段按逻辑单元(代码段、数据段、栈段)划分,段长可变;分页固定大小。两者的取舍:
- 划分依据:程序的逻辑结构(代码、数据、栈各一段)
- 优点:符合程序视角,段有语义(代码段可共享、只读保护)
- 缺点:段长可变,内存碎片化,管理复杂
- 划分依据:固定大小(4KB),无逻辑语义
- 优点:无碎片,管理简单,换页粒度统一
- 缺点:程序感知不到"段",页表大(用多级页表缓解)
现代系统普遍分页为主 + 分段语义辅助(x86 的分段机制实为分页服务),应用程序感知到的"段"是链接器布局的产物,不是操作系统的管理单位。
内存分配
虚拟内存之上,进程堆内存的分配(malloc / new)由内存管理器负责,两种经典算法:
| 算法 | 思路 | 特点 |
|---|---|---|
| 伙伴系统 | 内存按 2 的幂切块,分配大块后对半拆,释放时合并 | 分配释放快、外部碎片少;内部碎片(小块请求拿大块) |
| slab 分配器 | 为常用对象类型预建固定大小的缓存池 | Linux 内核用——同类型对象(如 inode)复用,避免反复初始化 |
伙伴系统的拆分示例
请求 3KB:从 4KB 块切出 4KB?不,3KB 向上取整到 4KB,若空闲列表没有 4KB 块,把 8KB 块拆成两个 4KB,取一个;释放时检查"另一半伙伴"是否空闲,空闲则合并回 8KB。合并是拆分操作的逆操作,保证大块不被长期拆碎。
与上层的关系
虚拟内存直接决定上层行为:
- 进程"内存占用"超过物理内存不一定会 OOM——只要访问模式局部性好,大部分页可驻留磁盘(Linux内存 有具体观测方法)
- JVM 的堆是进程地址空间里的一块连续区域,GC 移动对象只改进程内引用,不涉及虚拟内存边界
- 内存映射文件(mmap)把磁盘文件映射进地址空间,读写文件变成读写内存——本质是让虚拟内存系统代为管理磁盘缓存
内存管理与相邻领域深度咬合:进程与线程 的进程隔离正是靠"每个进程独立页表"实现的——页表就是地址空间的"领土边界";计算机组成原理 的存储层次里,MMU 和 TLB 是硬件对本文机制的加速。