04 - 内存管理¶
⏰ 建议学习时间:3 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:进程概念、CPU 调度基础、计算机组成原理基础
📋 本章目录¶
一、内存层次结构¶
现代计算机采用多级存储体系,在访问速度、容量和成本之间取得平衡。越靠近 CPU 的存储器速度越快、容量越小、价格越贵。
1.1 存储层次总览¶
┌──────────┐
│ 寄存器 │ ← 最快,纳秒以下
├──────────┤
│ L1 Cache │ ← ~1ns
├──────────┤
│ L2 Cache │ ← ~3-10ns
├──────────┤
│ L3 Cache │ ← ~10-30ns
├──────────┤
│ 内存DRAM │ ← ~50-100ns
├──────────┤
│ SSD │ ← ~100μs
├──────────┤
│ 机械硬盘 │ ← ~5-10ms
└──────────┘
1.2 各层延迟与容量对比¶
| 存储层级 | 典型延迟 | 典型容量 | 每 GB 成本(估) | 说明 |
|---|---|---|---|---|
| 寄存器 | < 1ns | 几百 B ~ 几 KB | — | CPU 内部,速度最快 |
| L1 Cache | ~1ns(约 4 个时钟周期) | 32KB ~ 64KB / 核 | — | 分为 I-Cache 与 D-Cache |
| L2 Cache | ~3-10ns(约 10 个时钟周期) | 256KB ~ 1MB / 核 | — | 每核独享 |
| L3 Cache | ~10-30ns(约 30-40 个时钟周期) | 8MB ~ 64MB | — | 多核共享 |
| 内存(DRAM) | ~50-100ns | 8GB ~ 512GB | $3-5 | 主存,断电丢失 |
| SSD(NVMe) | ~10-100μs | 256GB ~ 8TB | $0.1-0.3 | 闪存,非易失性 |
| 机械硬盘(HDD) | ~5-10ms | 1TB ~ 20TB | $0.02-0.05 | 旋转盘片,寻道时间长 |
💡 关键洞察:从 L1 缓存到磁盘,访问延迟增大了约 1000 万倍。这解释了为什么虚拟内存中的缺页中断代价极高。
1.3 局部性原理¶
内存层次结构之所以有效,依赖于程序的局部性原理(Locality):
- 时间局部性:最近访问的数据很可能在不久的将来再次被访问(如循环变量)
- 空间局部性:访问某地址后,其附近的地址也有很大概率被访问(如数组遍历)
二、地址空间¶
2.1 物理地址 vs 虚拟地址¶
| 概念 | 说明 |
|---|---|
| 物理地址(Physical Address) | 内存芯片上的实际地址,由地址总线直接寻址 |
| 虚拟地址(Virtual Address) | 进程看到的逻辑地址,由 CPU 生成,需要转换为物理地址 |
| 地址空间 | 一个进程可使用的所有虚拟地址的集合 |
2.2 地址转换流程(MMU 的角色)¶
MMU(Memory Management Unit,内存管理单元) 是 CPU 内部的硬件模块,负责将虚拟地址实时翻译为物理地址。
CPU 产生虚拟地址
│
▼
┌───────────┐ 命中 ┌─────────┐
│ TLB │──────────────→│ 物理地址 │──→ 访问内存
└───────────┘ └─────────┘
│ 未命中
▼
┌───────────┐ 找到 ┌─────────┐
│ 页表查询 │──────────────→│ 物理地址 │──→ 更新 TLB → 访问内存
└───────────┘ └─────────┘
│ 页不在内存(缺页)
▼
┌──────────────┐
│ 缺页中断处理 │──→ 从磁盘调入页面 → 更新页表 → 重新执行指令
└──────────────┘
2.3 为什么需要虚拟内存?¶
- 进程隔离:每个进程拥有独立的虚拟地址空间,一个进程无法直接访问另一个进程的内存,保证安全性
- 超额使用(Overcommit):所有进程的虚拟地址空间之和可以远大于物理内存,因为不是所有页面都需要同时驻留在内存中
- 简化编程:程序员不需要关心物理内存的实际布局,每个进程都"以为"自己独占整个地址空间
- 共享内存:多个进程可以将各自的虚拟页面映射到同一物理页面,实现高效的进程间通信
- 内存映射文件:可以把文件映射到虚拟地址空间,像访问内存一样访问文件
三、分页机制¶
3.1 页与页框¶
- 页(Page):虚拟地址空间被划分为固定大小的块,称为"页"。典型大小:4KB(也有 2MB、1GB 大页)
- 页框(Frame):物理内存被划分为与页相同大小的块,称为"页框"或"帧"
虚拟地址空间 物理内存
┌─────────┐ ┌─────────┐
│ 页 0 │──────────→│ 帧 2 │
├─────────┤ ├─────────┤
│ 页 1 │──────┐ │ 帧 0 │
├─────────┤ │ ├─────────┤
│ 页 2 │──┐ └──→│ 帧 5 │
├─────────┤ │ ├─────────┤
│ 页 3 │ │ │ 帧 1 │
├─────────┤ │ ├─────────┤
│ ... │ └─────→│ 帧 3 │
└─────────┘ ├─────────┤
│ 帧 4 │
├─────────┤
│ ... │
└─────────┘
地址分解(以 32 位地址、4KB 页为例):
虚拟地址(32位):
┌──────────────────────┬────────────┐
│ 页号(20位) │ 页内偏移(12位)│
│ 可寻址 2^20 = 1M 页 │ 4KB 内偏移 │
└──────────────────────┴────────────┘
3.2 页表结构(页号→帧号映射)¶
页表(Page Table) 存储在内存中,每个进程有一张独立的页表。
| 页号 | 帧号 | 存在位 | 修改位 | 访问位 | 保护位 |
|---|---|---|---|---|---|
| 0 | 2 | 1 | 0 | 1 | R/W |
| 1 | 5 | 1 | 1 | 1 | R/W |
| 2 | 3 | 1 | 0 | 0 | R |
| 3 | — | 0 | — | — | — |
3.3 页表项(PTE)结构¶
┌───────┬───────┬───────┬───────┬───────┬──────────────┐
│存在位P│修改位D│访问位A│保护位 │缓存禁止│ 帧号(PFN) │
│ 1bit │ 1bit │ 1bit │ 2bit │ 1bit │ 20bit │
└───────┴───────┴───────┴───────┴───────┴──────────────┘
各标志位含义:
| 标志位 | 全称 | 说明 |
|---|---|---|
| P(Present) | 存在位 | =1 表示该页在物理内存中;=0 表示在磁盘上(缺页) |
| D(Dirty) | 修改位 | =1 表示该页被修改过,换出时需要写回磁盘 |
| A(Accessed) | 访问位 | =1 表示该页被访问过,供页面置换算法参考 |
| R/W | 保护位 | 控制读/写/执行权限 |
3.4 多级页表¶
问题:32 位地址空间 + 4KB 页 → 需要 2^20 = 1M 个页表项。若每项 4 字节,一张页表占 4MB!而大部分地址空间是空的。
解决方案:多级页表,只为实际使用的地址区间创建页表。
二级页表(32 位系统)¶
虚拟地址(32位):
┌────────────┬────────────┬────────────┐
│一级页号(10位)│二级页号(10位)│ 页内偏移(12位)│
└────────────┴────────────┴────────────┘
│ │ │
▼ ▼ │
┌─────────┐ ┌─────────┐ │
│一级页表 │──→│二级页表 │──→ 帧号 + 偏移 → 物理地址
│(页目录) │ │(1024项) │
│(1024项) │ └─────────┘
└─────────┘
四级页表(64 位系统,x86-64)¶
虚拟地址(48位有效):
┌──────┬──────┬──────┬──────┬────────────┐
│PML4 │PDPT │PD │PT │ Offset │
│(9位) │(9位) │(9位) │(9位) │ (12位) │
└──────┴──────┴──────┴──────┴────────────┘
L4 L3 L2 L1 页内偏移
💡 Linux 从 4.14 内核开始支持五级页表,用于支持 57 位虚拟地址空间(128PB)。
3.5 TLB(翻译后备缓冲区)¶
TLB(Translation Lookaside Buffer) 是 MMU 中的高速缓存,缓存最近使用的页表项,避免每次访问内存都查页表。
CPU 生成虚拟地址
│
▼
┌─────────┐
│ 查 TLB │
└─────────┘
/ \
命中 未命中
│ │
▼ ▼
直接得到 查询页表
物理地址 │
│ ┌┴┐
│ 命中 缺页
│ │ │
│ 更新TLB 缺页中断
│ │ │
▼ ▼ ▼
访问物理内存
| 参数 | 典型值 |
|---|---|
| TLB 容量 | 64 ~ 1024 条目 |
| TLB 命中延迟 | 0.5 ~ 1 个时钟周期 |
| TLB 命中率 | > 99%(利用局部性) |
| TLB 未命中代价 | 10 ~ 100 个时钟周期 |
四、分段机制¶
4.1 段的概念¶
分段将进程的地址空间按逻辑功能划分为若干段,每个段有独立的起始地址和长度。
进程的虚拟地址空间(典型布局):
高地址 ┌────────────────┐
│ 内核空间 │
├────────────────┤ ← 用户/内核边界
│ 栈(Stack) │ ↓ 向低地址增长
│ │
│ (空闲区域) │
│ │
│ 堆(Heap) │ ↑ 向高地址增长
├────────────────┤
│ BSS段(未初始化)│
├────────────────┤
│ 数据段(Data) │
├────────────────┤
│ 代码段(Text) │
低地址 └────────────────┘
4.2 段表结构¶
| 段号 | 段基址 | 段长度 | 保护 |
|---|---|---|---|
| 0(代码段) | 0x00400000 | 0x10000 | R/X |
| 1(数据段) | 0x00600000 | 0x08000 | R/W |
| 2(堆) | 0x00700000 | 0x20000 | R/W |
| 3(栈) | 0x7FFF0000 | 0x10000 | R/W |
虚拟地址 = 段号 + 段内偏移 地址转换:物理地址 = 段基址 + 段内偏移(若偏移 < 段长度,否则越界中断)
4.3 分页 vs 分段对比¶
| 特性 | 分页 | 分段 |
|---|---|---|
| 划分依据 | 物理大小(固定) | 逻辑功能(可变) |
| 用户可见性 | 对用户透明 | 用户可感知 |
| 地址空间 | 一维(页号+偏移) | 二维(段号+偏移) |
| 内部碎片 | 有(最后一页不满) | 无 |
| 外部碎片 | 无 | 有(段大小不等) |
| 共享与保护 | 以页为单位(粒度粗) | 以段为单位(更自然) |
4.4 段页式管理¶
段页式 = 分段 + 分页 的结合:先分段,再在每个段内分页。
虚拟地址:
┌────────┬────────────┬────────────┐
│ 段号 │ 段内页号 │ 页内偏移 │
└────────┴────────────┴────────────┘
│ │ │
▼ ▼ │
段表 ──→ 该段的页表 ──→ 帧号 + 偏移 → 物理地址
💡 x86-64 的实际做法:虽然硬件同时支持分段和分页,但现代 Linux/Windows 实际上将所有段基址设为 0,段限长设为最大,等效于只用分页。
五、虚拟内存¶
5.1 请求分页原理¶
请求分页(Demand Paging) 是虚拟内存的核心机制:
- 进程启动时,不需要把所有页面都加载到物理内存
- 只有当 CPU 访问一个不在内存中的页面时,才触发缺页中断(Page Fault),从磁盘将该页调入内存
- 这是一种懒加载(Lazy Loading)策略
5.2 缺页中断处理流程¶
CPU 访问虚拟地址
│
▼
查 TLB → 未命中 → 查页表
│
▼
页表项存在位 P = 0?
│
Yes → 缺页中断!
│
▼
① 保存当前进程上下文
│
▼
② 检查地址是否合法
(是否在进程的虚拟地址空间内?)
│
非法 → 段错误(Segfault),终止进程
│
合法 ↓
▼
③ 在物理内存中找一个空闲帧
├── 有空闲帧 → 直接使用
└── 无空闲帧 → 调用页面置换算法选择"牺牲页"
├── 牺牲页修改位=1 → 写回磁盘
└── 牺牲页修改位=0 → 直接丢弃
│
▼
④ 从磁盘读取缺失页到选定帧(磁盘 I/O,最耗时!)
│
▼
⑤ 更新页表(设 P=1,填入帧号)、更新 TLB
│
▼
⑥ 恢复进程上下文,重新执行引发缺页的指令
5.3 缺页率与性能影响¶
设: - 内存访问时间 = 100ns - 缺页处理时间 = 8ms = 8,000,000ns(含磁盘 I/O) - 缺页率 = p
有效访问时间 = (1-p) × 100ns + p × 8,000,000ns
| 缺页率 p | 有效访问时间 | 性能下降 |
|---|---|---|
| 0(无缺页) | 100ns | — |
| 0.001(千分之一) | 8,100ns | 81 倍 |
| 0.000001 | 108ns | 1.08 倍 |
⚠️ 结论:缺页率必须极低(< 10^-6),否则性能急剧下降。这是页面置换算法如此重要的原因。
5.4 工作集模型与抖动(Thrashing)¶
工作集(Working Set):在一段时间窗口 Δ 内,进程实际访问的页面集合。
抖动(Thrashing):当系统为一个进程分配的物理帧数远少于其工作集大小时: - 频繁缺页 → 大量磁盘 I/O → CPU 利用率暴跌 - OS 以为 CPU 空闲,引入更多进程 → 情况更糟
解决策略: - 使用工作集模型,确保每个进程获得足够的帧 - 采用PFF(Page Fault Frequency)策略:监控缺页率,过高则多分配帧,过低则回收帧 - 若资源不足,挂起部分进程
六、页面置换算法¶
当物理内存帧已满且发生缺页时,需要选择一个"牺牲页"换出。置换算法的目标是最小化缺页率。
6.1 OPT(最优置换算法)¶
原理:替换未来最长时间内不会再被访问的页面。
特点:理论最优,无法实现(需要预知未来),仅用作基准参考。
示例:页面引用串 7,0,1,2,0,3,0,4,2,3,0,3,帧数 = 3
| 访问 | 7 | 0 | 1 | 2 | 0 | 3 | 0 | 4 | 2 | 3 | 0 | 3 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 帧1 | 7 | 7 | 7 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
| 帧2 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 0 | 0 | |
| 帧3 | 1 | 1 | 1 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | ||
| 缺页 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
缺页次数:7
def opt_page_replacement(pages, frame_size):
"""OPT 最优页面置换算法"""
frames = []
page_faults = 0
for i, page in enumerate(pages):
if page not in frames:
page_faults += 1
if len(frames) < frame_size:
frames.append(page)
else:
# 找未来最远使用的页面
farthest = -1
victim = 0
for j, frame in enumerate(frames):
try: # try/except捕获异常
next_use = pages[i + 1:].index(frame)
except ValueError:
next_use = float('inf') # 未来不再使用
if next_use > farthest:
farthest = next_use
victim = j
frames[victim] = page
return page_faults
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"OPT 缺页次数: {opt_page_replacement(pages, 3)}") # 输出: 9
6.2 FIFO(先进先出)¶
原理:替换最早进入内存的页面(先来先走)。
优点:实现简单(队列) 缺点:可能换出仍在频繁使用的页面;存在 Belady 异常
Belady 异常¶
📋 Belady 异常:增加物理帧数,缺页次数反而增多!该现象常见于 FIFO 等非栈算法;而 LRU、OPT 等栈算法不会出现。
经典反例:页面引用串 1,2,3,4,1,2,5,1,2,3,4,5
| 帧数 | 缺页次数 |
|---|---|
| 3 | 9 |
| 4 | 10 |
from collections import deque
def fifo_page_replacement(pages, frame_size):
"""FIFO 先进先出页面置换算法"""
frames = deque(maxlen=frame_size)
page_set = set()
page_faults = 0
for page in pages:
if page not in page_set:
page_faults += 1
if len(frames) == frame_size:
removed = frames.popleft()
page_set.discard(removed)
frames.append(page)
page_set.add(page)
return page_faults
# 演示 Belady 异常
pages = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
print(f"FIFO (3帧): {fifo_page_replacement(pages, 3)} 次缺页") # 9
print(f"FIFO (4帧): {fifo_page_replacement(pages, 4)} 次缺页") # 10 → Belady 异常!
6.3 LRU(最近最久未使用)¶
原理:替换最长时间没有被访问的页面。基于时间局部性——过去最久没用的,将来可能也不会用。
优点:性能接近 OPT,无 Belady 异常 缺点:实现开销较大
实现方式一:栈实现¶
每次访问一个页面,将其移到栈顶。置换时弹出栈底元素。
实现方式二:计数器实现¶
为每个页面维护最后访问时间戳,置换时选时间戳最小的。
def lru_page_replacement(pages, frame_size):
"""LRU 最近最久未使用页面置换算法 — 栈实现"""
frames = []
page_faults = 0
for page in pages:
if page not in frames:
page_faults += 1
if len(frames) >= frame_size:
frames.pop(0) # 移除最久未使用的(栈底)
frames.append(page)
else:
frames.remove(page) # 从原位置移除
frames.append(page) # 放到栈顶(最近使用)
return page_faults
# 测试
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LRU 缺页次数: {lru_page_replacement(pages, 3)}") # 输出: 12
def lru_counter_implementation(pages, frame_size):
"""LRU 计数器实现"""
frames = {} # {page: last_access_time}
page_faults = 0
for time, page in enumerate(pages):
if page not in frames:
page_faults += 1
if len(frames) >= frame_size:
# 找到最久未使用的页面(last_access_time 最小)
victim = min(frames, key=frames.get)
del frames[victim]
frames[page] = time
else:
frames[page] = time # 更新访问时间
return page_faults
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LRU (计数器) 缺页次数: {lru_counter_implementation(pages, 3)}")
6.4 LFU(最不经常使用)¶
原理:替换访问频率最低的页面。频率相同时,替换最早进入的。
优点:考虑了页面的使用频率 缺点:需要维护频率计数;早期高频页面可能长期占据内存("缓存污染")
from collections import defaultdict # defaultdict带默认值的字典,避免KeyError
def lfu_page_replacement(pages, frame_size):
"""LFU 最不经常使用页面置换算法"""
frames = set()
freq = defaultdict(int) # 页面访问频率
first_access = {} # 页面首次进入时间(频率相同时 FIFO)
page_faults = 0
for time, page in enumerate(pages): # enumerate同时获取索引和值
if page in frames:
freq[page] += 1
else:
page_faults += 1
if len(frames) >= frame_size:
# 找频率最低的页面,频率相同选最早的
victim = min(frames, key=lambda p: (freq[p], first_access[p])) # lambda匿名函数:简洁的单行函数
frames.discard(victim)
del freq[victim]
del first_access[victim]
frames.add(page)
freq[page] = 1
first_access[page] = time
return page_faults
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LFU 缺页次数: {lfu_page_replacement(pages, 3)}")
6.5 Clock 算法(时钟 / 二次机会算法)¶
原理:FIFO 的改进版。维护一个循环队列和一个指针,每个页面有一个访问位(A): 1. 页面被访问时,设 A = 1 2. 需要置换时,指针沿环扫描: - 若 A = 0 → 替换该页面 - 若 A = 1 → 给"第二次机会",将 A 置 0,指针移到下一个
指针 →
┌───┐
┌───│ A=1│←── 访问过,置0,跳过
│ └───┘
│ ↓
┌───┐ ┌───┐
│A=0│ │A=0│←── A=0,替换此页!
└───┘ └───┘
↑ ↓
│ ┌───┐
└───│A=1│←── 访问过,置0,跳过
└───┘
def clock_page_replacement(pages, frame_size):
"""Clock 时钟页面置换算法"""
frames = [None] * frame_size
use_bit = [0] * frame_size # 访问位
pointer = 0 # 时钟指针
page_faults = 0
count = 0 # 当前帧中实际有页面的数量
for page in pages:
# 检查是否已在帧中
found = False
for i in range(frame_size):
if frames[i] == page:
use_bit[i] = 1 # 标记为最近使用
found = True
break
if not found:
page_faults += 1
if count < frame_size:
# 还有空闲帧
frames[count] = page
use_bit[count] = 1
count += 1
else:
# 时钟算法扫描
while use_bit[pointer] == 1:
use_bit[pointer] = 0 # 给第二次机会
pointer = (pointer + 1) % frame_size
frames[pointer] = page
use_bit[pointer] = 1
pointer = (pointer + 1) % frame_size
return page_faults
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"Clock 缺页次数: {clock_page_replacement(pages, 3)}")
6.6 改进 Clock 算法¶
在基本 Clock 算法基础上,同时考虑访问位(A)和修改位(D),优先替换"未访问且未修改"的页面(换出代价最低)。
优先级(从高到低选择替换):
| 类别 | (A, D) | 含义 | 替换优先级 |
|---|---|---|---|
| 第1类 | (0, 0) | 未访问、未修改 | 最优先替换 |
| 第2类 | (0, 1) | 未访问、已修改 | 次优先 |
| 第3类 | (1, 0) | 已访问、未修改 | 再次 |
| 第4类 | (1, 1) | 已访问、已修改 | 最后替换 |
扫描过程: 1. 第一轮:找 (0,0) 的页面,不修改任何位 2. 第二轮:找 (0,1) 的页面,同时将扫描过的 A 位清 0 3. 若仍未找到,回到第一轮重新扫描(此时已有页面的 A 被清 0)
6.7 算法对比总结¶
| 算法 | 实现复杂度 | 性能 | Belady 异常 | 实际使用 |
|---|---|---|---|---|
| OPT | — | 最优(基准) | 无 | 无法实现 |
| FIFO | ★☆☆ | 较差 | 有 | 极少单独使用 |
| LRU | ★★★ | 很好 | 无 | 思想广泛应用 |
| LFU | ★★★ | 好 | 无 | 适合热点数据 |
| Clock | ★★☆ | 接近 LRU | 无 | Linux 实际使用 |
| 改进 Clock | ★★☆ | 优于 Clock | 无 | 实际 OS 使用 |
七、内存分配策略¶
7.1 连续内存分配¶
当空闲内存以多个不连续的"空洞"形式存在时,如何为新进程分配内存?
| 策略 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 首次适配(First Fit) | 从头扫描,找到第一个足够大的空闲块 | 速度快,倾向于保留高地址大块 | 低地址产生多碎片 |
| 最佳适配(Best Fit) | 找最小的能满足需求的空闲块 | 留下尽量大的空闲块 | 产生大量极小碎片 |
| 最差适配(Worst Fit) | 找最大的空闲块分配 | 剩余空间可能仍可用 | 大作业可能无法分配 |
💡 实践中,First Fit 通常表现最好,因为它搜索简单且分布合理。
7.2 伙伴系统(Buddy System)¶
Linux 内核用于管理物理页面的分配算法。
核心思想: 1. 内存按 2 的幂 划分(1页、2页、4页、8页、…) 2. 分配时,找到最小的满足需求的 2^k 大小块 3. 若块太大,对半分裂,一半分配,一半放入空闲链表 4. 释放时,检查"伙伴"(地址相邻的同级块)是否空闲,若是则合并
初始: 1024KB 完整空闲
分配 100KB → 需要 128KB → 分裂过程:
1024 → [512 | 512]
[512 | [256 | 256]]
[512 | [256 | [128 | 128]]]
↑ 分配这个
7.3 Slab 分配器(Linux 内核)¶
伙伴系统按页分配,但内核中大量小对象(如 task_struct、inode)远小于一页。
Slab 分配器 解决这一问题: - 预先分配一组相同大小对象的缓存池(Slab) - 分配/释放对象时直接从 Slab 中取/还,极快 - 避免反复执行伙伴系统的分裂/合并操作
Slab Cache (task_struct):
┌──────┬──────┬──────┬──────┬──────┐
│ obj1 │ obj2 │ obj3 │ obj4 │ obj5 │ ← 预分配的同类型对象
│ 使用 │ 使用 │ 空闲 │ 空闲 │ 使用 │
└──────┴──────┴──────┴──────┴──────┘
查看系统 Slab 信息:
八、Linux 内存管理实战¶
8.1 /proc/meminfo 解读¶
关键字段:
| 字段 | 含义 |
|---|---|
| MemTotal | 物理内存总量 |
| MemFree | 完全空闲的内存 |
| MemAvailable | 可用内存(含可回收缓存) |
| Buffers | 块设备 I/O 缓冲区(元数据缓存) |
| Cached | 页面缓存(文件内容缓存) |
| SwapTotal | 交换空间总量 |
| SwapFree | 空闲交换空间 |
| Dirty | 等待写回磁盘的脏页 |
| AnonPages | 匿名页面(堆、栈等,无文件后端) |
8.2 free 命令详解¶
total used free shared buff/cache available
Mem: 15Gi 6.2Gi 1.8Gi 512Mi 7.3Gi 8.5Gi
Swap: 8.0Gi 0.5Gi 7.5Gi
⚠️ 关键理解:
free值很小不代表内存不够!Linux 会尽量利用空闲内存做缓存。真正可用内存看available列。
8.3 vmstat 监控¶
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 51200 18432 12800 320000 0 0 12 24 150 300 15 5 78 2 0
重点关注: - si/so:Swap In / Swap Out,若持续不为 0,说明物理内存不足 - bi/bo:Block In / Block Out,磁盘 I/O - r:等待运行的进程数 - b:不可中断睡眠的进程数
8.4 OOM Killer 机制¶
当系统内存耗尽时,Linux 的 OOM(Out Of Memory)Killer 会选择杀掉一个进程来释放内存。
为每个进程计算 oom_score(/proc/<pid>/oom_score),分数越高越可能被杀。
# 查看进程的 OOM 分数
cat /proc/$(pidof nginx)/oom_score # $()命令替换:执行命令并获取输出
# 调整 OOM 分数(-1000 到 1000),-1000 表示永不被杀
echo -1000 > /proc/$(pidof sshd)/oom_score_adj
8.5 大页内存(Huge Pages)¶
默认 4KB 页面在大内存场景(数据库等)下,页表条目过多,TLB 命中率下降。大页(2MB 或 1GB)可以减少页表项数量。
# 查看大页配置
cat /proc/meminfo | grep Huge # |管道:将前一命令的输出作为后一命令的输入
# 分配 1024 个 2MB 大页(共 2GB)
echo 1024 > /proc/sys/vm/nr_hugepages
# 挂载 hugetlbfs
mkdir -p /mnt/hugepages
mount -t hugetlbfs nodev /mnt/hugepages
💡 透明大页(THP):Linux 可自动将普通 4KB 页合并为 2MB 大页,无需应用程序修改:
8.6 mmap 内存映射¶
mmap 将文件或设备映射到进程的虚拟地址空间,实现"像访问内存一样访问文件"。
// C 语言 mmap 示例
#include <sys/mman.h> // 引入头文件
#include <fcntl.h>
#include <stdio.h>
int main() {
int fd = open("data.txt", O_RDONLY);
// 映射文件到内存
char *addr = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0); // 指针:存储变量的内存地址
printf("First byte: %c\n", addr[0]); // 像数组一样访问
munmap(addr, 4096);
close(fd);
return 0;
}
mmap 的优势: - 避免 read()/write() 的用户态/内核态数据拷贝 - 实现进程间共享内存(MAP_SHARED) - 大文件的随机访问效率高
九、面试高频题 📋¶
题目 1:解释虚拟内存的作用¶
答:虚拟内存为每个进程提供独立的虚拟地址空间。主要作用:① 进程隔离——每个进程拥有独立的地址空间,互不干扰;② 超额使用——所有进程虚拟地址空间之和可大于物理内存;③ 简化编程——程序员无需关心物理内存布局;④ 内存保护——通过页表权限位防止非法访问;⑤ 共享——多个进程可映射到同一物理页面。
题目 2:什么是 TLB?为什么需要它?¶
答:TLB(Translation Lookaside Buffer)是 MMU 中的高速缓存,缓存最近使用的"虚拟页号→物理帧号"映射。由于每次内存访问都需要查页表(存在内存中),没有 TLB 就需要两次内存访问(查页表 + 访问数据)。TLB 命中率通常 > 99%,极大减少地址翻译的开销。
题目 3:分页和分段的区别?¶
答:分页将地址空间按固定大小(如 4KB)划分,对用户透明,消除外部碎片但有内部碎片;分段按逻辑功能划分(代码段、数据段等),大小可变,用户可见,无内部碎片但有外部碎片。现代系统大多使用分页或段页结合。
题目 4:什么是 Belady 异常?哪些算法存在?¶
答:Belady 异常是指增加物理帧数后缺页次数反而增多的反直觉现象。该现象通常出现在 FIFO 等非栈算法中;LRU、OPT 等栈算法不存在此现象——栈算法的定义是:n 个帧中的页面集合是 n+1 个帧中的子集。
题目 5:比较 FIFO、LRU、Clock 算法¶
答:FIFO 最简单但性能最差且有 Belady 异常;LRU 性能好(接近 OPT)但硬件实现成本高;Clock 是 FIFO 和 LRU 的折中——用一个访问位和循环扫描近似 LRU 效果,实现开销低于 LRU,是 Linux 等实际 OS 采用的方案。
题目 6:什么是抖动(Thrashing)?如何解决?¶
答:当进程分配的物理帧远小于其工作集时,频繁缺页导致大量磁盘 I/O,CPU 利用率急剧下降,这就是抖动。解决方法:① 使用工作集模型保证每个进程获得足够帧数;② 采用 PFF(页面故障频率)策略动态调整帧分配;③ 通过中级调度挂起部分进程释放帧。
题目 7:多级页表为什么能节省空间?¶
答:单级页表必须为整个虚拟地址空间创建所有页表项(32 位系统 → 4MB 页表)。多级页表将页表分层,只有实际使用的地址区间才创建下级页表。大部分进程只使用少量地址空间,未使用区域的页表不需要分配内存,大幅减少页表占用。
题目 8:Linux 的 free 命令中 buff/cache 是什么?内存不足如何判断?¶
答:Buffers 是块设备 I/O 缓冲区,缓存磁盘元数据;Cache 是页面缓存,缓存文件内容。Linux 尽量利用空闲内存做缓存以加速 I/O,这些缓存在必要时可回收。判断内存是否不足应看 available 列,而非 free 列。若 available 持续较低且 vmstat 中 si/so > 0,说明物理内存确实不足。
题目 9:mmap 和 read/write 的区别?¶
答:read/write 需要在用户空间和内核空间之间复制数据(2 次拷贝);mmap 将文件直接映射到进程虚拟地址空间,通过缺页中断按需加载,零拷贝。mmap 更适合大文件的随机访问和进程间共享内存场景。但对小文件顺序访问,read 有时更快(mmap 有映射/解映射开销)。
题目 10:伙伴系统和 Slab 分配器分别解决什么问题?¶
答:伙伴系统解决物理页面分配问题,按 2 的幂次方分配连续物理页,通过分裂/合并减少外部碎片。Slab 分配器建立在伙伴系统之上,解决内核小对象分配问题——预分配同类型对象池,避免反复进行页面级分配/释放;对象释放后不归还给伙伴系统而是缓存在 Slab 中以供快速重用。
十、练习题 ✏️¶
练习 1:页面置换计算¶
给定页面引用串 1, 2, 3, 4, 2, 1, 5, 6, 2, 1, 2, 3, 7, 6, 3, 2, 1, 2, 3, 6,物理帧数为 4。
分别计算 FIFO、LRU 的缺页次数,并比较结果。
练习 2:地址翻译¶
系统采用二级页表,虚拟地址 32 位,页面大小 4KB。 1. 一级页表和二级页表各有多少项? 2. 若页表项大小为 4 字节,一个二级页表占多少空间? 3. 若一个进程只使用了 12MB 的虚拟地址空间(从地址 0 开始),实际使用了多少内存存储页表?
练习 3:缺页性能计算¶
假设内存访问时间为 100ns,缺页处理时间为 10ms。若期望有效访问时间不超过 200ns,缺页率 p 最大可以是多少?
练习 4:实战操作¶
在 Linux 系统中: 1. 使用 free -h 查看当前内存使用情况 2. 使用 vmstat 1 10 监控内存变化 3. 找到系统中 OOM score 最高的前 5 个进程 4. 查看系统是否启用了透明大页
练习 5:编程实现¶
用 Python 实现改进 Clock 算法(同时考虑访问位 A 和修改位 D)。要求: - 输入:页面引用串、帧数、每次访问是否为写操作 - 输出:缺页次数及每次置换的过程
📚 延伸阅读¶
- 《深入理解计算机系统》(CSAPP)第 9 章 —— 虚拟内存
- 《操作系统概念》(恐龙书)第 9-10 章 —— 内存管理
- 《深入 Linux 内核架构》第 3 章 —— 内存管理
- Linux 内核文档 - Memory Management
- TLB 与多级页表详解 - LWN.net