一句话总结
虚拟内存让每个进程都以为自己独占整个地址空间(64 位下理论 128TB),实际物理内存只有几个 GB。做法是把地址空间切成固定大小的页(4KB),通过页表把虚拟页映射到物理页框;访问的页不在内存就触发缺页中断,由内核从磁盘换入。内存不够时按页面置换算法(LRU、CLOCK)淘汰旧页,淘汰太多导致抖动,最终可能触发 OOM。
初级理解
没有虚拟内存会怎样
# 直接使用物理地址的问题:
# 1. 进程能访问任意内存,一个野指针就能让整台机器崩溃
# 2. 两个程序的地址空间互相重叠,无法同时运行
# 3. 程序大小受物理内存限制,16GB 内存跑不了 32GB 的程序
# 虚拟内存解决:
# 1. 隔离:每个进程独立地址空间,互相看不见
# 2. 共享:父子进程按需共享页(fork 的 COW 就靠这个)
# 3. 扩容:不常用的页放磁盘,程序可以比内存大
分页:把地址空间切成页
# 虚拟地址(32 位)拆成两段:
# | 虚拟页号 VPN(20 位) | 页内偏移 offset(12 位) |
# 4KB 一页 → 2^20 = 100 万个虚拟页
# 地址转换过程:
# 1. CPU 拿到虚拟地址,拆出 VPN 和 offset
# 2. 查页表:VPN → 物理页框号 PFN(命中 TLB 时更快)
# 3. 拼出物理地址:PFN + offset
# 4. 页表项标记"不在内存"→ 触发缺页中断,内核调页
页表存在哪
# 每个进程一套页表,存放在内核空间(物理内存)
# 32 位单级页表要 4MB/进程,100 个进程就 400MB,不可接受
# → 多级页表:一级页表 1024 项,只有用到的二级页表才分配
# 64 位用四级页表(PGD/PUD/PMD/PTE),按需分配,开销可控
中级深入
TLB:地址转换的缓存
# 每次访存都查页表 = 每访问一次数据多一次内存访问,性能腰斩
# TLB(Translation Lookaside Buffer)缓存最近用过的 VPN→PFN
# 命中率通常 99%+,命中时零额外访存
# 上下文切换要刷新 TLB(地址空间变了)
# 优化:PCID/ASID 给 TLB 条目打标签,切换后不必全刷
# 大页(HugePage 2MB/1GB):一个 TLB 条目覆盖更多内存,减少 TLB 压力
缺页中断的处理流程
# 1. CPU 访问虚拟地址,MMU 查页表发现有效位为 0
# 2. 触发缺页中断(Page Fault),陷入内核
# 3. 内核判断:是合法的缺页(该页在交换区/文件中)还是非法访问?
# - 合法:找到或分配一个空闲物理页框,从磁盘/文件读入,更新页表
# - 非法:SIGSEGV,段错误(Java 里就是 OOM 的前兆)
# 4. 没有空闲页框 → 按置换算法选一页淘汰:
# - 干净的页直接丢弃,脏的页先写回磁盘(swap)
# 5. 恢复中断现场,重新执行那条指令
页面置换算法
| 算法 | 思路 | 优缺点 |
|---|---|---|
| FIFO | 淘汰最早进入的页 | 简单,可能淘汰常用页,有 Belady 异常 |
| LRU | 淘汰最久未使用的页 | 效果好,需硬件支持或近似实现 |
| CLOCK | 环形链表 + 引用位,近似 LRU | Linux 实际采用的改进版 |
| LFU | 淘汰使用次数最少的页 | 适应频率,但对历史流量敏感 |
LRU 理论上最优但精确实现代价高,操作系统普遍用 CLOCK 及其变种(如 Linux 的活跃/非活跃链表)做近似。
高级进阶
段式与段页式
# 段式:按逻辑单元(代码段、数据段、栈段)划分,大小不等
# 优点:符合程序结构、便于共享与保护;缺点:易产生外部碎片
# 页式:固定大小,无外部碎片;缺点:页与逻辑结构无关
# 段页式:先分段再分页,兼得两者好处,x86 就是段页式(64 位后段基本退化)
写时复制(COW)
# fork 时父子进程共享所有页,标记为只读
# 任一方写入 → 触发缺页中断 → 内核复制这一页给写入方
# 好处:fork 后立刻 exec 的场景几乎零复制成本
# Linux 的 fork 快、创建线程快,都受益于 COW
抖动与 OOM
# 抖动(thrashing):分配的页框太少,刚换入的页马上又被换出
# CPU 大量时间花在缺页中断上,系统吞吐暴跌
# 解法:降低多道程序度、用工作集模型分配页框、换更大的内存
# OOM:连置换都救不回来时内核的最后一招
# Linux OOM Killer 按 oom_score 挑一个进程杀掉(分高的是吃内存多的)
# 排查:dmesg 看 "Out of memory: Killed process"
实战场景
# 场景1:Linux 看内存与缺页
free -h # 看总量/可用/buffer/cache
cat /proc/meminfo # 详细:AnonPages、SwapCached、PageTables
vmstat 1 # si/so 列:每秒换入/换出的页(长期非 0 就是在抖)
ps -o min_flt,maj_flt,cmd -p PID # 次缺页(内存内解决)/主缺页(要走磁盘)
# 场景2:Java 内存问题定位
# Xmx 设得太小 + 堆内对象过多 → java.lang.OutOfMemoryError
# 排查:jstat -gcutil 看各区占用 → jmap -dump 拿堆 → MAT 分析
# 经验:留足容器内存(Xmx 别超过容器 limit 的 70%,给堆外和内核留空间)
# 场景3:该加内存还是该优化
# vmstat 的 si/so 持续很高 + CPU 等 IO → 内存不足,加内存或降并发
# si/so 接近 0 但应用慢 → 不是内存问题,查 CPU/锁/慢 SQL
# 大页适用场景:数据库(Oracle/Redis)减少 TLB miss,需预留内存
面试模拟
面试官:为什么需要虚拟内存?
你:三个原因:隔离(每个进程独立地址空间,互相不会破坏)、共享(按需共享页面,fork 的写时复制靠它)、扩容(不常用的页放磁盘,程序可以超过物理内存)。代价是每次访存要做地址转换,所以用 TLB 缓存。
面试官:缺页中断发生了什么事?
你:CPU 访问的虚拟页不在物理内存,MMU 触发缺页中断陷入内核。内核先判断是否合法:合法就从交换区或文件把这个页读进来、更新页表,没有空闲页框就先按置换算法淘汰一页;非法访问就直接段错误。处理完恢复现场重新执行指令。
面试官:LRU 为什么难以精确实现?
你:精确 LRU 要记录每个页最后一次访问的时间并在淘汰时排序,每次访存都更新开销太大,硬件不支持。所以工程上用近似:CLOCK 给每页一个引用位,扫描时优先淘汰引用位为 0 的页并顺手清零,一轮扫完就近似了 LRU,Linux 用的是更细的活跃/非活跃链表。