你大概见过这种场面:浏览器开着几十个标签页,开发工具正在编译,聊天软件和音乐播放器也没关。任务管理器里的内存占用已经很高,可大多数程序依然能继续运行,只是切回一个很久没碰的窗口时,界面偶尔会停顿一下。
这不是内存凭空变多了。操作系统做了两件事:先让每个进程看到一套独立、连续的虚拟地址,再把真正用到的那部分内容按页放进物理内存。暂时不用的页可以留在文件中、写入交换空间,或者压缩后留在内存里。程序只负责发出虚拟地址,硬件和内核共同完成后面的翻译、检查、调入与回收。
虚拟内存解决的是一组彼此牵扯的矛盾:程序希望地址空间大而连续,物理内存却有限而零散;多个进程希望互不干扰,又常常需要共享代码和数据;系统希望多放几个进程提高并发度,可放得太多又会因为频繁换页而变得更慢。理解这一章,关键不是背术语,而是顺着一次内存访问往下追:地址怎样翻译,缺页怎样恢复,内存满了怎样选择牺牲页,选择不当又为什么会引发颠簸。

图:进程发出的虚拟地址经过 MMU 和页表后,落到某个物理页框;页内偏移在翻译过程中保持不变。
说“一个程序占了多少内存”时,人们经常把虚拟地址空间、已经承诺的存储和当前驻留的物理内存混在一起。它们有关联,但不是一回事。
虚拟地址空间是进程能够使用的地址范围。进程看到的代码、全局数据、堆、栈、共享库和内存映射文件,都位于这个范围内。地址看起来可以连续,底层对应的物理页框却可以散落在内存各处;中间尚未使用的大片地址甚至可以完全没有物理页框。这种“地址存在、存储尚未到位”的稀疏布局,让堆和栈能够按需增长,也给共享库、映射文件和保护区留出了位置。
承诺或后备存储回答的是另一个问题:如果进程真的写入这些虚拟页,系统有没有办法保存内容?匿名页通常需要物理内存或交换空间承担后备责任,文件映射页则可以由对应文件提供内容。不同操作系统对“预留”“提交”和过量承诺的规则并不完全相同,所以不能看到一大片虚拟地址就断定同样多的物理内存已经被占用。
驻留集指当前确实在物理内存里的那些页。一个进程可以拥有很大的虚拟地址空间,真正驻留的只是一小部分。切回长期闲置的应用时出现短暂停顿,往往就是它需要的页已不在驻留集中,必须重新取得。
每个进程通常有自己的地址空间和页表。同一个数值的虚拟地址,在两个进程里可以指向完全不同的物理页框。这层间接映射提供了隔离:进程不能仅凭猜出一个地址就读取别人的内存,页表项中的读、写、执行和特权级权限还会进一步限制访问。
但“独立地址空间”并不意味着所有物理页都要独占。多个进程可以把各自不同的虚拟地址映射到同一个物理页框。只读共享库、共享内存、内存映射文件都依赖这种能力。线程则更进一步:同一进程中的线程通常直接共享整套进程地址空间,只保留各自的寄存器状态和栈等执行上下文。
虚拟内存最有价值的地方不是单纯“让程序超过内存容量”。它同时提供地址翻译、访问保护、稀疏分配和受控共享。把它只理解成“拿硬盘冒充内存”,会漏掉更重要的一半。
假设页大小是 4 KiB,CPU 要访问虚拟地址 12345。这个地址可以拆成虚拟页号和页内偏移:
虚拟页号是 3,页内偏移是 57。页表只需要回答“虚拟页 3 对应哪个物理页框”。如果对应页框号是 10,最终物理地址就是:
页内偏移没有变化,因为虚拟页和物理页框大小相同。变化的是高位所代表的页号。
页表项通常还保存一组状态和权限信息,例如页面是否驻留、是否允许写入、是否允许执行、是否被访问过、是否已经修改。具体位布局由处理器体系结构决定,但用途大体相同。
因此,一次翻译失败并不只有“页面不在内存”这一种原因。写只读页、用户态访问内核页、从不可执行页取指令,都可能触发异常。内核必须先判断这是可以修复的正常缺页,还是应该向进程报告的非法访问。
如果把整个虚拟地址空间做成一张线性大表,大量尚未使用的地址也要占页表项。多级页表把虚拟页号分段,每一段索引一级目录。某一大片虚拟地址完全没用到时,上层目录只需留下一个空项,下面整批页表都不必创建。
代价是查找路径变长。硬件若每次都逐级读取页表,普通内存访问会被放大成多次内存访问。于是处理器又加入了 TLB,也就是地址翻译的高速缓存。TLB 命中时可以迅速得到页框号;TLB 未命中时,硬件或软件需要进行页表遍历,找到映射后再填回 TLB。
TLB 未命中不等于缺页。TLB 未命中只说明高速缓存里没有翻译,页表中可能仍有完整且驻留的映射;只有页表也表明页面不在内存,或者访问权限不成立,才会进入缺页异常处理。
使用更大的页,可以让一个 TLB 项覆盖更多内存,也能缩小页表并减少遍历次数。数据库、大型堆和虚拟机等连续访问大块内存的负载,可能因此受益。可大页需要更大的连续物理区域,分配和回收更难;只使用其中一小部分时,内部碎片也更明显。大页不是“越大越快”,它只是把翻译开销、碎片和分配难度重新配平。
下面的小实验使用一张简化页表。输入地址后,你可以看到虚拟页号、页内偏移和物理地址;也可以点击页表项改变驻留状态,观察同一个地址怎样从正常翻译变成缺页。
按需分页的做法很克制:先建立合法的虚拟地址区域,页面真正被访问时再准备物理页框和内容。程序启动不必一次读入全部代码,刚扩大的堆也不必立刻占满内存。许多页面可能从程序开始到结束都没有被碰过,这些 I/O 和内存便被省了下来。
第一次访问尚未驻留的合法页面时,MMU 触发缺页异常,CPU 暂停当前执行流并进入内核。后面的处理大致如下。
内核先找到该地址所属的虚拟内存区域,检查访问类型和权限。地址根本不属于进程,或写入只读区域时,系统不会盲目调页,而是把它当成非法访问。
如果访问合法,内核判断页面内容来自哪里。它可能是需要清零的匿名页、文件中的一页、写时复制页面,也可能是先前换出的页。
系统取得一个可用页框。空闲列表不足时,还要先启动页面回收;选中的旧页若已修改,可能需要写回文件或交换空间。
页面内容准备好后,内核更新页表项和相关缓存状态,让映射指向新页框,并恢复被暂停线程的执行环境。

图:缺页处理必须先区分合法但尚未驻留的访问与真正的越界或权限错误。
缺页可能发生在取指令、读取操作数或写回结果的阶段。处理器要保存足够的状态,让异常返回后能从正确位置重启。现代体系结构通常提供精确异常语义:在操作系统看来,故障指令要么尚未产生可见结果,要么架构明确规定了可恢复方式。
这件事对复杂指令尤其重要。假如一条指令会连续读写多个内存位置,执行到一半才发现后面的页不在内存,系统不能把已经发生的修改当作没发生。处理器的指令设计、异常状态和内核处理必须共同保证重试不会破坏结果。由此也能看出,按需分页不是单靠操作系统软件就能随意加上的功能。
“缺页”这个名字容易让人以为每次都要访问存储设备。实际上,很多缺页可以在内存内部解决。例如首次写入匿名页时分配一个清零页框,或者目标页仍在内存的缓存/待机集合中,只需重新建立映射。这类情况常被称为软缺页。
如果页面内容只能从文件、交换设备等后备存储重新读入,就会发生硬缺页。它包含设备排队和 I/O 延迟,通常比内存访问慢很多。名称和统计口径会随操作系统变化,但判断性能时应先问清楚:这次异常到底有没有产生外部 I/O。
设普通内存访问时间为 ,一次完整缺页处理的平均时间为 ,缺页概率为 ,可以用下面的简化式理解平均成本:
这个模型省略了 TLB、缓存层次和异步写回等细节,却能揭示核心矛盾: 可能比 大很多个数量级。因此,哪怕 看起来很小,也可能主导平均访问时间。换成固态存储能缩短硬缺页,但不可能把它变成普通内存访问;如果系统已经陷入频繁换页,仅靠更快的设备仍然治标不治本。
新分给用户进程的页框不能带着上一个使用者的数据。系统通常在交付前将其清零,有些实现会提前准备一批已清零页框,有些则在首次需要时完成。清零既是初始化,也是安全边界。
空闲页框还会保留一定水位。若每次缺页都等到最后一个空闲页框耗尽才开始回收,处理路径就必须同步等待写回和扫描。提前在后台回收冷页,可以让大多数缺页更快拿到可用页框,但后台扫描本身也会消耗 CPU 和内存带宽。
启动一个新进程时,如果立即复制父进程的所有可写页面,代价很可能白付。子进程常常很快加载另一个程序,刚复制的内容随即被丢掉。写时复制把这个成本推迟到“真的有人写”那一刻。
创建子进程后,父子页表先指向相同的物理页,并把原本可写的映射暂时设为只读和写时复制状态。双方读取时继续共享。某一方第一次写入时,处理器因为写保护触发异常;内核确认这是写时复制页后,分配新页框,复制旧内容,让写入方改映射并恢复写权限。另一方仍指向原页。

图:未修改的页面一直共享;只有发生写入的那一页才产生副本。
它节省的是启动时的复制时间和物理内存,尤其适合“创建后很快执行新程序”的路径。只读代码页本来就能安全共享,不必复制;可写数据中没有被改动的部分,也能一直共享。
代价被转移到了首次写入:这一刻会发生异常、分配和复制,写入延迟比普通写操作大。内核还要处理多个线程同时写同一共享页的竞态,保证引用计数和页表更新正确。写时复制不是免费复制,而是把复制变成更细粒度、更可能被避免的按需工作。
共享内存让多个进程的映射长期指向同一物理内容,一方写入后另一方能够看到。它很适合高吞吐的进程间通信,但数据一致性需要进程自己用锁、原子操作或协议维护。
私有文件映射则可以借助写时复制:最初读取文件页,写入时得到进程自己的匿名副本,修改不会回到原文件。两种映射看起来都可能共享物理页,语义却完全不同,关键要看修改是否应该传播。
有些 Unix 系统提供让子进程临时直接使用父进程地址空间的创建方式。它省去了页表复制,却要求父进程暂停,而且子进程在加载新程序或退出前几乎不能随意修改内存。除非严格遵守接口约束,否则一次普通写入就可能破坏父进程状态。
缺页发生时若还有空闲页框,事情很简单。真正困难的场面是物理内存已满:系统要选一个驻留页作为牺牲页,解除它的映射,再把新页放进腾出的页框。
选页之前先看内容是否可恢复。干净的文件页可以直接丢弃,需要时再从文件读取;已修改的文件页必须写回文件;匿名页若没有其他副本,通常需要交换空间或压缩存储来保存。修改位的价值就在这里:避免对从未改动的页做无意义写回。
页面置换算法追求的不是“选出绝对没用的页”,因为系统看不到未来。它只能利用进入时间、访问历史和引用位,估计哪一页再次被访问的可能性较低。

图:FIFO 看进入顺序,OPT 看未来,LRU 看过去;三者掌握的信息不同。
先进先出选择最早进入内存的页。维护一个队列即可,实现成本低。问题是“住得久”不代表“不再用”:一个循环中反复访问的热点页可能恰好最早进入,FIFO 仍会把它换走。
FIFO 还可能出现 Belady 异常:对某些引用串,增加页框数量反而产生更多缺页。原因不是内存变多有害,而是 FIFO 在不同容量下形成了不同淘汰顺序,较大容量的驻留集合不一定包含较小容量的驻留集合。
最优算法选择未来最久以后才会再次访问的页;若某页以后不再出现,就优先换掉它。它在给定引用串和页框数时得到最少缺页,可用来评价其他算法。
运行中的系统无法预知任意程序未来会访问哪一页,所以 OPT 不能直接实现。离线分析、可重复任务或有明确提示的工作负载可以利用部分未来信息,但那仍不等于通用的最优置换。
最近最久未使用算法选择过去最长时间没有被访问的页。它依据时间局部性:刚访问过的代码和数据,短期内更可能再次使用。对许多负载,LRU 比 FIFO 更贴近真实行为,而且不会出现 FIFO 那种 Belady 异常。
精确 LRU 的维护成本很高。每次内存访问都更新时间或调整全局顺序,会给硬件和内核带来巨大压力。实际系统通常读取硬件设置的访问位,再配合时钟扫描、老化计数、活跃/非活跃队列或多代队列,近似判断冷热。近似算法的目标不是还原每一次访问,而是以可接受的维护成本把明显的冷页筛出来。
局部置换只从发生缺页的进程所占页框中挑牺牲页。这样进程之间的性能隔离较强,但一个进程闲置的页框不能迅速救助另一个突然扩大的工作集。
全局置换允许从更大的系统范围选页,物理内存利用更灵活;代价是进程会互相影响。一个内存需求激增的任务可能挤出别人的热点页,使其他任务也开始缺页。现代系统还会叠加优先级、内存控制组、NUMA 节点和不可回收页等约束,实际选择范围远比“全局或局部”二选一复杂。
修改页框数、引用串和算法,观察每一步的命中与缺页。选择 OPT 时,工具会向后查看剩余引用;这也直观说明了它为何适合当基准,却不能直接用于在线系统。
页面置换回答“换谁”,页框分配回答“每个进程能留下多少页”。两者不能分开看。一个进程如果拿到的页框少于当前执行阶段真正需要的页面集合,再聪明的置换算法也只能在几张热点页之间来回搬运。
体系结构会对进程所需的最小页框数施加约束。一条指令可能跨页,源操作数和目标操作数也可能各在不同页面;要让指令在缺页后可恢复,系统至少得容纳完成这类访问所需的页面。这个下限由指令集和页面机制共同决定。
在下限之上,最简单的分法是平均分配。可进程大小和工作负载不同,平均不一定合理。按进程地址空间、历史驻留需求或优先级分配会更灵活,但也会引入策略问题:大进程是否天然应该拿得更多,后台低优先级任务又该不该被挤到几乎无法运行?
实际系统往往动态调整。空闲内存多时允许工作集扩大,压力上升时逐步回收;还会保留内核、设备和不可分页内存需要的余量。分配不是一次做完的静态切蛋糕,而是持续反馈。
程序通常在一段时间内集中访问少量代码和数据。例如处理一批图片时,热点可能是解码循环、当前图片缓冲区和少数库函数;切换到保存阶段后,热点集合又会变化。这种在一定时间窗口内活跃的页面集合,可以用工作集来描述。
设最近 次内存引用形成的页面集合为 ,其大小就是这个窗口下的工作集需求:
窗口太小会漏掉仍然重要的页,太大又会把已经冷却的旧阶段算进来。工作集不是进程永恒不变的属性,而是随执行阶段和观察窗口移动的估计。
假设多个进程当前工作集大小之和已经超过可用页框数。进程 A 刚调入一页,可能挤走进程 B 的热点页;B 继续运行又立刻把它调回来,再挤走 C 的页。存储设备很忙,CPU 却经常等待缺页完成,有效工作反而减少,这就是颠簸。
一个危险的反馈是:调度器看到 CPU 利用率降低,以为系统里可运行任务不够,又放入更多进程。新增进程继续争夺页框,缺页率更高,CPU 利用率进一步下降。正确方向通常是降低多道程序度、暂停或限制部分任务,让留下来的进程先保住工作集。

图:总工作集超过可用页框后,换入换出会吞掉执行时间;增加并发任务只会继续放大压力。
系统不一定要精确计算完整工作集。另一种思路是观察页面错误频率:某进程缺页率持续高,说明分到的页框可能不足;缺页率长期很低,则可能有部分页框可以让出。
这套反馈也不能只看一个瞬时数值。进程刚切换执行阶段时,缺页会自然升高;顺序扫描大文件也可能产生大量一次性访问。系统需要结合时间窗口、页面类型、回收成本和全局压力判断,避免追着短暂波动反复调配。
拖动时间位置和窗口长度,观察同一条引用串的活跃页面怎样变化。下方再把同样规模的工作集扩展到多个进程,比较总需求与物理页框。如果总需求超过供给,工具会显示缺口,但它不会假装精确预测缺页率——真实缺页还取决于调度和访问顺序。
内存压力上升时,系统不一定马上把页面写到磁盘。它先判断页面类型和恢复成本,再选择更合适的去向。
干净文件页最容易处理,因为文件本身就是副本,解除映射后即可回收页框。脏文件页需要先写回。匿名页包含堆、栈等运行时数据,没有原始文件可重新读取;它们要么继续驻留,要么被压缩保存,要么写入交换空间。被锁定、正在进行某些 I/O 或属于不可分页区域的页,则不能随意回收。
冷匿名页如果压缩率不错,可以用更小的内存块保存。再次访问时,系统在内存中解压,通常比从存储设备读回更快。压缩池达到上限后,部分内容仍可能被写到交换设备。
这项技术的代价是压缩与解压消耗 CPU,压缩池也需要元数据和管理空间。已经压不小的数据反复尝试会浪费计算;CPU 本就繁忙时,压缩还可能与应用争夺执行时间。它适合缓冲内存压力,不会改变“总工作集长期超过物理内存就必须做取舍”的事实。
按需调页可能让顺序访问产生一串小 I/O。系统若判断程序正在连续读取,可以一次预取邻近页面,把多次等待合并成更大的读取。预测准确时,页面会在真正访问前准备好;预测错误时,预取页占用内存和带宽,还可能挤出更有价值的热点页。
因此,预取通常是提示和启发式策略,而不是正确性要求。应用即使不给提示也应能正常访问,只是性能可能不同。
当干净缓存已回收、冷匿名页已压缩或换出,仍无法满足必须到来的分配时,系统只能拒绝分配、限制任务,或者终止部分进程释放内存。这个阶段不是虚拟内存失效,而是所有后备资源都有限:虚拟地址可以画得很大,真正保存被修改数据的空间却不能凭空出现。
交换空间扩大了匿名页的后备容量,却不等于增加了同等性能的内存。只要活跃工作集长期依赖交换设备,交互延迟就会显著上升。
用户进程通常以页为单位获得映射,内核却频繁创建进程描述符、目录项、网络缓冲区等小对象。有些设备还要求物理连续内存。若所有请求都直接按整页处理,小对象会浪费大量空间;若任意切割物理内存,又很难在需要大块连续页时找到合适区域。
常见做法是分层:底层页分配器管理连续页块,上层对象分配器把页块组织成固定类型或固定大小的对象缓存。

图:伙伴系统解决页块拆分与合并,Slab 类分配器在页块之上复用内核对象。
伙伴系统把连续物理页按 个页分组。收到请求后,若没有正好大小的块,就把更大的块一分为二,持续拆到能够满足请求的最小阶。释放时检查与它同阶、由同一次拆分产生的伙伴;伙伴也空闲,就合并成上一阶的大块。
例如需要 21 KiB,而可分配粒度最终只能给出 32 KiB,未使用部分形成内部碎片。伙伴系统的优势是拆分和合并规则明确,能较快得到连续页;代价是请求被向上取整,而且长期运行后,不可移动页可能把大范围切碎,导致总空闲页不少却找不到高阶连续块。
对象分配器向页分配器取得一个或多个页,再切成适合某类对象的槽位。进程描述符、文件对象或相同大小的小块可以各自进入对应缓存。对象释放后回到缓存,下次请求直接复用,省去重复布局和部分初始化工作。
一个 slab 可以是满、部分使用或空。分配器通常优先使用部分 slab 的空闲槽位,以减少半空页块数量;当缓存过多时,再把完全空闲的 slab 归还底层页分配器。
Slab 并不能神奇地消除所有碎片。对象大小不能整除页块时仍会留下边角,长期不用的缓存也可能占空间。它真正解决的是大量同类小对象的分配速度、对齐和复用问题。多核系统还会使用每 CPU 缓存,减少所有处理器争用同一把锁,但本地缓存过多又会增加内存占用,依然需要回收和再平衡。
小页减少内部碎片,却增加页表项、TLB 压力和 I/O 次数;大页减少翻译与遍历开销,却要求更大的连续页框,换入换出粒度也更粗。系统往往同时支持基础页和大页,根据映射与负载选择,而不是全机只使用一种尺寸。
假设二维数组按行连续存储。逐行访问会在同一页内连续处理许多元素,空间局部性较好;逐列访问则可能每次跳到另一页。在数组远大于缓存和驻留集时,两个循环完成的计算相同,缺页和缓存未命中数量却可能相差很大。
/* 更贴近行主序布局 */
for (int i = 0; i < 行数; i++)
for (int j = 0; j < 列数; j++)
data[i][j] = 0;虚拟内存对程序保持功能透明,不代表性能也与访问模式无关。数据结构布局、批处理大小和遍历顺序,都会改变工作集。
设备通过 DMA 向一段内存写数据时,页框必须在操作完成前保持有效。若系统中途回收页面并把同一页框交给别的用途,设备就可能把数据写到错误对象。内核会对这类页面进行固定或锁定,I/O 完成后再解除。
固定页提高了设备访问的安全性和效率,却会缩小可回收内存。长期固定大量页还会妨碍迁移、压缩和连续内存分配,所以相关接口需要严格配对,并限制长期固定的范围。
内存映射文件让文件页直接进入进程地址空间。普通文件读写也常经过页缓存。两条路径最终可能共享相同的物理缓存页,避免同一文件内容存两份。页面回收因此不只是“从进程里换页”,还在匿名内存、文件缓存、写回压力和应用工作集之间做选择。
不同操作系统的字段名、队列结构和策略不同,但都绕不开同一条主线。
Linux 在软件层把页表抽象成多级结构,硬件层数不足时会折叠中间级。页表项既能指向普通页,也能在较高层映射大页。回收方面,系统利用访问历史区分冷热页面,现代实现还可以用多代队列按时间层次估计页面年龄。匿名页可进入交换或压缩缓存,干净文件页则可以直接回收。
Windows 用工作集描述进程当前驻留的可分页页面。页面离开某个进程工作集后,内容未必立即从 RAM 消失;若仍在待机或过渡状态,再访问可能只产生软缺页。只有必须从后备存储读取时,才形成更昂贵的硬缺页。这个例子提醒我们,进程“当前驻留多少”和系统“内存里是否还有该内容”也不是同一个问题。
Unix 家族常见的进程创建路径把写时复制与加载新程序配合起来:先低成本建立子进程视图,真正修改时再复制;若子进程立即加载新程序,大部分旧页面从未被复制。这个组合把通用接口和常见使用模式衔接得很好,但首次写入延迟和页表维护仍然存在。
这些实现没有一个简单的“最佳算法”标签。真实系统会把页类型、最近访问、脏页成本、进程隔离、NUMA 位置、内存控制和设备约束一起纳入决策。教科书里的 FIFO、LRU 和工作集模型,是理解这些策略的坐标系,不是内核源码的一比一流程图。
现在再看开头那个场景。你切回很久没用的浏览器标签页,CPU 先用虚拟地址访问代码和数据。TLB 中没有翻译时,硬件遍历页表;若页表显示页面不驻留,就触发缺页。
内核确认地址合法后寻找页面内容。干净的文件代码可以重新读取,匿名数据可能从压缩池或交换空间恢复。没有空闲页框时,回收器根据冷热信息挑选其他页面;脏页还可能需要写回。映射更新后,故障指令重新执行,窗口终于恢复。
如果只是偶尔发生,延迟很短,虚拟内存成功用少量驻留页支撑了更多程序。如果所有进程的工作集一起超过物理内存,系统便不断回收刚要再次使用的页,进入颠簸。此时再精巧的地址翻译也救不了容量矛盾,只能减少并发、压缩或换出冷数据,或者增加物理内存。
这就是虚拟内存的完整取舍:它用翻译和异常处理换来隔离、共享与按需分配;又用回收策略努力把昂贵 I/O 留给真正的冷页。它能推迟并缓和物理内存不足,却不能消灭存储容量和访问速度的边界。
CPU 重新执行触发异常的指令。对程序而言,这次访问像是“晚了一点才成功”,而不是要求应用自己跳回原位置重试。