2、操作系统内存管理基础:虚拟内存与物理内存、分页与分段机制、页表与TLB、缺页中断与页面置换算法

好,咱们正式开始聊内存管理。说实话,这是整个内存优化的地基。你想想看,如果连操作系统怎么管内存都不清楚,后面那些优化手段基本就是空中楼阁。

我个人习惯把内存管理比作一个「大仓库」。物理内存就是仓库的实际货架,而虚拟内存呢,是给每个程序发的一张「仓库地图」。程序以为自己独占整个仓库,其实它看到的只是地图上的位置——这就是虚拟内存的核心思想。

2.1 虚拟内存与物理内存

先问个问题:为什么要有虚拟内存?

直接原因很简单——物理内存不够用。我早年做嵌入式开发时,遇到过一台设备只有 256MB 内存,却要同时跑三个应用。每个应用都觉得自己有 1GB 空间,这怎么做到的?

答案就是虚拟内存。

虚拟内存给每个进程提供了一个独立的、连续的地址空间。进程访问的是虚拟地址,操作系统和硬件再把这个地址翻译成真实的物理地址。说白了,就是「骗」进程说你有大房子,实际上你住的是隔断间。

关键点:虚拟地址 ≠ 物理地址。中间这层映射关系,由操作系统维护。

举个例子:

进程A认为地址 0x1000 是它的变量
实际上 0x1000 被映射到了物理内存的 0x7000
进程B也认为地址 0x1000 是它的变量
实际上 0x1000 被映射到了物理内存的 0x8000

两个进程用同一个虚拟地址,互不干扰。这就是隔离性。

我的经验:调试内存泄漏时,我经常先看虚拟内存占用。如果虚拟内存暴涨但物理内存没怎么变,大概率是程序申请了内存但没真正使用——这叫「惰性分配」,后面会细讲。

2.2 分页与分段机制

虚拟地址怎么映射到物理地址?主要有两种方式:分段和分页。

分段,就是把程序分成代码段、数据段、堆栈段等。每个段有独立的基地址和长度限制。我记得早期 x86 架构就是分段为主,写个程序还得操心段寄存器,挺麻烦的。

分页,才是现代操作系统的标配。它把虚拟内存和物理内存都切成固定大小的块——叫「页」。通常是 4KB 一页。

我做个对比你就明白了:

特性 分段 分页
单位大小 可变(段大小由程序决定) 固定(通常 4KB)
碎片问题 外部碎片严重 内部碎片可控
管理复杂度 较低 较高
现代系统 基本淘汰 主流方案

为什么分页成了主流?说白了,分段那个可变大小太坑了。程序运行久了,内存里全是小洞,新的大段塞不进去——这就是外部碎片。分页固定大小,碎片最多浪费一页,好处理得多。

注意:分页不是没有碎片。4KB 的页,你只用 1 字节,剩下 4095 字节就浪费了。这叫「内部碎片」。不过相比分段的外部碎片,这已经算很好了。

2.3 页表与 TLB

分页机制的核心就是页表。页表记录了每个虚拟页号对应的物理页框号。

每次 CPU 访问内存,都要先查页表。但问题来了——页表存在内存里,查一次页表就要访问一次内存。这意味着每次内存访问,实际上变成了两次内存访问。性能直接腰斩。

怎么办?加缓存。

这就是 TLB(Translation Lookaside Buffer)——页表的高速缓存。它把最近用过的页表项存到 CPU 芯片里,访问速度比内存快两个数量级。

CPU访问虚拟地址 → 先查TLB
  ├─ TLB命中 → 直接得到物理地址 → 访问内存
  └─ TLB未命中 → 查内存中的页表 → 更新TLB → 访问内存

我在项目中遇到过一个问题:某个数据处理程序,性能突然下降 30%。查了半天,发现是数据访问模式太分散,TLB 命中率从 99% 掉到了 70%。后来把数据重新排列,让访问更集中,性能就恢复了。

优化思路:如果你的程序频繁访问大量数据,尽量让数据在虚拟地址上连续。这样 TLB 能覆盖更多有效地址,减少未命中。

2.4 缺页中断

进程访问一个虚拟地址,页表里找不到对应的物理页——这就触发了缺页中断。

缺页中断的处理流程大概是这样的:

  1. CPU 触发缺页异常,操作系统接管
  2. 检查虚拟地址是否合法(是不是非法访问?)
  3. 如果合法,从磁盘上把数据加载到物理内存
  4. 更新页表,建立映射关系
  5. 重新执行触发缺页的那条指令

嗯,这里要注意:缺页中断是「同步」的。进程在等磁盘 I/O 的时候,会被挂起。如果频繁缺页,性能会惨不忍睹。

我曾经调过一个 Java 应用,堆内存设得太大,物理内存装不下,结果频繁缺页。磁盘 I/O 成了瓶颈,CPU 大部分时间在等磁盘。这就是典型的「颠簸」(Thrashing)。

颠簸的代价:系统大部分时间花在换入换出上,实际工作几乎停滞。CPU 利用率可能很高,但全是中断处理的开销。

2.5 页面置换算法

物理内存满了,又要加载新页——必须把某个旧页踢出去。踢谁?这就是页面置换算法的事。

常见的算法有这些:

算法 思路 优缺点
FIFO 先进先出 实现简单,但可能踢掉常用页
LRU 最近最少使用 效果好,但实现成本高
Clock 近似 LRU,用标志位 折中方案,Linux 在用
LFU 最不经常使用 适合长期模式,不适应突发

我个人最推荐理解 LRU。虽然实际系统很少用纯 LRU(太费资源),但它的思想是所有置换算法的基准。

LRU 的核心假设:如果一页最近被访问过,那么它很可能很快还会被访问。反过来,很久没被访问的页,以后被访问的概率也低。

Linux 实际用的是 Clock 算法的变种。它给每个页一个「访问位」,定期扫描。如果访问位是 1,就清零并跳过;如果是 0,就置换出去。说白了,就是给每个页一次「免死金牌」。

避坑指南:我曾经在数据库缓存层用过 FIFO 置换,结果热点数据频繁被踢,命中率惨不忍睹。后来换成 LRU 近似算法,命中率从 60% 提升到了 92%。选置换算法,一定要看你的访问模式。

好了,这一章的内容就到这。虚拟内存、分页、页表、TLB、缺页中断、置换算法——这些概念环环相扣。你理解透了,后面讲内存分配、内存池、大页优化,才能真的听懂。

下一章,我们聊聊进程的内存布局和堆栈管理。到时候我会分享一个我踩过的坑——栈溢出导致的神秘崩溃。