内存管理
概念
内存管理是操作系统的核心职责之一,负责协调进程对物理内存的使用。其目标是:让多个进程安全隔离地共享有限的物理内存,同时提供比物理内存更大的地址空间幻觉。
关键术语速览:
| 术语 | 含义 |
|---|---|
| 虚拟地址 | 进程看到的地址,由 OS 映射到物理地址 |
| 物理地址 | 实际内存芯片上的地址 |
| 页(Page) | 虚拟地址空间的固定大小块(通常 4KB) |
| 页框(Frame) | 物理内存的固定大小块,大小与页相同 |
| MMU | 内存管理单元,硬件完成地址翻译 |
| TLB | 快表,缓存最近使用的页表项 |
| 缺页中断 | 访问未映射到物理内存的虚拟页时触发 |
核心原理
1. 虚拟内存
为什么需要虚拟内存
- 进程隔离:每个进程拥有独立的虚拟地址空间,无法直接访问其他进程的内存,防止越界破坏。
- 地址空间统一:每个进程都从地址
0x0开始,编译器和链接器无需关心物理内存布局。 - 按需加载:程序启动时不必全部装入内存,只在访问时才分配物理页,节省内存。
- 内存超用(Overcommit):进程申请的虚拟内存总量可以超过物理内存,配合换页机制使用。
虚拟地址 → 物理地址翻译(MMU + 页表)
虚拟地址(VA)
|
v
+-------+--------+--------+
| VPN2 | VPN1 | Offset | (多级页表示例,VPN = 虚拟页号)
+-------+--------+--------+
| |
v v
页全局目录 → 页中间目录 → 页表 → 页表项(PTE)
|
v
物理页框号(PFN)
|
v
PFN << 12 | Offset = 物理地址(PA)整个翻译由硬件 MMU 自动完成,OS 负责维护页表数据结构。
进程虚拟地址空间布局(以 64 位 Linux 为例)
高地址
+---------------------------+ 0xFFFFFFFFFFFFFFFF
| 内核空间 | (用户态不可访问)
+---------------------------+ 0xFFFF800000000000
| |
| (不可用间隙) |
| |
+---------------------------+ 0x00007FFFFFFFFFFF
| 栈(Stack) | 向低地址增长 ↓
| (局部变量、返回地址) |
+---------------------------+
| ... |
+---------------------------+
| 内存映射区(mmap 区) | 动态库、匿名映射
+---------------------------+
| ... |
+---------------------------+
| 堆(Heap) | 向高地址增长 ↑
| (malloc 分配) |
+---------------------------+
| BSS 段(未初始化数据) |
+---------------------------+
| 数据段(已初始化数据) |
+---------------------------+
| 代码段(Text) | 只读,存放指令
+---------------------------+ 0x0000000000400000
低地址2. 分页(Paging)
页与页框
- 虚拟地址空间被划分为固定大小的页(Page),物理内存被划分为同等大小的页框(Frame)。
- 典型大小:4KB( 字节),地址低 12 位为页内偏移。
- 虚拟地址 = 虚拟页号(VPN) + 页内偏移(Offset)。
页表结构
- 一级页表:一张大表,32 位地址空间需 个表项,占用 4MB(每进程),开销大。
- 多级页表:将页表拆成树状结构,只为实际使用的地址范围分配页表节点,节省空间。Linux x86-64 默认使用 4 级页表(PGD → PUD → PMD → PTE)。
- 每个页表项(PTE)存储:物理页框号 + 标志位(Present / Dirty / Accessed / User/Kernel 等)。
TLB(快表)加速地址翻译
多级页表每次翻译需多次内存访问,性能差。TLB 是 MMU 内部的硬件缓存,存储最近使用的 VPN → PFN 映射。
CPU 发出虚拟地址
|
v
查 TLB ──命中──→ 直接得到物理地址(1 个时钟周期级别)
|
未命中(TLB Miss)
|
v
查多级页表(走内存,慢)
|
v
更新 TLB → 返回物理地址- TLB 刷新:进程切换时需刷新 TLB(或使用 ASID 标记避免完全刷新)。
- 命中率通常 > 99%,得益于程序的局部性原理。
缺页中断(Page Fault)处理流程
- CPU 访问虚拟地址,MMU 发现 PTE 的 Present 位为 0。
- CPU 触发缺页中断,陷入内核态。
- OS 缺页中断处理程序检查该地址是否合法(是否在 VMA 范围内)。
- 若合法:找一个空闲页框,将对应页从磁盘(swap 区或文件)读入。
- 更新页表 PTE,设置 Present 位,返回用户态重新执行触发中断的指令。
- 若非法(如访问 NULL):触发 Segmentation Fault,进程收到 SIGSEGV 信号。
3. 分段(Segmentation)
段 vs 页
| 对比项 | 分段 | 分页 |
|---|---|---|
| 划分依据 | 逻辑含义(代码段/数据段/栈段) | 固定大小块 |
| 大小 | 可变 | 固定(4KB) |
| 外碎片 | 有(段间空隙) | 无 |
| 内碎片 | 无 | 有(页内未用空间) |
| 共享与保护 | 按段粒度,直观 | 需要多个页对齐 |
段页式(先分段再分页)
将地址空间先按逻辑划分为段,每段再独立分页。地址翻译:段号 → 段基址 + 页表 → 物理地址。Intel x86 保护模式即采用段页式,但现代 OS(Linux/Windows)将所有段基址设为 0,实际退化为纯分页。
现代 OS 为什么主要用分页
- 页大小固定,管理简单,无外碎片。
- 硬件(MMU/TLB)天然为分页设计优化。
- 便于换页(swap)和内存映射。
- 段页式复杂度高,收益有限。
4. 页面置换算法
当物理内存不足、需要换入新页时,OS 必须选择一个页换出到磁盘。
FIFO(先进先出)
按页进入内存的顺序淘汰最早进入的页。实现简单,但性能差。
Belady 异常:分配的物理页框增多,缺页次数反而增加。FIFO 是唯一会出现此异常的常见算法。
LRU(最近最少使用)
淘汰最长时间未被访问的页,基于时间局部性假设。理论上接近最优,但精确实现开销大(需维护访问时间戳或链表)。
实现方式:
- 双向链表 + 哈希表:每次访问将页移到链表头,淘汰链表尾部。时间复杂度 O(1)。
- 计数器:每个页记录最后访问时间戳,淘汰时扫描最小值,O(n)。
Clock(时钟算法,近似 LRU)
用一个循环链表和一个指针("时钟指针")模拟。每个页有一个 使用位(R bit):
- 访问页时置 R = 1。
- 需要换出时,时钟指针扫描:R = 1 则清零并跳过,R = 0 则换出该页。
优点:O(1) 近似 LRU,Linux 实际使用的策略基于此思想(active/inactive 链表)。
OPT(最优算法)
淘汰未来最长时间不会被访问的页。缺页率最低,是理论基准,但因无法预知未来,只用于评估其他算法。
对比总结
| 算法 | 缺页率 | 实现复杂度 | Belady 异常 | 实用性 |
|---|---|---|---|---|
| OPT | 最低 | 不可实现 | 无 | 理论基准 |
| LRU | 接近最优 | 中(需链表+哈希) | 无 | 高(近似实现) |
| Clock | 接近 LRU | 低 | 无 | 高(Linux 实际使用) |
| FIFO | 较高 | 最低 | 有 | 低 |
5. 内存映射(mmap)
mmap 系统调用将文件或设备映射到进程的虚拟地址空间。
文件映射 vs 匿名映射
- 文件映射:虚拟地址区域对应磁盘文件的某个区间,读写内存即读写文件(由 OS 在合适时机同步)。
- 匿名映射:不对应任何文件,常用于分配大块内存(
malloc大对象底层即用此方式)。
共享映射 vs 私有映射(写时复制 COW)
- 共享映射(MAP_SHARED):多个进程映射同一物理页,写入对所有进程可见,也写回文件。
- 私有映射(MAP_PRIVATE):写时复制(Copy-On-Write)。初始时共享同一物理页,某进程写入时内核为其复制一份私有副本,其他进程不受影响。
fork() 就利用 COW:父子进程共享所有物理页,只有在写入时才复制,大幅降低 fork 开销。
主要用途
| 用途 | 说明 |
|---|---|
| 文件 I/O 优化 | 绕过 read/write 系统调用,减少数据拷贝次数 |
| 进程间共享内存 | MAP_SHARED 让多进程共享同一段物理内存 |
| 动态库加载 | .so / .dll 以文件映射方式加载,多进程共享代码段 |
| 大内存分配 | malloc 对 > 128KB 的请求使用匿名 mmap |
6. 内存分配
malloc 底层:brk vs mmap
glibc 的 malloc 有两种向 OS 申请内存的方式:
| 方式 | 触发条件 | 系统调用 | 特点 |
|---|---|---|---|
brk | 申请 < 128KB | brk / sbrk | 移动堆顶指针,适合小对象,不立即归还 OS |
mmap | 申请 ≥ 128KB | mmap + munmap | 独立映射,free 后立即归还 OS |
128KB 阈值(MMAP_THRESHOLD)可动态调整。malloc 内部维护空闲链表,优先复用已释放的内存块,减少系统调用次数。
内存碎片
- 内碎片(Internal Fragmentation):分配的块比请求的大,多余部分浪费。例如请求 10 字节,分配了 16 字节对齐块。
- 外碎片(External Fragmentation):空闲块总量足够,但分散成小块,无法满足大的连续分配请求。
伙伴系统(Buddy System)
Linux 内核用于管理物理页框的分配算法:
- 内存按 个页框为单位管理(order 0 = 1 页,order 11 = 2048 页)。
- 分配时找最小满足需求的 order,若无则拆分更大的块(一分为二,两块互为"伙伴")。
- 释放时检查伙伴是否空闲,若是则合并,反复向上合并,减少外碎片。
Slab 分配器
伙伴系统以页为粒度,对内核中大量小对象(如 task_struct、inode)效率低。Slab 在伙伴系统之上增加对象缓存层:
- 为每种固定大小的对象维护一个缓存(slab cache)。
- 预先分配一批对象,分配/释放时直接从缓存取回,避免反复初始化。
- 减少内碎片,提升小对象分配速度。
面试常问 & 怎么答
Q1:什么是虚拟内存?为什么需要它?
答题思路:先定义,再说三个核心原因,可提 MMU + 页表机制。
虚拟内存是 OS 为每个进程提供的独立地址空间抽象,由 MMU 将虚拟地址动态映射到物理地址。
需要它的原因:
- 进程隔离:每个进程有独立的地址空间,无法随意访问其他进程内存,保证安全性。
- 地址空间统一:程序编译时使用固定的虚拟地址,无需关心实际物理内存位置。
- 按需加载 + 内存超用:物理内存不足时,将暂时不用的页换到磁盘(swap),进程申请的虚拟空间可超过物理内存总量。
Q2:页面置换算法有哪些?LRU 怎么实现?
答题思路:列出四种算法,重点介绍 LRU 的 O(1) 实现,提 Clock 是实际使用的近似方案。
常见算法:OPT(理论最优)、LRU(最近最少使用)、Clock(时钟,近似 LRU)、FIFO(先进先出,有 Belady 异常)。
LRU 精确实现:双向链表 + 哈希表。
- 哈希表:页号 → 链表节点,O(1) 查找。
- 双向链表:维护访问顺序,最近访问的节点移到头部,尾部节点是最久未用的。
- 访问页:O(1) 查哈希 → 将节点移到头部。
- 缺页换出:O(1) 移除并返回尾部节点。
实际系统:精确 LRU 需要在每次访问时修改链表,代价高。Linux 使用基于 Clock 思想的双链表(active list / inactive list)近似实现。
Q3:malloc 底层是怎么实现的?
答题思路:分两层回答——用户态 glibc 的空闲链表复用,以及向 OS 申请内存的两种方式。
malloc分两层工作:用户态(glibc ptmalloc):
- 维护多个空闲链表(bins),按大小分类(fast bins、small bins、large bins 等)。
malloc时优先从空闲链表中找合适块复用,避免频繁系统调用。free时将块放回对应链表,相邻空闲块会合并以减少碎片。向 OS 申请内存:
- 小于 128KB:调用
brk/sbrk扩展堆顶,内存释放后不立即归还 OS(留在堆中复用)。- 大于等于 128KB:调用
mmap申请独立匿名映射,free后调用munmap立即归还 OS。这样设计是权衡系统调用开销与内存归还及时性的结果。
看到什么就先想到这类
| 关键词 / 场景 | 联想到 |
|---|---|
| 进程间内存隔离 | 虚拟内存、页表、MMU |
| 地址翻译慢 / TLB miss | TLB、多级页表、页大小 |
| 程序访问 NULL 崩溃 | 缺页中断 → Segmentation Fault |
| 物理内存不足 | 页面置换算法、swap |
| 多进程共享库 | mmap 文件映射、共享页 |
| fork 很快 / 写时复制 | COW(Copy-On-Write)、私有映射 |
| malloc 大对象 vs 小对象 | brk vs mmap,128KB 阈值 |
| 内核分配小对象高效 | Slab 分配器 |
| 物理页框分配 | 伙伴系统(Buddy System) |
| 内存泄漏 / 碎片 | 内碎片 vs 外碎片,堆管理 |