---
title: 虚拟内存详解：地址转换、TLB、缺页中断与页面置换
description: 虚拟内存高频面试题总结，从进程隔离讲起，讲清虚拟地址到物理地址的分段、分页、多级页表、TLB、缺页中断、页面置换算法、Belady 异常，以及 mmap、COW、JVM 大页等工程场景。
category: 计算机基础
tag:
  - 操作系统
  - 内存管理
head:
  - - meta
    - name: keywords
      content: 虚拟内存,虚拟地址,物理地址,MMU,页表,多级页表,TLB,缺页中断,页面置换算法,CLOCK算法,Belady异常,mmap,COW,操作系统面试题
---

打开任务管理器时，你可能会看到一个挺反直觉的现象：每个进程都像拿着一大片“自己的内存”，有些进程里的地址看起来还差不多，但它们并不会互相影响。浏览器、IDE、数据库同时跑，大家都以为自己有一块连续、干净、独占的空间。

这不是因为程序之间互相信任，而是操作系统在中间加了一层翻译。

程序看到的是虚拟地址，真正落到内存条上的位置，由内核和硬件一起决定。虚拟内存要讲的，也就是这层翻译怎么做、进程为什么能隔离、内存不够时又怎么把一部分数据先挪到磁盘上。

## 没有虚拟内存会怎样？

先看一个反例。

很多人大学里玩过单片机。单片机上没有复杂的操作系统，CPU 直接操作物理地址。这个环境里如果想同时跑两个程序，麻烦马上就来了：第一个程序往地址 2000 写了个值，第二个程序也刚好把数据放在 2000，那一写就把对方的数据覆盖了，两个程序一起出问题。

原因也不绕：两个程序都在直接引用同一套物理地址，谁也躲不开谁。

操作系统的做法是加一层隔离：给每个进程发一套独立的“虚拟地址”。进程只跟自己的虚拟地址打交道，这个地址最后落到哪块物理内存上，进程不用知道，操作系统统一安排。

于是就有了两个概念：

- 程序里用的地址，叫 **虚拟地址（Virtual Address）**。
- 真正存在内存条上的地址，叫 **物理地址（Physical Address）**。

进程访问虚拟地址时，CPU 里的内存管理单元（MMU）会根据映射关系，把它翻译成物理地址，再去访问内存。不同进程写的虚拟地址哪怕数值一样，映射到的物理地址也可以完全不同，自然就不会打架。

![虚拟地址到物理地址的映射过程：不同进程的相同虚拟地址通过 MMU 和页表映射到不同物理页](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-virtual-physical-mapping.png)

我们可以把虚拟内存的好处归成三条，后面整篇文章其实都在围绕它们展开：

- **进程隔离**：每个进程一套页表，互相看不到对方的物理内存，A 进程没法靠一个地址直接摸到 B 进程的数据。
- **突破物理内存大小限制**：程序运行有局部性，暂时用不到的页可以先放到磁盘上，需要时再换回来，所以进程“感觉到”的内存可以比物理内存大。
- **统一且连续的地址空间**：进程看到的是一整片连续的虚拟地址，物理上却可以东一块西一块。拼起来这件事，交给映射表。

接下来最重要的问题只有一个：**虚拟地址到物理地址，到底怎么映射？**

主要有两种办法：**分段和分页** 。

## 分段是怎么映射的？

分段（Segmentation）出现得比较早，思路也很符合程序员直觉。一个程序本来就由代码、数据、栈、堆这些部分组成，它们的访问权限和生命周期都不一样，那就按逻辑切成几个段。

分段下的虚拟地址由两部分组成：**段选择子和段内偏移量**。

段选择子放在段寄存器里，里面最关键的是段号，用来当段表的索引。段表里记录这个段的基地址、段界限（段有多长）和特权级。

翻译过程也不复杂：拿段号去段表里查到段基地址，再检查段内偏移量有没有超过段界限。合法的话，基地址加偏移量就是物理地址。比如要访问段 3、偏移 500 的地址，段 3 的基地址是 7000，那物理地址就是 7000 + 500 = 7500。

![分段地址转换示意图：虚拟地址由段号和段内偏移组成，通过段表查到基地址后计算物理地址](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-segmentation.png)

分段解决了“程序不用关心物理地址”的问题，但它也留下两个坑。

**第一个是外部内存碎片**。每个段的长度不固定，段与段之间很容易剩下一些零碎空隙。举个例子，物理内存里依次放了四段：A 占 256 MB、B 占 128 MB、C 占 256 MB、D 占 128 MB。现在释放掉 B 和 D，空闲总量有 256 MB，但它被 C 隔成了两块 128 MB。此时想再放一个连续 200 MB 的段，就放不下了。总量够，连续空间不够。

**第二个是整理碎片代价高**。想把这些零散空闲空间拼成一整块，就得做内存紧凑：把还在用的段挪位置，重新排成连续空间。如果搬移过程还伴随把段换出到磁盘 Swap、再换回来，就会多出一大块磁盘 I/O。不管怎么做，变长段这种大粒度搬移都很重，碰上大段，系统很容易卡住。

段的问题就卡在这里：粒度大、长度不固定，碎片和整理成本都不好控制。

## 分页又是怎么映射的？

分页（Paging）换了个办法：不按代码、数据、栈这种逻辑去切，而是把虚拟地址空间和物理地址空间都切成固定大小的小块，每一块叫一页（Page）。Linux 下一页默认是 4 KB。

虚拟地址到物理地址靠页表（Page Table）映射。页表在内存里，MMU 负责查表翻译。地址转换通常分三步：

- 把虚拟地址拆成页号和页内偏移；
- 用页号去页表里查出对应的物理页号；
- 物理页号拼上页内偏移，得到最终物理地址。

![分页地址转换示意图：虚拟地址拆成页号和页内偏移，页表把虚拟页映射到物理页帧](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-paging.png)

分页怎么解决分段的毛病？

页大小固定，物理页也按固定粒度管理，不会像变长段那样在段与段之间留下奇怪的小空隙，所以基本**消除了外部碎片**。代价是页内可能浪费：一个程序哪怕只用了几个字节，也得占满一整页，这部分叫**内部碎片**。不过这个浪费通常可控，比外部碎片好处理。

管理粒度也变小了。物理内存不够时，操作系统可以挑一些“最近没怎么用”的页换出到磁盘（Swap Out），需要时再换入（Swap In）。调入调出的单位，从一整个变长段，缩到了固定大小的页。

别误会，分页不代表磁盘压力一定小。真遇到大量主缺页或者抖动，频繁的页级 I/O 一样能把系统拖垮。

分页还有一个很实用的点：程序不需要一次性全部装进内存。先把虚拟页和物理页的映射关系准备好，但不急着把页真的搬进物理内存。等程序访问到某个虚拟页，再把它加载进来。这就是按需调页（Demand Paging）的基础。

## 段页式：两者其实能合体

分段和分页不一定只能选一个，也可以合在一起用，这就是 **段页式内存管理** 。

做法是先分段再分页：先把程序切成有逻辑意义的段，再把每个段切成固定大小的页。地址也就变成三段：段号、段内页号、页内偏移。每个程序一张段表，每个段再挂一张页表，段表项里存这个段对应页表的起始地址。

缺点也很好理解：访问内存的次数变多。一次段页式地址转换要走三趟内存：

1. 第一趟查段表拿到页表起始地址；
2. 第二趟查页表拿到物理页号；
3. 第三趟才用物理页号加页内偏移访问真正的数据。

这里插一段历史，能解释为什么 Linux 看起来“既分段又分页”。Intel 从 80286 开始用段式管理，到 80386 补上了页式管理，但页式是建立在段式之上的：逻辑地址先经分段变成线性地址，也就是通常说的虚拟地址；线性地址再经分页变成物理地址。CPU 硬件就是这么设计的，Linux 只能配合。

Linux 的做法是把分段尽量架空：把所有段的基地址都设成 0，每个段都覆盖整个 4 GB（32 位下）虚拟空间。这样逻辑地址和线性地址数值相等，段的存在感就很低，只保留访问控制和内存保护的作用。所以严格说，Linux 主要靠分页管理内存。

## 单级页表为什么不够用？

分页落到真实系统里，最先碰到的不是思路问题，而是空间问题。

算一笔账。32 位环境下，虚拟地址空间是 4 GB，页大小 4 KB（2^12），那一个进程就有约 100 万（2^20）个页。每个页表项占 4 字节，整张页表就是 4 × 2^20 = 4 MB。

4 MB 看着还行，但别忘了，**每个进程都有自己的页表**。100 个进程就是 400 MB 内存用来存页表。到了 64 位环境，只会更夸张。

更别扭的是，单级页表得把整个虚拟地址空间一次性铺满。页表的工作是翻译地址，某个虚拟地址如果在页表里没有位置可查，翻译就断了。所以哪怕进程实际只用了一小片地址，那 100 万个页表项也得先建出来，绝大多数还都是空的。

这就太浪费了。

## 多级页表怎么省空间？

多级页表（Multi-Level Page Table）的招数很直接：**只给真正用到的地址建下级页表，没用到的就不建。**

还是 32 位、4 KB 页的场景。把 100 多万个页表项再分一层：一级页表（页目录）有 1024 项，每一项指向一张二级页表，每张二级页表也有 1024 项。1024 × 1024 正好覆盖那 100 多万个页表项。

你可能会马上反问：这不是多了一层吗？4 KB 的一级表，再加 4 MB 的二级表，岂不是更费？

如果真把 4 GB 虚拟地址全映射满，确实更费。但现实里，一个进程通常不会用满 4 GB。关键就在这里：一级页表必须常驻，它覆盖全部地址空间，但只占 4 KB；二级页表按需创建，某个一级表项没用到，对应的二级表就不建。

算个数。假设只有 20% 的一级表项被用到，那页表总开销就是 4 KB（一级）+ 20% × 4 MB（二级）≈ 0.804 MB。对比单级页表的 4 MB，省得很明显。这里省下来的内存，靠的是局部性原理：程序在一段时间内通常只访问地址空间里的一小块。

到了 64 位，两级就不够用了。当前 Linux 的通用页表抽象是五级，自顶向下是：

- 全局页目录 PGD（Page Global Directory）
- 第四级目录 P4D（Page 4th Directory）
- 上层页目录 PUD（Page Upper Directory）
- 中间页目录 PMD（Page Middle Directory）
- 页表项 PTE（Page Table Entry）

![多级页表示意图：PGD、PUD、PMD、PTE 分层索引，只为实际使用的地址范围创建下级页表](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-multi-level-page-table.png)

在只用四级硬件分页的 x86-64 上，P4D 这一层会被“折叠”掉，不实际参与地址转换。所以你常听到的“四级页表”，说的是这种折叠后的形态，不是 Linux 只定义了四级。

具体到 x86-64，目前主流是四级分页，用 48 位虚拟地址（寻址 256 TB）。一个 64 位虚拟地址通常这样拆：高 16 位是符号扩展位，接下来 PGD、PUD、PMD、PTE 各占 9 位（每级 512 项，2^9），最低 12 位是页内偏移（对应 4 KB 页）。每个页表项 8 字节，512 项 × 8 字节 = 4 KB，每一级页表刚好占满一个页。

需要更大地址空间时，x86-64 提供五级分页（LA57），把规范线性地址从 48 位扩到 57 位，物理地址最多 52 位。这里别把时间线记错：Linux 从 4.14（2017 年）起支持五级分页，是否启用取决于 CPU 和内核配置；Intel 这边明确支持 57 位虚拟、52 位物理地址的是第三代至强可扩展（Ice Lake 服务器平台，2021 年发布）。Linux 文档还提到，五级分页最多给用户空间 56 位虚拟地址；为了兼容那些把指针高位拿去做 tagging 的程序，内核默认不会主动在 47 位以上分配地址，除非应用显式请求。普通机器默认还是四级更常见。

## TLB（快表）解决了什么？

多级页表省了空间，但会多花时间：原来查一次表，现在 64 位下可能要查四级。一次内存访问背后，如果还藏着四五次查表访存，代价就太高了。

还是靠局部性原理救场。程序在一段时间内反复访问的页，通常就那几批。那就把最常用的页表项，缓存到比内存快得多的硬件里。这块缓存就是 TLB（Translation Lookaside Buffer），中文叫快表、转址旁路缓存，封装在 CPU 的 MMU 里。

有了 TLB，CPU 寻址时先查 TLB：

- 命中（TLB Hit），直接拿到物理页号，跳过多级页表查找。
- 未命中（TLB Miss），再去查内存里的多级页表，查到后把这一项塞进 TLB，方便下次访问。

![TLB 缓存地址转换结果的流程：CPU 先查 TLB，命中直接访问内存，未命中再查多级页表并更新 TLB](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-tlb-cache.png)

因为热点页就那么多，TLB 命中率通常不低。多级页表带来的查表成本，大多数时候都被 TLB 扛掉了。

## 缺页中断（Page Fault）是怎么走完的？

按需调页有个前提：进程访问某个虚拟页时，这个页不一定已经在物理内存里。MMU 查页表时，如果发现页表项显示该页不在内存（页表项里有个存在位 / present bit），就会触发缺页中断，把控制权交给内核的缺页处理程序。

流程可以按这几步记：

1. CPU 拿着虚拟地址查页表，发现目标页不在物理内存，触发缺页中断（一种硬件异常）。
2. 进入内核态，缺页处理程序先判断这次访问是否合法。如果访问的是非法地址，比如野指针，就报段错误（Segmentation Fault），进程通常会被杀掉。
3. 合法的话，找一个空闲物理页帧。如果没有空闲帧，就回收或置换一个“受害者”页：干净的文件页可以直接丢弃，要用时再从原文件读回；脏的文件页要先写回原文件；匿名页则在启用了 Swap 时写入交换空间。到底会不会产生磁盘写，取决于页的类型以及脏不脏。
4. 把需要的页从磁盘（Swap 区或文件）读进物理内存，更新页表项，让它指向新的物理页帧。
5. 返回用户态，重新执行刚才触发缺页的那条指令，这次就能正常访问了。

![缺页中断处理流程：MMU 发现页不在内存后进入内核，检查访问合法性、分配或置换页帧、更新页表并重试指令](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-page-fault.png)

从 Linux 的性能统计角度看，缺页主要分两类，`getrusage` 里也只有 `ru_minflt` 和 `ru_majflt`：

- **次缺页（Minor Page Fault）**：页其实已经在物理内存里了，只是当前进程的页表还没建立映射，比如多个进程共享的库；写时复制（COW）触发的页复制通常也算次缺页，因为它要新建或复制页面，但不用读盘。开销小。
- **主缺页（Major Page Fault）**：页确实不在内存，必须从磁盘（文件或 Swap）读进来，开销大。

访问非法地址，比如野指针，也会由硬件触发 page-fault 异常进内核。但内核判定非法后，一般是给进程发 `SIGSEGV`。这属于错误处理，通常不和 minor/major 并列当成第三种性能统计类别。

如果物理内存太紧，系统大部分时间都在换入换出页，CPU 没怎么干正事，全在搬数据，这种状态叫**抖动（Thrashing）**。

## 页面置换算法：换谁出去？

物理内存满了，又要装新页，就得挑一个页换出去。挑得好，后面少缺页；挑得差，刚换出去的页转头又要用，白折腾。

常见算法有这么几个。

**OPT（最优置换）**：换出“未来最长时间内不会被访问”的页。它的缺页次数理论上最少，但需要预知未来，现实里实现不了，主要用来当衡量其他算法的参照。

**FIFO（先进先出）**：维护一个队列，谁最早进来就先换谁。实现简单，但很容易误伤热点页，因为一个页待得久，不代表以后就用不到。

**LRU（最近最少使用）**：换出“最久没被访问”的页。它赌的是局部性：最近用过的页，接下来大概率还会用。LRU 效果接近 OPT，但成本高，要么给每个页维护时间戳，要么用链表在每次访问时把页移到表头。纯软件实现很难扛住高频访问。

**CLOCK（时钟 / 二次机会）**：LRU 的近似实现，用来避开 LRU 的高成本。给每个页加一个访问位（reference bit），所有页排成一个环，一根指针像时钟一样转。要换页时，指针指到谁就看它的访问位：是 1，说明最近用过，给它“第二次机会”，把访问位清 0，指针往下走；是 0，就换这一页出去。一个访问位加一圈环形扫描，就能便宜地模拟“最近有没有被用过”。

![CLOCK 页面置换算法示意图：页面按环形队列排列，指针根据访问位 R 判断给第二次机会还是淘汰页面](https://oss.javaguide.cn/github/javaguide/cs-basics/operating-system/virtual-memory-clock-algorithm.png)

**LFU（最不经常使用）**：给每个页记访问次数，换出访问次数最少的页。它看的是访问频率，不是访问时间。问题是早期被频繁访问、后来不用的页，计数很高却赖着不走，所以实际中常配合计数衰减来用。

横向对比一下：

| 算法  | 换出依据          | 实现成本          | 效果                 | 能不能落地       |
| ----- | ----------------- | ----------------- | -------------------- | ---------------- |
| OPT   | 未来最久不用      | 无法实现          | 理论最优             | 只作基准         |
| FIFO  | 进入时间最早      | 很低              | 一般，可能误伤热点页 | 能               |
| LRU   | 最久未访问        | 高（时间戳/链表） | 接近 OPT             | 纯软件较吃力     |
| CLOCK | 访问位 + 环形扫描 | 低                | 接近 LRU             | 能，主流近似方案 |
| LFU   | 访问次数最少      | 中（需计数）      | 看场景               | 能，常配衰减     |

这里别误会：上面这些是教科书算法，用来理解置换策略。真实的 Linux 内核不是在 OPT/FIFO/LRU/CLOCK 里直接挑一个，而是用活跃/非活跃双 LRU 链表、workingset、refault 检测这套近似机制；文件页和匿名页的回收策略也不一样，还会受 NUMA、cgroup、内存水位影响。所以“Linux 用的就是 CLOCK”这种说法不严谨。

## Belady 异常：加内存反而更慢？

按直觉，物理内存的页帧越多，缺页应该越少。但 FIFO 会打脸：**有时候增加页帧数，缺页次数反而变多**。这就是 Belady 异常（Belady's Anomaly），由 László Bélády 在 1960 年代发现。

用经典访问串就能复现。访问序列 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5，跑 FIFO：

- 3 个页帧时，缺页 9 次。
- 4 个页帧时，缺页 10 次。

多给了一个页帧，缺页反而多了一次。

原因在于 FIFO 不满足**栈性质（stack property）**：n 个页帧时驻留的页集合，不一定是 n+1 个页帧时驻留页集合的子集。这个包含关系一断，加页帧就可能踢错页。

满足栈性质的算法叫栈算法。它们对每个页的置换优先级和页帧数无关，所以从数学上可以避开 Belady 异常。OPT 和 LRU 都是栈算法，可以证明页帧数增加时，缺页只会减少或不变，不会反增。FIFO 不满足这个性质，所以会出问题。至于 LFU 会不会出现 Belady 异常，取决于它的频率统计方式，以及相同频率时怎么打破平局，不能直接说它一定免疫。

CLOCK 也别想当然。经典二次机会/CLOCK 是 LRU 的近似，但不具备 LRU 那种严格的栈性质；极端情况下，所有访问位都是 1，它还会退化成 FIFO。所以不能因为它“近似 LRU”，就推出它一定免疫 Belady 异常。稳妥的结论是：FIFO 存在能触发 Belady 异常的访问序列，OPT 和 LRU 一定不会，CLOCK、LFU 这类要看具体定义。

记住一个结论就够用了：Belady 异常是 FIFO 这类非栈算法的毛病，遇到“加内存性能反降”的诡异现象，先怀疑置换策略，而不是怀疑内存条坏了。

## 这些概念在工程里怎么用？

虚拟内存不是只活在操作系统课本里，往上层走几步就能撞见它。

**mmap 与零拷贝**：`mmap()` 把文件直接映射到进程的虚拟地址空间，读文件变成访问内存。映射建立时并不会立刻把文件读进来，等你访问到某一页，才触发缺页、按页加载，这就是按需调页。它省掉了一次从内核缓冲区到用户缓冲区的拷贝，所以经常出现在零拷贝方案里。

**Redis 的内存与碎片**：Redis 是纯内存数据库，但它申请的内存最终也要落到物理页上。内存分配器（默认 jemalloc）按固定大小档位（size class）分配，会有空间浪费。它和分页里“不足一页也占一页”在“分配粒度大于实际用量”这点上很像，但一个发生在用户态分配器，一个发生在操作系统分页层，位置和治理方式都不同，不能直接当成同一种碎片。Redis 持久化时 fork 出子进程做快照，靠的也是写时复制（Copy-On-Write）：父子进程先共享同一批物理页，谁写谁才触发页复制，背后还是页表那套机制。

**JVM 的堆**：JVM 向操作系统申请的堆，也是一片虚拟地址空间。大堆会让页表覆盖的范围变大，配大页（HugePage / 2 MB 大页）能减少页表项数量、缓解 TLB 压力，对降低 GC 期间的访存开销有帮助。GC 扫描对象时反复跳来跳去，访问局部性差，更直接的代价是 cache miss 和 TLB miss 增多；只有相关页还没驻留、被回收过，或者系统本身内存吃紧时，才会进一步表现为缺页。这也是大堆调优要盯 TLB 的原因。

往下是硬件的页表和 TLB，往上是数据库、JVM、零拷贝。虚拟内存这层东西，看起来偏底层，实际经常会从各种性能问题里冒出来。

## 面试里怎么答？

如果面试官问“为什么需要虚拟内存”，别一上来就只说“为了隔离”。可以先这么答：程序里用的是虚拟地址，不是内存条上的真实地址。CPU 访问内存时，MMU 会根据页表把虚拟地址翻译成物理地址。这样每个进程都像在用一片连续的独立内存，哪怕两个进程里的地址数值一样，最后也可能落到不同的物理内存上。

然后再补一句收益：进程之间改不到对方的数据，程序不用关心物理内存具体放在哪，操作系统还能按需分配内存、换页，以及用 COW 这种机制减少复制。

如果继续追问“分页解决了什么”，就拿它和分段对比。分段是按代码段、数据段、栈这些逻辑模块来切，长度不固定，所以容易留下外部碎片，后面整理起来也麻烦。分页就简单很多：虚拟地址空间和物理内存都切成固定大小的页，页表只负责记录“虚拟页号 → 物理页帧”的关系。这样基本消除了外部碎片，不过页内可能会浪费一点空间，也就是内部碎片。

多级页表也可以顺手带一下：它不是为了让查表更快，而是为了省内存。没用到的地址空间，不需要真的把下级页表建出来。

问到 TLB 和缺页中断时，可以按“先走快路径，再处理异常”来说。CPU 先查 TLB，命中就直接拿到物理页号；没命中，才去查多级页表。如果页表项显示这个页还不在内存里，就触发缺页异常。内核先判断这次访问合不合法，合法才分配页帧，必要时回收旧页，再从文件或 Swap 把页面调进来，最后更新页表，重新执行刚才那条指令。

minor fault 通常不用读盘，major fault 要读盘；如果是非法地址访问，最后一般会走到 `SIGSEGV`。

页面置换算法不用把名字挨个背一遍，抓住一句话就行：换出去的页，最好是后面很久都用不到的页。OPT 最优但现实里做不到；LRU 效果接近 OPT，但实现成本高；CLOCK 用访问位近似 LRU；FIFO 最简单，但可能出现 Belady 异常。真实 Linux 也不是照搬某个教科书算法，而是用活跃/非活跃 LRU、workingset、refault 等机制做近似回收。
