05|内存管理
每个进程都以为自己独占了整个内存空间,这个”幻觉”是操作系统精心构建的。本章将揭开虚拟内存的面纱,深入分页、分段、页表、TLB 与内存分配算法,并理解 OOM 的来龙去脉。
📋 开篇自测:你已经知道多少?
- 什么是虚拟内存?为什么不直接让进程使用物理地址?
- 分页和分段有什么区别?现代操作系统主要使用哪种?
- 什么是 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 未命中 |
| |
+----------------------------------------------------+
📝 结尾自测:检验你的收获
- 虚拟内存解决了直接使用物理地址的哪三个问题?
- 32 位系统使用 4KB 页面,一级页表需要多少内存?多级页表如何解决这个问题?
- TLB 是什么?它为什么能大幅提升内存访问速度?进程切换时 TLB 会怎样?
- LRU 和 Clock 算法的核心区别是什么?Linux 实际使用的是哪种算法的变种?
- 什么是 OOM Killer?如何保护关键进程不被 OOM Kill?
购买课程解锁全部内容
系统底层入门:10 章掌握操作系统核心
¥29.90