33 - 虚拟存储与页面置换

虚拟存储是现代操作系统的标志性技术——它让每个进程独占 4GB(或更大)的地址空间,超出的部分可以透明地交换到磁盘。当物理内存吃紧时,页面置换算法决定哪些页该被踢出、哪些页该留在内存中。本章深入 Linux 的虚拟内存实现,将理论具象化。


33.1 虚拟内存概念

什么是虚拟内存

虚拟内存(Virtual Memory)将进程的逻辑地址空间与物理内存解耦。进程看到的是连续的大地址空间,实际的物理内存分配可以是不连续的、按需的、甚至部分在磁盘上。

graph TD
 subgraph "进程 A 的虚拟地址空间"
 A0["页 0"]
 A1["页 1"]
 A2["页 2 (未映射)"]
 A3["页 3"]
 end
 
 subgraph "进程 B 的虚拟地址空间"
 B0["页 0"]
 B1["页 1 (未映射)"]
 B2["页 2"]
 end
 
 subgraph "物理内存"
 P0["物理帧 0: 进程B页0"]
 P1["物理帧 1: 进程A页1"]
 P2["物理帧 2: 进程A页3"]
 P3["物理帧 3: 进程B页2"]
 end
 
 subgraph "磁盘 (交换空间)"
 S0["交换区: 进程A页0"]
 S1["交换区: (空)"]
 end
 
 A1 --> P1
 A3 --> P2
 A0 -.->|"page out"| S0
 B0 --> P0
 B2 --> P3

需求分页(Demand Paging)

进程启动时,只有必要的页面被加载到内存,其余页面在第一次访问时才被加载:

# 观察进程的实际内存占用(虚拟 vs 物理)
cat /proc/$$/status | grep -E "VmSize|VmRSS|VmData"
# VmSize: 8120 kB ← 虚拟内存总大小(所有映射,不管是否在内存中)
# VmRSS: 3652 kB ← 常驻内存(实际占用的物理页面)
# VmData: 1432 kB ← 数据段虚拟大小
 
# VmRSS << VmSize → 这就是需求分页的效果
# 很多虚拟页面从未被访问,因此无需分配物理帧

33.2 缺页中断(Page Fault)

缺页的处理流程

sequenceDiagram
 participant CPU
 participant MMU
 participant K as Linux 内核
 participant DISK as 磁盘

 CPU->>MMU: 虚拟地址 VA
 MMU->>MMU: 查 TLB → 未命中
 MMU->>K: 走页表
 K->>K: PTE.Present = 0 → 缺页!
 K->>K: 判断缺页类型
 
 alt 首次访问(匿名页或文件映射)
 K->>K: do_anonymous_page()<br/>分配物理帧,建立映射
 else 页面在交换区
 K->>DISK: swap_readpage()<br/>从交换区读回
 else 页面在文件缓存
 K->>DISK: 从文件系统读回<br/>(页缓存命中则跳过)
 else 非法访问(权限错误/段错误)
 K->>CPU: SIGSEGV (段错误)
 end
 
 K->>K: 更新页表 PTE
 K->>MMU: 失效 TLB 条目
 K-->>CPU: 返回,重试指令

次缺页 vs 主缺页

类型含义需要 I/O?示例
次缺页 (Minor Fault)页面已在内存(在页缓存或其他进程中),只需建立页表映射COW 页面首次写入、共享库已映射
主缺页 (Major Fault)页面不在内存,必须从磁盘读取首次加载程序代码、从 swap 读回
非法缺页 (Invalid Fault)访问不可能合法的地址进程被杀死空指针解引用、写只读页面
# 查看进程的缺页统计
cat /proc/$$/stat | awk '{print "minflt:",$10,"majflt:",$12}'
# minflt: 次缺页次数(不需要磁盘 I/O)
# majflt: 主缺页次数(需要磁盘 I/O)
 
# 全局缺页统计
cat /proc/vmstat | grep -E "pgfault|pgmajfault|pswpin|pswpout"
# pgfault: 所有缺页次数(大部分是次缺页)
# pgmajfault: 主缺页次数
# pswpin: 从 swap 换入的页数
# pswpout: 换出到 swap 的页数
 
# 实时监控缺页
sar -B 1 5 2>/dev/null
# pgpgin/s: 每秒从磁盘读入的页数
# pgpgout/s: 每秒写出到磁盘的页数
# fault/s: 每秒缺页次数
# majflt/s: 每秒主缺页次数

33.3 页面置换算法

当物理内存不足,需要选择哪一页换出时,页面置换算法登场。

OPT(最优置换,理论基准)

OPT: 选择未来最长时间不再使用的页面置换
问题:无法预知未来 → 仅用作理论性能上界
用途:作为衡量其他算法的参照标准

FIFO 与 Belady 异常

FIFO: 置换驻留时间最长的页面(最简单的队列)

Belady 异常:增加物理帧数,缺页次数反而增加!
示例:
 页面引用串: 1 2 3 4 1 2 5 1 2 3 4 5
 3 个帧: 9 次缺页
 4 个帧: 10 次缺页 ← 更多帧却更差!
# 用 Python 验证 Belady 异常
def fifo_page_faults(pages, num_frames):
 frames = []
 faults = 0
 for page in pages:
 if page not in frames:
 faults += 1
 if len(frames) < num_frames:
 frames.append(page)
 else:
 frames.pop(0) # FIFO
 frames.append(page)
 return faults
 
ref_string = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
print(f"3 frames: {fifo_page_faults(ref_string, 3)} faults")
print(f"4 frames: {fifo_page_faults(ref_string, 4)} faults")
# 3 frames: 9 faults
# 4 frames: 10 faults ← Belady 异常!

LRU(最近最少使用)

LRU: 置换最久没有被使用的页面
 利用"局部性原理":最近用过的页面更可能再次被使用

实现方案:
 1. 计数器方案:每次访问记录时间戳,置换时找最小
 → 需要硬件支持(每条内存访问指令更新计数器)
 2. 栈方案:维护页号栈,被访问的页移到栈顶
 → O(1) 更新但需要维护链表

纯 LRU 需要硬件支持,普通系统很少直接实现

CLOCK(二次机会)算法 ★

CLOCK 是 LRU 的近似,Linux 实际使用其增强版本:

// CLOCK 算法伪代码
// 使用页表条目的 Accessed (A) 位
 
struct page {
 int referenced; // 访问位(由硬件设置,OS 可清零)
};
 
// 循环指针(时钟指针)
int clock_hand = 0;
 
page_t *select_victim() {
 while (1) {
 if (pages[clock_hand].referenced == 0) {
 // 找到牺牲页
 return &pages[clock_hand];
 } else {
 // 给第二次机会,清零访问位
 pages[clock_hand].referenced = 0;
 }
 clock_hand = (clock_hand + 1) % num_pages;
 }
}

增强 CLOCK(Linux 使用的方案)

增强 CLOCK 同时考虑访问位(A)修改位(D)

A (Accessed)D (Dirty)类别优先置换顺序
00既没访问也没修改1st(最佳牺牲品)
01没访问但修改过(需写回)2nd
10访问过但干净3rd
11访问过也修改过4th(最不应置换)
# Linux 当前使用的页面回收算法
cat /sys/kernel/mm/lru_gen/enabled 2>/dev/null && echo "LRU Gen" || \
echo "使用传统 LRU 链表"
 
# 查看内核页面回收相关的配置
grep -r "LRU" /boot/config-$(uname -r) 2>/dev/null | head -10

33.4 页面分配策略

固定分配 vs 可变分配

策略描述优点缺点
固定分配每个进程分配固定数量的帧简单可预测不适应动态需求
可变分配帧数根据缺页频率动态调整自适应,效率高实现复杂

全局置换 vs 局部置换

// 全局置换:可以选择任何进程的页面置换
// 优点:灵活,全局最优
// 缺点:一个进程缺页过多会影响其他进程
 
// 局部置换:只能从本进程的页面中选择置换
// 优点:隔离性好,不影响其他进程
// 缺点:可能本进程的页面都不适合置换
 
// Linux 默认使用全局置换,但通过 cgroup 可以限制
# 用 cgroup 限制进程的内存(局部置换的现代等价物)
# 创建带内存限制的 cgroup
sudo mkdir /sys/fs/cgroup/memory_limit
echo "512M" | sudo tee /sys/fs/cgroup/memory_limit/memory.max
echo "512M" | sudo tee /sys/fs/cgroup/memory_limit/memory.high
 
# 在该 cgroup 中运行进程
sudo sh -c 'echo $$ > /sys/fs/cgroup/memory_limit/cgroup.procs'
# 进程内存超过 512M 时会触发回收,仅影响本 cgroup 内的进程

33.5 系统颠簸(Thrashing)

颠簸的定义与检测

graph TD
 A["多道程序度过高<br/>每个进程帧数不足"] --> B["频繁缺页<br/>大量主缺页"]
 B --> C["CPU 大量时间用于<br/>页面换入换出"]
 C --> D["CPU 利用率骤降"]
 D --> E["调度器认为<br/>并发度不够"]
 E --> F["引入更多进程"]
 F --> A
 
 style A fill:#ffcdd2,stroke:#333
 style C fill:#ffcdd2,stroke:#333
 style E fill:#ffcdd2,stroke:#333
# 检测颠簸的几种方法
 
# 1. 查看 交换 I/O 速率
vmstat 1 5
# si (swap in) 和 so (swap out) 持续很高 → 可能在颠簸
 
# 2. 查看缺页频率
sar -B 1 5
# majflt/s 持续很高
 
# 3. 查看 I/O 等待
iostat -x 1 5
# %iowait 很高,但系统没有磁盘瓶颈 → 可能在颠簸
 
# 4. 查看负载平均值
uptime
# load average 远大于 CPU 核数 → 很多进程在等待(可能是 I/O 等待)

工作集模型

工作集(Working Set, WS)是进程在 Δ 时间窗口内访问的页面集合:

WS(t, Δ) = { 进程在 [t-Δ, t] 时间内访问过的页面集合 }

如果分配给进程的帧数 < |WS| → 频繁缺页 → 颠簸
如果分配给进程的帧数 ≥ |WS| → 缺页频率可接受
# 估算进程的工作集(需要采样)
# 通过 /proc/PID/clear_refs 和 /proc/PID/smaps 结合
# 1. 重置访问位
echo 1 | sudo tee /proc/$$/clear_refs
# 2. 等待一段时间(Δ)
sleep 10
# 3. 查看哪些页面被访问了
cat /proc/$$/smaps | grep -E "^[0-9a-f]|Referenced:" | grep -B1 "Referenced:.*[1-9]"
# 被访问过的页面(PSS 或 Referenced > 0 表明被访问)
 
# 这是定性分析,操作系统内核会持续追踪但不会暴露原始工作集数据

33.6 Linux OOM Killer

何时触发 OOM

当系统内存严重不足,内核无法通过页面回收和 swap 满足内存请求时,OOM Killer(Out-Of-Memory Killer)会被激活:

# 查看 OOM Killer 是否最近触发过
dmesg | grep -i "out of memory\|oom-killer\|killed process"
sudo journalctl -k | grep -i oom
 
# 查看当前的 OOM 状态
cat /proc/sys/vm/panic_on_oom # 0=救急, 1=内核panic
cat /proc/sys/vm/oom_kill_allocating_task # 0=选最耗内存的, 1=杀触发OOM的
 
# OOM Killer 的选择算法(badness score)
# 每个进程有一个 oom_score,值越高越可能被杀
cat /proc/$$/oom_score
# 分数范围:(0-1000),由内核根据内存占用、CPU 时间等计算
 
# oom_score_adj: 手动调整 OOM 评分
# 范围:-1000 到 +1000
# -1000: 完全豁免(永远不被杀,如 sshd)
# +1000: 优先被杀
cat /proc/$$/oom_score_adj
# 保护关键进程不被 OOM Killer 杀死
# 例如保护 sshd
echo -1000 | sudo tee /proc/$(pgrep sshd)/oom_score_adj
 
# 查看哪些进程被保护
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
 adj=$(cat /proc/$pid/oom_score_adj 2>/dev/null)
 if [ "$adj" = "-1000" ]; then
 echo "PID $pid ($(cat /proc/$pid/comm 2>/dev/null)): OOM protected"
 fi
done | head -20

33.7 Swap 空间

Swap 的原理与作用

Swap 是磁盘上的一块区域,用于存放被换出的匿名页(堆、栈等内存)。文件映射的页面则直接写回原文件。

# 查看 swap 使用情况
swapon --show
# NAME TYPE SIZE USED PRIO
# /dev/sda3 partition 8G 2.3G -2
# /swapfile file 2G 0B -3
 
free -h | grep Swap
 
# 查看每个进程的 swap 使用量
for pid in $(ls /proc | grep -E '^[0-9]+$' | head -20); do
 swap=$(awk '/VmSwap/{print $2}' /proc/$pid/status 2>/dev/null)
 [ -n "$swap" ] && echo "PID $pid: ${swap} kB"
done
 
# 查看全局 swap 统计
cat /proc/vmstat | grep -E "pswpin|pswpout"

创建和管理 Swap

# 方法 1: 创建 swap 文件
sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 # 4GB
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
 
# 永久生效
echo '/swapfile none swap defaults 0 0' | sudo tee -a /etc/fstab
 
# 方法 2: 使用 swap 分区
# 在分区工具中创建类型为 82 (Linux swap) 的分区
sudo mkswap /dev/sdXY
sudo swapon /dev/sdXY
 
# 移除 swap
sudo swapoff /swapfile
sudo rm /swapfile

Swappiness:控制换出激进程度

# swappiness 范围 0-100(默认 60)
# 0: 尽量不 swap(仅当内存耗尽时)
# 100: 积极 swap(尽早换出匿名页)
 
cat /proc/sys/vm/swappiness # 默认值
 
# 对于桌面系统,降低 swappiness 可以让 UI 更流畅
echo 10 | sudo tee /proc/sys/vm/swappiness
 
# 对于数据库服务器,通常也设为较低值
echo 1 | sudo tee /proc/sys/vm/swappiness
 
# 持久化
echo "vm.swappiness = 10" | sudo tee /etc/sysctl.d/99-swap.conf
sudo sysctl --system

33.8 大页(Huge Pages)

为什么需要大页

TLB(Translation Lookaside Buffer)的条目数有限(典型值数百条)。使用更大的页面可以减少 TLB 条目的消耗:

页面大小覆盖 1GB 需 TLB 条目TLB 覆盖上限 (256 条目)
4KB262,144 条1 MB
2MB512 条512 MB
1GB1 条256 GB
# 查看大页配置
grep Huge /proc/meminfo
# HugePages_Total: 128 # 预留的大页总数
# HugePages_Free: 128 # 空闲大页数
# HugePages_Rsvd: 0 # 已预留但未映射
# HugePages_Surp: 0 # 超过预留的大页
# Hugepagesize: 2048 kB # 大页大小 (2MB)
 
# 查看透明大页状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never
# always: 始终使用 THP
# madvise: 仅当程序显式通过 madvise() 请求时使用
# never: 禁用 THP
 
# 查看 THP 使用统计
cat /sys/kernel/mm/transparent_hugepage/khugepaged/pages_collapsed
grep thp /proc/vmstat
# thp_fault_alloc: THP 缺页分配次数
# thp_collapse_alloc: khugepaged 合并分配次数
# thp_split: THP 被拆分为 4KB 页的次数

预留大页

# 创建 128 个 2MB 大页(= 256MB 预留)
echo 128 | sudo tee /proc/sys/vm/nr_hugepages
 
# 持久保留(通过 sysctl)
echo "vm.nr_hugepages = 128" | sudo tee /etc/sysctl.d/99-hugepages.conf
 
# 挂载 hugetlbfs
sudo mkdir -p /mnt/huge
sudo mount -t hugetlbfs none /mnt/huge
# 或者添加到 /etc/fstab
echo "none /mnt/huge hugetlbfs defaults 0 0" | sudo tee -a /etc/fstab
 
# 查看内存中 1GB 大页的情况(x86-64 支持 1GB 页)
cat /proc/cpuinfo | grep pdpe1gb
# 如果有 pdpe1gb 标志 → CPU 支持 1GB 大页

33.9 页面回收与缓存

Linux 的页面类型

# 查看各类页面的统计
cat /proc/meminfo | grep -E "^AnonPages|^Mapped|^Cached|^Buffers|^Dirty|^Writeback|^Shmem"
# AnonPages: 匿名页(堆、栈、malloc)
# Mapped: 文件映射页(被 mmap 的文件)
# Cached: 页面缓存(文件数据在内存中的副本)
# Buffers: 块设备缓冲区(文件系统元数据)
# Dirty: 已修改但尚未写回磁盘的页
# Writeback: 正在写回磁盘的页
# Shmem: tmpfs 用的共享内存
 
# 匿名页:回收时写入 swap
# 文件映射页:回收时写回原文件(如果脏)
# 缓冲/缓存页:回收时写回磁盘(如果脏)或直接丢弃

页面回收的优先级

graph TD
 A["内存压力"] --> B["回收优先级<br/>从低到高"]
 B --> C1["1. 文件页缓存<br/>(Clean Cache)<br/>直接丢弃"]
 B --> C2["2. 干净匿名页<br/>(Clean Anon)<br/>不需要写 swap"]
 B --> C3["3. 脏文件映射页<br/>(Dirty Mapped)<br/>写回文件后丢弃"]
 B --> C4["4. 脏匿名页<br/>(Dirty Anon)<br/>写入 swap"]
 
 style C1 fill:#c8e6c9,stroke:#333
 style C4 fill:#ffcdd2,stroke:#333
# 查看内核页面回收的活动
cat /proc/vmstat | grep -E "pgscan|pgsteal|pgrotated"
# pgscan_kswapd: kswapd 扫描的页数
# pgscan_direct: 直接回收扫描的页数(更紧急)
# pgsteal_kswapd: kswapd 成功回收的页数
# pgsteal_direct: 直接回收成功的页数
 
# 如果 pgscan_direct 显著 > 0 → 内存压力很大
# kswapd(后台回收)来不及处理,必须同步等待回收

33.10 虚拟内存观测命令汇总

# 全局一览
cat /proc/meminfo # 内存使用全景
cat /proc/vmstat # 虚拟内存统计(大量计数器)
vmstat 1 5 # 实时内存/CPU/交换统计
sar -r 1 5 2>/dev/null # 内存使用历史
sar -B 1 5 2>/dev/null # 页面 I/O 历史
sar -S 1 5 2>/dev/null # 交换空间使用
 
# 进程级别
cat /proc/PID/status # 进程内存概况
cat /proc/PID/smaps # 详细内存映射
cat /proc/PID/smaps_rollup # smaps 汇总
pmap -x PID # 进程内存映射(人类可读)
 
# 页面与交换
cat /proc/buddyinfo # Buddy 分配器状态
cat /proc/slabinfo # Slab 分配器详情
swapon --show # Swap 设备/文件
 
# OOM 相关
cat /proc/PID/oom_score # OOM 杀手评分
cat /proc/PID/oom_score_adj # OOM 调整值
dmesg | grep -i oom # OOM 历史事件
 
# 大页
grep Huge /proc/meminfo # 大页统计
cat /sys/kernel/mm/transparent_hugepage/enabled # THP 状态
场景诊断命令关注指标
内存泄漏watch -n1 'cat /proc/PID/status | grep VmRSS'VmRSS 持续增长
频繁 swapvmstat 1 | awk '{print $7, $8}'si/so 持续非零
颠簸sar -B 1majflt/s 很高
OOM 风险dmesg | tail找到 oom-killer 记录
内存碎片化cat /proc/buddyinfo高阶 order 的空闲块很少

相关链接