跳到主要内容
预计阅读 31 分钟

05|内存管理

每个进程都以为自己独占了整个内存空间,这个”幻觉”是操作系统精心构建的。本章将揭开虚拟内存的面纱,深入分页、分段、页表、TLB 与内存分配算法,并理解 OOM 的来龙去脉。

📋 开篇自测:你已经知道多少?

  1. 什么是虚拟内存?为什么不直接让进程使用物理地址?
  2. 分页和分段有什么区别?现代操作系统主要使用哪种?
  3. 什么是 OOM Killer?它在什么情况下会被触发?

一、为什么需要虚拟内存

1.1 直接使用物理地址的灾难

在最早期的计算机上,程序直接使用物理内存地址。这带来了三个严重问题:

问题1: 内存不够用
  物理内存: 4GB
  程序A: 需要 3GB
  程序B: 需要 2GB
  --> 无法同时运行!

问题2: 地址冲突
  程序A 在编译时把数据放在地址 0x1000
  程序B 也在编译时把数据放在地址 0x1000
  --> 同时运行必然冲突!

问题3: 安全隐患
  程序A 可以直接访问地址 0x2000
  恰好 0x2000 是程序B的数据
  --> 程序间没有隔离!

1.2 虚拟内存的核心思想

虚拟内存的核心思想是在程序和物理内存之间加一层地址翻译

进程A 看到的世界:                进程B 看到的世界:
+------------------+            +------------------+
| 0xFFFFFFFF       |            | 0xFFFFFFFF       |
|                  |            |                  |
| (内核空间)       |            | (内核空间)       |
|                  |            |                  |
| 0xC0000000       |            | 0xC0000000       |
| 栈              |            | 栈               |
| ...             |            | ...              |
| 堆              |            | 堆               |
| 数据段          |            | 数据段            |
| 代码段          |            | 代码段            |
| 0x08048000       |            | 0x08048000       |
+------------------+            +------------------+
  "我拥有4GB内存!"               "我也拥有4GB内存!"

        |                              |
        | 地址翻译 (MMU)                | 地址翻译 (MMU)
        v                              v

实际物理内存 (可能只有 2GB):
+--------------------------------------------------+
| 进程A代码 | 进程B数据 | 进程A栈 | 空闲 | 进程B代码 |
+--------------------------------------------------+
0x00000000                                   0x80000000

每个进程都有自己独立的虚拟地址空间(通常 32 位系统为 4GB,64 位系统为 256TB),但实际上这些虚拟地址通过页表映射到有限的物理内存。如果物理内存不够,一些暂时不用的数据还可以被交换到磁盘的 swap 分区中。

1.3 虚拟内存的三大好处

隔离性:每个进程只能访问自己的虚拟地址空间,无法直接触及其他进程的内存。

便利性:每个进程都从相同的虚拟地址开始,编译器不需要考虑程序实际加载到哪个物理地址。

超额分配:所有进程的虚拟地址空间总和可以远大于物理内存——因为大多数程序并不会同时使用所有分配的内存。


二、分页机制

2.1 基本分页

分页的思想是把虚拟地址空间和物理内存都切割成固定大小的块:

  • 虚拟地址空间的块叫页(Page)
  • 物理内存的块叫页帧/页框(Page Frame)
  • 通常大小为 4KB(也有 2MB、1GB 的大页)
虚拟地址空间 (进程A)         物理内存
+--------+ 页号0             +--------+ 帧号0
| 页 0   | ----映射-------> | 帧 5   |
+--------+ 页号1             +--------+ 帧号1
| 页 1   | ----映射---+     | 帧 2   |
+--------+ 页号2      |     +--------+ 帧号2
| 页 2   | --磁盘     +---> | 页1映射 |
+--------+ 页号3             +--------+ 帧号3
| 页 3   | ----映射---+     | (空闲)  |
+--------+            |     +--------+ 帧号4
                      |     | (其他进程)|
                      |     +--------+ 帧号5
                      |     | 页0映射 |
                      |     +--------+ 帧号6
                      +---> | 页3映射 |
                            +--------+

虚拟地址到物理地址的翻译过程:

虚拟地址 (32位, 4KB页):
+--------------------+--------------+
|  页号 (20位)        | 页内偏移(12位)|
+--------------------+--------------+

翻译过程:
  1. 取出页号 (高20位)
  2. 查页表,找到对应的帧号
  3. 帧号 + 页内偏移 = 物理地址

例: 虚拟地址 0x00003A7F
  页号 = 0x00003 = 3
  偏移 = 0xA7F
  查页表: 页3 -> 帧6
  物理地址 = 帧6的起始地址 + 0xA7F = 0x6000 + 0xA7F = 0x6A7F

2.2 页表(Page Table)

页表是虚拟地址到物理地址的映射表,每个进程都有自己的页表。

页表项 (Page Table Entry, PTE) 的结构:

+----+---+---+---+---+---+------------------------+
| P  | R | M | U | G | NX|     帧号 (物理地址高位)  |
+----+---+---+---+---+---+------------------------+

P  (Present)   : 1=在物理内存中, 0=不在(在磁盘上)
R  (Read/Write): 1=可读写, 0=只读
M  (Modified)  : 1=页面被修改过(脏页)
U  (User)      : 1=用户态可访问, 0=仅内核态
G  (Global)    : 1=全局页(不随进程切换而刷新TLB)
NX (No Execute): 1=不可执行(安全保护)

2.3 多级页表

一个 32 位系统,4KB 页,需要 2^20 = 1048576 个页表项。每个页表项 4 字节,一张页表就要 4MB。如果有 100 个进程,光页表就要 400MB。这显然不可接受。

解决方案:多级页表。只为实际使用的虚拟地址区域创建页表项。

二级页表 (x86-32):

虚拟地址:
+------------+------------+--------------+
| 一级索引    | 二级索引    | 页内偏移      |
| (10位)     | (10位)     | (12位)       |
+------------+------------+--------------+

    一级页表 (页目录)          二级页表
    +--------+              +--------+
 0  | 指针   | -----------> | PTE 0  |  -> 物理帧
    +--------+              | PTE 1  |  -> 物理帧
 1  | NULL   | (未使用)     | ...    |
    +--------+              | PTE 1023| -> 物理帧
 2  | 指针   | ---+         +--------+
    +--------+    |
 3  | NULL   |    |         +--------+
    +--------+    +-------> | PTE 0  |  -> 物理帧
    | ...    |              | PTE 1  |  -> 物理帧
    +--------+              | ...    |
1023| NULL   |              +--------+
    +--------+

优势: 大部分二级页表不需要创建 (NULL指针)
     实际内存占用远小于完整的一级页表

x86-64 使用四级页表(PGD -> PUD -> PMD -> PTE),Linux 4.14(2017年)起支持五级页表(CONFIG_X86_5LEVEL),可寻址 128 PiB 的虚拟地址空间。

2.4 TLB:页表的缓存

每次内存访问都要查页表(多级页表更要多次内存访问),这太慢了。CPU 中有一个专门的高速缓存叫做 TLB(Translation Lookaside Buffer,地址转换后备缓冲)

CPU 访问内存的完整路径:

  CPU 发出虚拟地址
        |
        v
  +----------+
  |   TLB    |  -- 命中 --> 直接获得物理地址 (极快, 约1个时钟周期)
  +----------+
        |
        | 未命中 (TLB Miss)
        v
  +----------+
  | 页表查询  |  -- 查找多级页表 (慢, 可能需要4次内存访问)
  +----------+
        |
        | 页表项中 P=1 (在内存中)
        v
  更新 TLB, 获得物理地址
        |
        | 页表项中 P=0 (不在内存中)
        v
  +----------+
  | 缺页异常  |  -- 从磁盘加载页面到内存 (极慢, 毫秒级)
  +----------+

TLB 条目数通常只有 64-1024 个,但由于程序的局部性原理(倾向于反复访问相近的地址),TLB 的命中率通常在 99% 以上。

🤔 想一想 进程切换时,TLB 需要被刷新(因为新进程的页表不同)。这个开销有多大?有什么办法减少它?


三、分段与段页式

3.1 分段机制

分段按照程序的逻辑结构来划分内存——代码段、数据段、堆、栈各自是独立的段:

分段地址:
+----------+------------+
| 段选择子  | 段内偏移    |
+----------+------------+

段表:
+------+----------+--------+------+
| 段号  | 基地址    | 段长度  | 权限 |
+------+----------+--------+------+
|  0   | 0x10000  | 0x5000 | RX   |  代码段
|  1   | 0x20000  | 0x3000 | RW   |  数据段
|  2   | 0x80000  | 0x2000 | RW   |  栈段
+------+----------+--------+------+

物理地址 = 段基地址 + 段内偏移 (需检查不超过段长度)

3.2 分页 vs 分段

特性分页分段
划分粒度固定大小(4KB)可变大小
对程序员透明可见(有逻辑意义)
外部碎片
内部碎片有(最后一页可能未用满)
共享粒度段(整个代码段/数据段)

3.3 现代系统的选择

现代操作系统(Linux x86-64)基本只用分页。虽然 x86 架构仍然保留了分段硬件,但 Linux 将所有段的基地址都设为 0、长度设为最大,相当于”绕过”了分段,让分页机制全权负责地址翻译。

64 位模式(Long Mode)下,段寄存器几乎失去了意义——处理器本身就弱化了分段机制。


四、页面置换算法

4.1 缺页中断

当程序访问的虚拟页面不在物理内存中(页表项 P=0)时,就会触发缺页中断(Page Fault)。内核的缺页处理流程:

缺页中断发生
      |
      v
检查访问是否合法
      |
  +---+---+
  |       |
合法      非法 --> 发送 SIGSEGV (段错误)
  |
  v
物理内存有空闲帧?
  |
  +---+---+
  |       |
  有      没有 --> 选择一个页面换出 (页面置换算法)
  |              |
  |              v
  |         该页面是脏页?
  |           |
  |       +---+---+
  |       |       |
  |       是      否
  |       |       |
  |    写回磁盘   直接丢弃
  |       |       |
  |       +---+---+
  |           |
  +-----+-----+
        |
        v
  从磁盘加载目标页面到空闲帧
        |
        v
  更新页表项 (设置帧号, P=1)
        |
        v
  重新执行导致缺页的指令

4.2 常见页面置换算法

最优置换(OPT):替换将来最长时间不会使用的页面。理论最优但无法实现(需要预知未来),用作对比基准。

先进先出(FIFO):替换最早进入内存的页面。

访问序列: 7 0 1 2 0 3 0 4

3个帧:
步骤  访问  帧1  帧2  帧3  缺页?
 1     7    7              是
 2     0    7    0         是
 3     1    7    0    1    是
 4     2    2    0    1    是 (替换最早的7)
 5     0    2    0    1    否
 6     3    2    3    1    是 (替换最早的0)
 7     0    2    3    0    是 (替换最早的1)
 8     4    4    3    0    是 (替换最早的2)

缺页次数: 7

最近最少使用(LRU, Least Recently Used):替换最近最长时间未被访问的页面。

同样的访问序列: 7 0 1 2 0 3 0 4

3个帧 (LRU):
步骤  访问  帧状态(左=最近使用)  缺页?
 1     7    [7]                 是
 2     0    [0, 7]              是
 3     1    [1, 0, 7]           是
 4     2    [2, 1, 0]           是 (替换最久未用的7)
 5     0    [0, 2, 1]           否 (0移到最前面)
 6     3    [3, 0, 2]           是 (替换最久未用的1)
 7     0    [0, 3, 2]           否
 8     4    [4, 0, 3]           是 (替换最久未用的2)

缺页次数: 6 (比 FIFO 少)

时钟算法(Clock / Second Chance):LRU 的近似实现,更适合实际系统。

                    指针
                      |
                      v
        +---+   +---+   +---+   +---+
        | A |-->| B |-->| C |-->| D |--+
        | 1 |   | 0 |   | 1 |   | 0 |  |
        +---+   +---+   +---+   +---+  |
          ^                             |
          +-----------------------------+

每个页面有一个"引用位" (0或1)
替换过程:
  1. 检查指针指向的页面
  2. 如果引用位=1,将其清零,指针前移 (给第二次机会)
  3. 如果引用位=0,替换此页面

Linux 实际使用的是改进版的 LRU 算法——双链表 LRU(Active/Inactive 链表)

  Active 链表 (活跃页面):
  [热页面] <-> [较热页面] <-> [温页面]

  Inactive 链表 (不活跃页面):
  [凉页面] <-> [较冷页面] <-> [最冷页面]

  - 新页面先进入 Inactive 链表
  - 被再次访问时提升到 Active 链表
  - Active 过长时,尾部降级到 Inactive
  - 需要回收时,从 Inactive 链表尾部换出

五、内存分配算法

5.1 内核态内存分配

Linux 内核使用**伙伴系统(Buddy System)**管理物理页帧:

伙伴系统:
  将物理内存按 2 的幂次大小分组管理

  阶  块大小    空闲块链表
  0   4KB      [块] [块] [块] ...
  1   8KB      [块] [块] ...
  2   16KB     [块] ...
  3   32KB     [块] [块] ...
  ...
  10  4MB      [块] ...

分配 12KB 内存:
  1. 12KB > 8KB, 需要 16KB 的块 (阶2)
  2. 如果阶2 没有空闲块, 从阶3 拆分一个 32KB 块
     32KB -> 16KB + 16KB (互为"伙伴")
  3. 返回一个 16KB 块, 另一个加入阶2空闲链表

释放时:
  检查伙伴是否也空闲,如果是则合并成更大的块

对于小于一页的内存分配(如几十字节的内核数据结构),Linux 使用 Slab 分配器

Slab 分配器:
  针对特定大小的对象预先分配和缓存

  task_struct 缓存:
  +------+------+------+------+------+
  | obj  | obj  | obj  | 空闲 | 空闲  |
  +------+------+------+------+------+
  一个 Slab (一页或多页)

  优势:
  - 避免频繁分配/释放小对象的开销
  - 减少内部碎片
  - 对象构造/析构可以缓存

5.2 用户态内存分配

用户程序调用 malloc() 分配内存时,glibc 内部使用的是 ptmalloc2 分配器:

malloc(size) 的幕后:

  size <= 128KB:
    使用 brk() 系统调用扩展堆
    +----------+-------+----------+
    | 已分配    | 空闲  | brk 指针  |
    +----------+-------+-----^----+
                              |
                         brk(新地址)

  size > 128KB:
    使用 mmap() 系统调用直接映射
    从虚拟地址空间的中间区域分配一大块
    (释放时直接 munmap,不留碎片)

🤔 想一想 为什么 malloc(1GB) 可能立即成功返回,但实际使用这块内存时才分配物理页面?这和”惰性分配”有什么关系?


六、OOM Killer

6.1 内存耗尽怎么办

当系统的物理内存和 swap 空间都耗尽时,Linux 的 OOM Killer(Out-Of-Memory Killer) 就会出手——它会选择一个”最该死”的进程并将其杀掉,以释放内存。

OOM Killer 的评分机制:

每个进程有一个 oom_score (0-1000):

影响因素:
  + 内存使用量越大,分数越高
  + 运行时间越短,分数越高(长期运行的进程通常更重要)
  + root 用户的进程适当减分

管理员可以手动调整:
  /proc/<pid>/oom_score_adj   (-1000 到 1000)
  -1000 = 永远不会被 OOM Kill
  0     = 默认
  1000  = 优先被杀
# 查看进程的 OOM 分数
cat /proc/$(pgrep mysql)/oom_score

# 保护关键进程不被 OOM Kill
echo -1000 > /proc/$(pgrep mysql)/oom_score_adj

# 查看 OOM Kill 日志
dmesg | grep -i "out of memory"

6.2 overcommit 策略

Linux 默认允许内存超额分配(overcommit):malloc() 可以返回比实际物理内存+swap 更多的内存,因为内核赌的是你不会真正全部用到。

# 查看 overcommit 策略
cat /proc/sys/vm/overcommit_memory

# 0 = 启发式超额分配 (默认,允许合理范围的超额)
# 1 = 总是允许超额分配 (永不拒绝 malloc)
# 2 = 不允许超额分配 (严格模式)

# 策略2下的限制:
# 可分配内存 = swap大小 + 物理内存 * overcommit_ratio/100
cat /proc/sys/vm/overcommit_ratio  # 默认 50

七、大页(Huge Pages)

对于使用大量内存的应用(如数据库、JVM),4KB 的标准页面意味着大量的页表项和频繁的 TLB 未命中。大页可以显著减少这个开销:

标准页面 vs 大页:

使用 1GB 内存:
  4KB 页:  需要 262144 个页表项, TLB 很容易未命中
  2MB 大页: 需要 512 个页表项, TLB 压力大幅降低
  1GB 巨页: 需要 1 个页表项

配置大页:
# 分配 1024 个 2MB 大页 (共 2GB)
echo 1024 > /proc/sys/vm/nr_hugepages

# 查看大页使用情况
cat /proc/meminfo | grep Huge

八、本章总结

+----------------------------------------------------+
|                内存管理核心知识                       |
+----------------------------------------------------+
|                                                    |
|  虚拟内存: 隔离 + 便利 + 超额分配                    |
|                                                    |
|  分页: 虚拟页 -> 物理帧 (通过页表映射)               |
|    多级页表: 节省空间 (只创建需要的部分)              |
|    TLB: 页表的硬件缓存 (99%+ 命中率)                |
|                                                    |
|  页面置换: FIFO < LRU < Clock                      |
|    Linux: Active/Inactive 双链表                    |
|                                                    |
|  内存分配:                                          |
|    内核态: 伙伴系统(页级) + Slab(小对象)             |
|    用户态: brk(小) + mmap(大)                       |
|                                                    |
|  OOM Killer: 内存耗尽时杀进程                        |
|  大页: 减少 TLB 未命中                              |
|                                                    |
+----------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 虚拟内存解决了直接使用物理地址的哪三个问题?
  2. 32 位系统使用 4KB 页面,一级页表需要多少内存?多级页表如何解决这个问题?
  3. TLB 是什么?它为什么能大幅提升内存访问速度?进程切换时 TLB 会怎样?
  4. LRU 和 Clock 算法的核心区别是什么?Linux 实际使用的是哪种算法的变种?
  5. 什么是 OOM Killer?如何保护关键进程不被 OOM Kill?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90