| title | 虚拟内存详解:地址转换、TLB、缺页中断与页面置换 | |||||||
|---|---|---|---|---|---|---|---|---|
| description | 虚拟内存高频面试题总结,从进程隔离讲起,讲清虚拟地址到物理地址的分段、分页、多级页表、TLB、缺页中断、页面置换算法、Belady 异常,以及 mmap、COW、JVM 大页等工程场景。 | |||||||
| category | 计算机基础 | |||||||
| tag |
|
|||||||
| head |
|
打开任务管理器时,你可能会看到一个挺反直觉的现象:每个进程都像拿着一大片“自己的内存”,有些进程里的地址看起来还差不多,但它们并不会互相影响。浏览器、IDE、数据库同时跑,大家都以为自己有一块连续、干净、独占的空间。
这不是因为程序之间互相信任,而是操作系统在中间加了一层翻译。
程序看到的是虚拟地址,真正落到内存条上的位置,由内核和硬件一起决定。虚拟内存要讲的,也就是这层翻译怎么做、进程为什么能隔离、内存不够时又怎么把一部分数据先挪到磁盘上。
先看一个反例。
很多人大学里玩过单片机。单片机上没有复杂的操作系统,CPU 直接操作物理地址。这个环境里如果想同时跑两个程序,麻烦马上就来了:第一个程序往地址 2000 写了个值,第二个程序也刚好把数据放在 2000,那一写就把对方的数据覆盖了,两个程序一起出问题。
原因也不绕:两个程序都在直接引用同一套物理地址,谁也躲不开谁。
操作系统的做法是加一层隔离:给每个进程发一套独立的“虚拟地址”。进程只跟自己的虚拟地址打交道,这个地址最后落到哪块物理内存上,进程不用知道,操作系统统一安排。
于是就有了两个概念:
- 程序里用的地址,叫 虚拟地址(Virtual Address)。
- 真正存在内存条上的地址,叫 物理地址(Physical Address)。
进程访问虚拟地址时,CPU 里的内存管理单元(MMU)会根据映射关系,把它翻译成物理地址,再去访问内存。不同进程写的虚拟地址哪怕数值一样,映射到的物理地址也可以完全不同,自然就不会打架。
我们可以把虚拟内存的好处归成三条,后面整篇文章其实都在围绕它们展开:
- 进程隔离:每个进程一套页表,互相看不到对方的物理内存,A 进程没法靠一个地址直接摸到 B 进程的数据。
- 突破物理内存大小限制:程序运行有局部性,暂时用不到的页可以先放到磁盘上,需要时再换回来,所以进程“感觉到”的内存可以比物理内存大。
- 统一且连续的地址空间:进程看到的是一整片连续的虚拟地址,物理上却可以东一块西一块。拼起来这件事,交给映射表。
接下来最重要的问题只有一个:虚拟地址到物理地址,到底怎么映射?
主要有两种办法:分段和分页 。
分段(Segmentation)出现得比较早,思路也很符合程序员直觉。一个程序本来就由代码、数据、栈、堆这些部分组成,它们的访问权限和生命周期都不一样,那就按逻辑切成几个段。
分段下的虚拟地址由两部分组成:段选择子和段内偏移量。
段选择子放在段寄存器里,里面最关键的是段号,用来当段表的索引。段表里记录这个段的基地址、段界限(段有多长)和特权级。
翻译过程也不复杂:拿段号去段表里查到段基地址,再检查段内偏移量有没有超过段界限。合法的话,基地址加偏移量就是物理地址。比如要访问段 3、偏移 500 的地址,段 3 的基地址是 7000,那物理地址就是 7000 + 500 = 7500。
分段解决了“程序不用关心物理地址”的问题,但它也留下两个坑。
第一个是外部内存碎片。每个段的长度不固定,段与段之间很容易剩下一些零碎空隙。举个例子,物理内存里依次放了四段: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 负责查表翻译。地址转换通常分三步:
- 把虚拟地址拆成页号和页内偏移;
- 用页号去页表里查出对应的物理页号;
- 物理页号拼上页内偏移,得到最终物理地址。
分页怎么解决分段的毛病?
页大小固定,物理页也按固定粒度管理,不会像变长段那样在段与段之间留下奇怪的小空隙,所以基本消除了外部碎片。代价是页内可能浪费:一个程序哪怕只用了几个字节,也得占满一整页,这部分叫内部碎片。不过这个浪费通常可控,比外部碎片好处理。
管理粒度也变小了。物理内存不够时,操作系统可以挑一些“最近没怎么用”的页换出到磁盘(Swap Out),需要时再换入(Swap In)。调入调出的单位,从一整个变长段,缩到了固定大小的页。
别误会,分页不代表磁盘压力一定小。真遇到大量主缺页或者抖动,频繁的页级 I/O 一样能把系统拖垮。
分页还有一个很实用的点:程序不需要一次性全部装进内存。先把虚拟页和物理页的映射关系准备好,但不急着把页真的搬进物理内存。等程序访问到某个虚拟页,再把它加载进来。这就是按需调页(Demand Paging)的基础。
分段和分页不一定只能选一个,也可以合在一起用,这就是 段页式内存管理 。
做法是先分段再分页:先把程序切成有逻辑意义的段,再把每个段切成固定大小的页。地址也就变成三段:段号、段内页号、页内偏移。每个程序一张段表,每个段再挂一张页表,段表项里存这个段对应页表的起始地址。
缺点也很好理解:访问内存的次数变多。一次段页式地址转换要走三趟内存:
- 第一趟查段表拿到页表起始地址;
- 第二趟查页表拿到物理页号;
- 第三趟才用物理页号加页内偏移访问真正的数据。
这里插一段历史,能解释为什么 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)
在只用四级硬件分页的 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 位以上分配地址,除非应用显式请求。普通机器默认还是四级更常见。
多级页表省了空间,但会多花时间:原来查一次表,现在 64 位下可能要查四级。一次内存访问背后,如果还藏着四五次查表访存,代价就太高了。
还是靠局部性原理救场。程序在一段时间内反复访问的页,通常就那几批。那就把最常用的页表项,缓存到比内存快得多的硬件里。这块缓存就是 TLB(Translation Lookaside Buffer),中文叫快表、转址旁路缓存,封装在 CPU 的 MMU 里。
有了 TLB,CPU 寻址时先查 TLB:
- 命中(TLB Hit),直接拿到物理页号,跳过多级页表查找。
- 未命中(TLB Miss),再去查内存里的多级页表,查到后把这一项塞进 TLB,方便下次访问。
因为热点页就那么多,TLB 命中率通常不低。多级页表带来的查表成本,大多数时候都被 TLB 扛掉了。
按需调页有个前提:进程访问某个虚拟页时,这个页不一定已经在物理内存里。MMU 查页表时,如果发现页表项显示该页不在内存(页表项里有个存在位 / present bit),就会触发缺页中断,把控制权交给内核的缺页处理程序。
流程可以按这几步记:
- CPU 拿着虚拟地址查页表,发现目标页不在物理内存,触发缺页中断(一种硬件异常)。
- 进入内核态,缺页处理程序先判断这次访问是否合法。如果访问的是非法地址,比如野指针,就报段错误(Segmentation Fault),进程通常会被杀掉。
- 合法的话,找一个空闲物理页帧。如果没有空闲帧,就回收或置换一个“受害者”页:干净的文件页可以直接丢弃,要用时再从原文件读回;脏的文件页要先写回原文件;匿名页则在启用了 Swap 时写入交换空间。到底会不会产生磁盘写,取决于页的类型以及脏不脏。
- 把需要的页从磁盘(Swap 区或文件)读进物理内存,更新页表项,让它指向新的物理页帧。
- 返回用户态,重新执行刚才触发缺页的那条指令,这次就能正常访问了。
从 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,就换这一页出去。一个访问位加一圈环形扫描,就能便宜地模拟“最近有没有被用过”。
LFU(最不经常使用):给每个页记访问次数,换出访问次数最少的页。它看的是访问频率,不是访问时间。问题是早期被频繁访问、后来不用的页,计数很高却赖着不走,所以实际中常配合计数衰减来用。
横向对比一下:
| 算法 | 换出依据 | 实现成本 | 效果 | 能不能落地 |
|---|---|---|---|---|
| OPT | 未来最久不用 | 无法实现 | 理论最优 | 只作基准 |
| FIFO | 进入时间最早 | 很低 | 一般,可能误伤热点页 | 能 |
| LRU | 最久未访问 | 高(时间戳/链表) | 接近 OPT | 纯软件较吃力 |
| CLOCK | 访问位 + 环形扫描 | 低 | 接近 LRU | 能,主流近似方案 |
| LFU | 访问次数最少 | 中(需计数) | 看场景 | 能,常配衰减 |
这里别误会:上面这些是教科书算法,用来理解置换策略。真实的 Linux 内核不是在 OPT/FIFO/LRU/CLOCK 里直接挑一个,而是用活跃/非活跃双 LRU 链表、workingset、refault 检测这套近似机制;文件页和匿名页的回收策略也不一样,还会受 NUMA、cgroup、内存水位影响。所以“Linux 用的就是 CLOCK”这种说法不严谨。
按直觉,物理内存的页帧越多,缺页应该越少。但 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 等机制做近似回收。






