F — 内存管理

内存管理是操作系统最核心的功能之一。它解决两个问题:给每个进程一个独占全部内存的幻觉(虚拟内存),并在物理内存的实际约束下高效执行(分页 + 置换)。

虚拟内存

每个进程拥有独立的虚拟地址空间,大小取决于寻址位数(32 位:4GB,64 位:理论上 2^64 字节,实际上 48 位有效位 = 256TB)。

虚拟地址 (进程看到的) 物理地址 (硬件实际的)
 0x7fff1234 页表映射 0xabcd0000

虚拟地址到物理地址的转换由 CPU 的 MMU(Memory Management Unit) 在每次内存访问时自动完成。映射的基本单元为页(Page),通常 4KB。

页表

页表是虚拟页号到物理页框号的映射表。现代系统使用多级页表来节省空间(大部分虚拟地址空间未被使用):

graph TD
 A["虚拟地址: [PGD索引 | PMD索引 | PTE索引 | 页内偏移]"] --> B["PGD (页全局目录)"]
 B -->|"每个进程一个"| C["PMD (页中间目录)"]
 C --> D["PTE (页表项)"]
 D --> E["物理页框 + offset → 物理地址"]
级别x86_64 名称位数条目数
第 1 级PGD (Page Global Directory)9 bit512
第 2 级PUD (Page Upper Directory)9 bit512
第 3 级PMD (Page Middle Directory)9 bit512
第 4 级PTE (Page Table Entry)9 bit512
偏移offset12 bit4KB

页表项(PTE)不仅包含物理页框地址,还包含控制位:

含义
Present (P)该页是否在物理内存中(1 在,0 不在则触发缺页中断)
Read/Write (R/W)可写(1)或只读(0)
User/Supervisor (U/S)用户态可访问(1)或仅内核可访问(0)
Accessed (A)页面被访问过(用于页面置换算法)
Dirty (D)页面被写入过(write-back 时需要写回磁盘)
NX禁止执行(No-eXecute,防止栈上代码执行攻击)

TLB(Translation Lookaside Buffer)

每次内存访问都遍历四级页表需要访问 4 次物理内存,太慢。TLB 是 MMU 内部的一套高速缓存,存储最近使用的虚拟页→物理页映射。

访问虚拟地址:
① 查 TLB → 命中: 1 个时钟周期,直接得到物理地址
 ↓
 不命中: 遍历多级页表(几十个周期),结果存入 TLB

TLB 的容量有限(通常几十到几百条目),因此连续访问大范围内存时可能 TLB miss,导致额外几十个时钟周期的开销。

对容器的意义:遍历一个跨越多个页的 vector(例如 1MB 的数组 = 256 个 4KB 页面),如果 TLB 只能缓存在其中几十条映射,将频繁 TLB miss。使用大页(Huge Page)(2MB 或 1GB)可以减少页表层次,降低 TLB 压力。

缺页中断(Page Fault)的完整流程

当一个程序执行到访问某个变量的指令时(例如 int x = *p;p 是一个虚拟地址),CPU 的 MMU 会先查页表,看这个虚拟地址所在的虚拟页是否已经映射到了物理页框。

如果页表项中 Present=0(尚未分配物理页框),MMU 触发硬件异常。CPU 保存当前指令的上下文位置(PC、寄存器),然后切换到内核态。内核检查该地址是否合法:

  • 非法(如空指针、已释放的指针) → 发送 SIGSEGV 信号,杀死进程
  • 合法(地址在进程的有效虚拟地址范围内,只是还没分配物理页框)→ 从空闲物理页框中取一个,在页表中添加映射:把虚拟页号指向这个物理页框号。这就像投影——虚拟页是一张底片,物理页框是幕布,页表把底片投影到幕布上

建好映射后,内核返回用户态。CPU 恢复上下文,重新执行那条导致缺页的指令。这一次 MMU 再查页表,Present=1,映射生效,数据被成功读出。

flowchart TD
 A["程序执行: int x = *p;<br/>(p 是虚拟地址)"] --> B["MMU 查页表<br/>找 p 所在虚拟页的映射"]
 B --> C{"页表项的 Present 位?"}
 C -->|"=1 (已有物理页框)"| D["获取物理地址,直接访问"]
 C -->|"=0 (尚未分配物理页框)"| E["MMU 触发硬件异常<br/>CPU 保存当前指令的上下文"]
 E --> F["CPU 切换到内核态<br/>运行缺页中断处理函数"]
 F --> G{"内核检查: 虚拟地址合法?"}
 G -->|"非法<br/>(空指针/野指针/越界)"| H["发送 SIGSEGV<br/>杀死进程"]
 G -->|"合法<br/>(页表有记录但未分配物理页)"| I["从空闲物理页框中取一个"]
 I --> J["添加映射:<br/>虚拟页号 → 物理页框号<br/>(投影关系建立)"]
 J --> K["内核返回用户态<br/>CPU 恢复指令上下文"]
 K --> L["CPU 重新执行那条指令<br/>这次 MMU 查到映射了"]
 L --> B

通俗理解:虚拟页号像一张标签,物理页框像仓库里的货架位置。缺页中断就是第一次使用这张标签时,仓库管理员在页表上记下”这个标签 → 那个货架”,此后凭标签取货就快了。关键点——贴标签的一瞬间,东西还没搬。首次搬东西时才真正搬进来,后续再取就是”货架上有”。

  • 一次缺页中断的时间成本约 1-10\mu s,比 cache miss (100ns) 再慢 100 倍

对容器的影响

  • malloc 分配一大块内存时瞬间返回(只建了虚拟地址空间的标签,页表中记一笔 Present=0 表示”标签已登记但货架未分配”)
  • 首次遍历写入时,每碰到一个新页就触发一次缺页中断(第一次使用这个标签,管理员去分配货架)
  • 第二遍遍历时物理页框已分配完毕(标签→货架映射已经建立),速度恢复正常
  • vector 扩容后首次写入的时间不均匀:扩容越大、新页越多、耗时波动越大

页面置换算法

当物理内存已满、需要加载新页时,必须选择一页换出到磁盘(swap):

算法策略特点
FIFO先进入的页先换出简单,但可能换出频繁使用的页
LRU(Least Recently Used)换出最久未使用的页理论上接近最优,但实现开销大
Clock(NRU)循环扫描,给第二次机会LRU 的近似,在 Linux 中使用
LFU(Least Frequently Used)换出访问次数最少的页可能留下”过去热门、现已不用”的页

Linux 使用一个更复杂的双层 LRU 列表:活跃列表(Active List)和非活跃列表(Inactive List),结合第二次机会机制。

交换(Swapping)

flowchart LR
 A["物理内存"] -->|"换出 (page out)"| B["Swap 分区/文件"]
 B -->|"换入 (page in)"| A

频繁的换入换出就是颠簸(Thrashing)——物理内存严重不足,大部分 CPU 时间浪费在页面置换而非实际计算上,系统几乎无法响应。

对容器的意义:在大数据量存储场景下,vector 的连续存储比 list 更不容易触发颠簸——连续分配的内存页更集中,被换出时一次 I/O 操作换一整块,list 散列的节点可能分布在完全不同的页面上,换入时需要多次 I/O。

本章与其他模块的链接