虚拟内存:操作系统如何管理内存
在现代计算机上,每个程序运行时都仿佛独占了一大片连续的内存空间:代码从固定的地址开始,栈从高地址向下增长,堆在中间按需扩展。多个程序同时运行,彼此之间互不干扰,一个程序的错误访问不会破坏另一个程序的数据。程序申请的内存总量,甚至可以超过机器实际安装的物理内存。
这一切都建立在虚拟内存机制之上。它是操作系统与硬件协作实现的一层抽象,几乎影响着程序运行的每一个方面:进程隔离、内存分配、文件读写、进程创建,乃至程序的性能表现。本文介绍虚拟内存的基本概念、分页机制、地址转换过程、缺页处理、页面置换,以及与之相关的一系列重要技术。
一、为什么需要虚拟内存
在没有虚拟内存的早期系统中,程序直接使用物理地址访问内存。这种方式存在几个根本性的问题。
缺乏隔离。 所有程序共享同一个物理地址空间,一个程序可以随意读写其他程序甚至操作系统内核的内存。一个程序的错误就可能导致整个系统崩溃,恶意程序更可以窃取或篡改其他程序的数据。
地址冲突。 程序在编译时需要确定代码和数据的地址。如果两个程序被编译为使用相同的地址,它们就无法同时运行。虽然可以在加载时对程序的地址进行重定位,但这增加了复杂性和加载开销。
内存碎片。 程序需要一段连续的物理内存。随着程序不断启动和退出,空闲内存被切分成大量不连续的小块,即使空闲总量充足,也可能无法为一个新程序找到足够大的连续空间。
容量受限。 程序能使用的内存不能超过物理内存的大小,运行多个程序时,每个程序可用的内存更少。
虚拟内存通过在程序和物理内存之间引入一层地址转换,系统性地解决了这些问题。
二、虚拟地址与物理地址
在虚拟内存系统中,存在两种地址:
- 虚拟地址:程序中使用的地址。每个进程拥有自己独立的虚拟地址空间,在 64 位系统上,这个空间可以非常庞大;
- 物理地址:内存芯片上实际的存储位置。
程序发出的每一次内存访问使用的都是虚拟地址。处理器中的内存管理单元(Memory Management Unit,MMU)负责在访问发生时,将虚拟地址转换为物理地址。这一转换过程由硬件自动完成,对程序完全透明。
转换规则由操作系统设定和维护。操作系统为每个进程建立一套独立的映射关系:
- 不同进程的相同虚拟地址,可以映射到不同的物理地址,因此各进程可以使用相同的地址布局而互不冲突;
- 一个进程无法通过虚拟地址访问到未被映射给它的物理内存,从而实现了进程之间的隔离;
- 虚拟地址空间中连续的区域,可以映射到物理内存中不连续的位置,从而避免了对连续物理内存的需求;
- 虚拟地址可以暂时不映射到任何物理内存,而是对应磁盘上的数据,在需要时再加载,从而让程序可以使用超过物理内存容量的地址空间。
三、分页机制
如果为每个字节都单独记录一条映射关系,映射表本身就会比内存还大。因此,虚拟内存以固定大小的块为单位进行管理,这就是分页(Paging)。
页与页框
- 虚拟地址空间被划分为固定大小的块,称为页(Page);
- 物理内存被划分为同样大小的块,称为页框(Page Frame)或物理页。
最常见的页大小为 4KB。映射以页为单位进行:每个虚拟页可以映射到一个物理页框,或者暂时不映射。
一个虚拟地址可以分为两部分:
- 页号:高位部分,表示该地址位于哪个虚拟页;
- 页内偏移:低位部分,表示该地址在页内的位置。对于 4KB 的页,页内偏移占 12 位。
地址转换时,只需根据页号找到对应的物理页框号,再与页内偏移拼接,即可得到物理地址。页内偏移在转换前后保持不变。
页表
记录虚拟页到物理页框映射关系的数据结构称为页表(Page Table)。每个进程拥有自己的页表,处理器中有一个专门的寄存器保存当前进程页表的物理地址。进程切换时,操作系统更新这个寄存器,处理器随即使用新进程的映射关系。
页表中的每一项称为页表项,除了物理页框号之外,还包含若干标志位,常见的有:
- 存在位:表示该虚拟页当前是否映射到物理内存;
- 读写位:表示该页是否允许写入;
- 用户/内核位:表示该页是否允许用户态程序访问;
- 执行禁止位:表示该页中的数据是否允许作为指令执行;
- 访问位:处理器在该页被访问时自动置位,供操作系统统计页面的使用情况;
- 脏位:处理器在该页被写入时自动置位,表示页面内容已被修改。
这些标志位使得操作系统能够对内存实施细粒度的保护。例如,将代码段设置为只读且可执行,将数据段和栈设置为可读写但不可执行,可以有效阻止许多类型的攻击。
四、多级页表
单级页表的问题
在 32 位系统上,虚拟地址空间为 4GB。如果页大小为 4KB,就有一百万个虚拟页,每个页表项占 4 字节,单个进程的页表就需要 4MB 连续内存。而在 64 位系统上,如果采用单级页表,所需空间将是天文数字。
但实际上,绝大多数进程只使用了虚拟地址空间中很小的一部分:代码、数据、堆、栈和少量映射区域,中间大片区域完全未被使用。为这些未使用区域维护页表项是巨大的浪费。
多级页表的结构
多级页表将页号进一步分成多段,形成一个树状结构:
- 顶层页表的每一项指向一个下一级页表;
- 下一级页表的每一项再指向更下一级页表;
- 最底层页表的每一项指向实际的物理页框。
关键在于:如果某一片虚拟地址区域完全未被使用,对应的下级页表根本不需要分配,上级页表项只需标记为不存在即可。这样,页表占用的空间与进程实际使用的内存大致成正比,而非与整个地址空间成正比。
在主流的 64 位处理器上,通常只使用虚拟地址的低 48 位,采用四级页表,每级页号占 9 位,每个页表恰好占用一个 4KB 的页,包含 512 个页表项。为支持更大的地址空间,一些新处理器还支持五级页表,可使用 57 位虚拟地址。
多级页表的代价
多级页表节省了空间,但带来了时间上的代价:一次地址转换需要依次访问每一级页表。在四级页表下,一次内存访问在最坏情况下需要先进行四次额外的内存访问来查找页表,再进行一次实际的数据访问,开销达到原来的五倍。
如果每次内存访问都要付出这样的代价,系统性能将无法接受。这就需要一个关键的硬件组件。
五、TLB:地址转换的缓存
转译后备缓冲区(Translation Lookaside Buffer,TLB)是 MMU 中一个专门用于缓存地址转换结果的小型高速缓存。它保存了最近使用过的虚拟页号到物理页框号的映射,以及相应的权限标志。
地址转换时,MMU 首先查找 TLB:
- 命中:直接得到物理页框号,转换在一个周期内完成,几乎没有额外开销;
- 未命中:需要遍历多级页表,这一过程称为页表遍历。在主流处理器上由硬件自动完成,找到映射后将其填入 TLB,供后续使用。
TLB 的容量很小,通常只有几十到几千个条目,并且也像数据缓存一样分为多个层级。但由于程序访问内存具有局部性,同一页内的连续访问只需要一次地址转换,TLB 的命中率在大多数情况下都非常高。
TLB 与进程切换
由于不同进程的同一虚拟地址映射到不同的物理地址,进程切换后,TLB 中原有的条目对新进程是无效的。
最简单的做法是在每次进程切换时清空 TLB。但这会导致新进程刚开始运行时出现大量 TLB 未命中,影响性能。
现代处理器通常为 TLB 条目附加一个地址空间标识,用于区分不同进程的条目。进程切换时无需清空 TLB,只需切换当前的地址空间标识,不同进程的条目可以共存于 TLB 中。
TLB 一致性
当操作系统修改了某个页表项,例如解除映射、修改权限,就必须确保所有 CPU 核心的 TLB 中不再保留该页的旧条目。由于每个核心都有自己的 TLB,修改页表的核心需要通过处理器间中断通知其他核心刷新相应条目,这一操作称为 TLB 击落(TLB Shootdown)。
在核心数众多的系统上,TLB 击落的开销相当可观。频繁地映射和解除映射内存,例如大量调用内存映射和解除映射的系统调用,可能因此成为性能瓶颈。
六、缺页异常
当程序访问的虚拟地址在页表中没有有效映射,或访问方式违反了页的权限设置时,MMU 会触发一个缺页异常(Page Fault),处理器暂停当前指令,转入操作系统内核的缺页处理程序。
缺页异常并不一定意味着错误。事实上,它是虚拟内存实现许多重要功能的核心机制。根据情况不同,缺页可以分为以下几类:
次要缺页(Minor Fault):所需的数据已经在物理内存中,只是尚未建立映射。例如,页面已经存在于页缓存中,或者是一个首次访问的匿名页,只需分配一个清零的物理页。内核建立映射后即可返回,开销较小,通常在微秒级。
主要缺页(Major Fault):所需的数据不在物理内存中,需要从磁盘读取。例如,页面曾被换出到交换空间,或者访问的是一个尚未被读入的文件映射区域。这需要发起磁盘 I/O,进程被挂起等待,开销可能达到毫秒级,比次要缺页高出数个数量级。
非法访问:访问的地址根本不属于进程合法的地址空间,或者违反了权限,例如写入只读页、执行不可执行的页。此时内核会向进程发送信号,通常导致进程以段错误终止。
内核通过检查进程的虚拟内存区域描述来区分这些情况。每个进程的地址空间被划分为若干区域,例如代码段、数据段、堆、栈、各个文件映射区域,内核记录了每个区域的起止地址、权限和对应的后备存储。缺页时,内核据此判断该访问是否合法,以及应当如何获取数据。
七、按需分页与延迟分配
虚拟内存的一个重要特性是按需分页(Demand Paging):页面只有在被实际访问时,才会分配物理内存或从磁盘加载。
程序加载
当执行一个程序时,内核并不会把整个可执行文件读入内存,而是只建立虚拟内存区域与文件之间的映射关系。程序开始运行后,访问到哪一页,才通过缺页异常把哪一页从文件中加载进来。从未被执行的代码,永远不会占用物理内存。
这使得程序启动更快,内存占用也更少。一个体积庞大的程序,如果只使用了其中一小部分功能,实际占用的物理内存可能远小于其文件大小。
内存分配
当程序申请一大块内存时,内核通常只在进程的地址空间中预留一段虚拟地址区域,并不立即分配物理内存。只有当程序第一次写入某一页时,才会触发缺页,由内核分配一个物理页并清零。
这就是为什么一个进程的虚拟内存大小往往远大于其常驻内存大小:前者是已预留的地址空间总量,后者是实际占用的物理内存。
内存超额分配
由于申请内存时并不立即分配物理页,操作系统可以允许所有进程申请的内存总量超过物理内存与交换空间之和,这称为内存超额分配(Overcommit)。
这一策略基于一个观察:许多程序申请的内存并不会全部使用。超额分配提高了内存的利用率,但也带来了风险:如果进程真的都去使用自己申请的内存,物理内存就会耗尽。此时,内核会启动内存溢出终止机制,按照一定的评分规则选择一个或多个进程将其强制终止,以释放内存。
Linux 允许通过配置调整超额分配的策略,从启发式的宽松允许,到严格禁止超额分配。
八、写时复制
写时复制(Copy-on-Write)是虚拟内存支持的另一项重要优化,最典型的应用是进程创建。
在类 Unix 系统中,创建新进程的标准方式是复制当前进程,生成一个子进程,子进程拥有与父进程相同的地址空间内容。如果真的复制父进程的全部内存,对于一个占用数 GB 内存的进程而言,创建子进程的代价将非常高昂。更何况,许多子进程在创建后会立即加载执行一个新的程序,复制的内容马上就被丢弃了。
写时复制的做法是:
- 创建子进程时,不复制任何物理页,而是让子进程的页表指向与父进程相同的物理页;
- 将父子进程中所有可写页面的页表项都标记为只读;
- 当父进程或子进程试图写入某一页时,触发缺页异常;
- 内核此时才为写入方分配一个新的物理页,复制原页面内容,更新写入方的页表使其指向新页,并恢复为可写;
- 如果某个物理页只剩下一个进程引用,则直接恢复可写,无需复制。
这样,只有被实际修改的页面才会被复制。子进程如果立即加载新程序,几乎不需要复制任何内存。
写时复制还被广泛用于其他场景,例如一些数据库利用创建子进程的方式生成内存快照:子进程看到的是创建时刻的一致内存视图,父进程可以继续处理写入请求,被修改的页面才会被复制。
九、内存映射文件
内存映射(Memory Mapping)允许将一个文件的内容映射到进程的虚拟地址空间中。映射建立后,进程可以像访问普通内存一样,通过指针直接读写文件内容。
工作方式
建立映射时,内核只记录该虚拟区域与文件的对应关系,不读取任何数据。当进程访问某一页时,触发缺页,内核从文件中读取对应的数据到页缓存中,并将该物理页直接映射到进程的地址空间。
对于共享映射,进程对映射区域的修改会直接写入页缓存中的物理页,内核会在适当的时机将修改写回文件。多个进程映射同一个文件时,它们共享同一组物理页,一个进程的修改其他进程立即可见。
对于私有映射,修改采用写时复制,不会影响文件和其他进程。
优势
- 减少数据复制:传统的文件读取需要将数据从页缓存复制到用户缓冲区,内存映射直接访问页缓存中的数据,省去了这次复制;
- 简化编程:对文件的随机访问可以直接通过指针完成,无需反复调用定位和读取函数;
- 共享内存:多个进程映射同一文件,可以实现高效的进程间数据共享。动态链接库就是通过这种方式被多个进程共享的,物理内存中只需保留一份代码。
局限
内存映射也并非总是更优:
- 访问未加载的页面会触发缺页异常,其开销可能高于一次系统调用;
- I/O 错误以信号的形式报告,而不是返回错误码,处理起来更加困难;
- 何时将修改写回磁盘由内核决定,应用程序对写入时机和顺序的控制能力有限,这对需要严格保证持久化顺序的数据库等系统可能带来问题;
- 频繁地建立和解除映射,会带来页表修改和 TLB 击落的开销。
因此,是否使用内存映射需要根据具体的访问模式权衡。
十、页面置换与交换
当物理内存不足时,操作系统需要将一些页面从内存中移出,为新的页面腾出空间,这称为页面置换。
可回收的页面类型
内存中的页面大致可以分为两类:
- 文件页:页缓存中的文件数据,以及可执行文件和动态库的代码。如果页面未被修改,可以直接丢弃,需要时从文件重新读取;如果已被修改,则需要先写回文件。
- 匿名页:堆、栈等没有对应文件的内存。它们没有可以重新读取的来源,要回收它们,必须先写入磁盘上的交换空间(Swap),需要时再读回。
置换算法
理想的置换策略是淘汰将来最长时间不会被访问的页面,但这需要预知未来,无法实现。实际系统采用各种近似算法,核心思想都是:最近被访问过的页面,近期很可能再次被访问。
时钟算法是一种经典的近似 LRU 算法。所有页面排成一个环,一个指针沿环移动。检查一个页面时,如果其访问位为 1,说明最近被访问过,将访问位清零后跳过;如果访问位为 0,则选择该页面淘汰。访问位由硬件在页面被访问时自动设置,算法开销很小。
Linux 使用活跃链表和非活跃链表来组织页面。新访问的页面首先进入非活跃链表,再次被访问时才晋升到活跃链表。回收时优先从非活跃链表中选择页面,活跃链表中长时间未被访问的页面会被降级到非活跃链表。这种两级结构可以防止一次性的大量顺序访问(例如扫描一个大文件)将真正频繁使用的页面挤出内存。新版本的内核还引入了更多代际的页面分类,以更精确地估计页面的冷热程度。
抖动
当系统中所有活跃进程的工作集(即近期频繁访问的页面集合)总和超过物理内存时,就会出现抖动(Thrashing):进程刚把需要的页面换入,马上又因为其他进程的需要而被换出,绝大部分时间都消耗在页面换入换出上,实际有效的工作几乎停滞,系统响应变得极其缓慢。
解决抖动的根本办法是减少同时运行的进程数量,或增加物理内存。在生产环境中,许多服务器选择禁用或限制交换空间,宁可让内存溢出终止机制快速终止进程,也不愿让系统陷入长时间的抖动状态。也有一些系统使用基于内存压缩的交换方式,将要换出的页面压缩后保存在内存中的一块区域,以较小的 CPU 代价避免磁盘 I/O。
十一、大页
标准的 4KB 页面在某些场景下会成为性能瓶颈。对于使用大量内存的应用,例如数据库、虚拟机、大规模数据处理程序,数十 GB 的内存包含数百万个页面:
- TLB 只能覆盖其中极小一部分,TLB 未命中频繁发生,每次未命中都需要遍历多级页表;
- 页表本身也占用大量内存。
为此,处理器支持大页(Huge Page),常见的大小为 2MB 和 1GB。一个 2MB 的大页相当于 512 个普通页,只需要一个 TLB 条目即可覆盖,页表遍历的层级也减少一级。使用大页可以显著提高 TLB 的覆盖范围,减少地址转换的开销。
Linux 提供了两种使用大页的方式:
- 预留大页:系统启动时或运行时预先保留一定数量的大页,应用程序通过特定的方式显式申请。这种方式性能稳定,但需要提前规划,预留的内存不能用于其他用途;
- 透明大页(Transparent Huge Pages):内核自动尝试为进程使用大页,对应用程序透明。当进程的某段内存区域对齐且连续时,内核会分配或合并为大页。
透明大页使用方便,但也存在一些问题:为了获得连续的物理内存,内核可能需要进行内存整理,这会带来延迟抖动;大页的写时复制需要复制整个 2MB 的页面;对于内存访问稀疏的应用,大页可能导致内存浪费。因此,部分数据库和对延迟敏感的系统建议关闭透明大页,或将其设置为仅在应用程序明确请求时才使用。
十二、NUMA 与内存访问
在多处理器服务器上,内存通常不是均匀访问的。每个处理器插槽都有自己直接连接的本地内存,访问本地内存的延迟较低,访问其他插槽的远程内存则需要经过处理器间的互连,延迟更高、带宽更低。这种架构称为非一致内存访问(NUMA)。
虚拟内存系统在分配物理页时,需要考虑 NUMA 拓扑。Linux 默认采用首次访问策略:一个页面在哪个节点上被首次访问,就从哪个节点分配物理内存。这在大多数情况下能让线程使用本地内存。
但如果一个线程在一个节点上初始化了大量数据,随后由其他节点上的线程使用,就会产生大量的远程访问。内核提供了自动 NUMA 均衡功能,能够检测页面的实际访问来源,并将页面迁移到访问它的节点上。应用程序也可以通过相关接口和工具,显式控制进程的内存分配策略和 CPU 绑定。
十三、对程序性能的影响
理解虚拟内存机制,有助于解释和优化程序的许多性能现象。
首次访问的开销。 新分配的大块内存在首次写入时会触发大量缺页,因此程序初始化阶段或首次处理请求时可能较慢。对延迟敏感的应用,可以在启动时主动访问一遍需要的内存(预热),或使用锁定内存的接口,确保关键内存常驻且已建立映射。
内存访问局部性。 访问模式越集中,TLB 命中率越高,页面被换出的可能性越低。随机访问分散在巨大地址范围中的数据结构,不仅会导致 CPU 缓存未命中,也会导致 TLB 未命中。
进程创建的成本。 虽然写时复制使得创建子进程本身很快,但对于拥有巨大地址空间的进程,复制页表本身也需要时间,并且之后父子进程的写入会触发大量缺页复制。因此,一些使用子进程生成快照的系统,在内存很大且写入频繁时,可能观察到明显的延迟和内存增长。
内存释放的时机。 程序释放的内存,内存分配器并不一定会立即归还给操作系统,而可能保留以供后续分配。因此进程的常驻内存在释放后可能并不下降。这是分配器的设计选择,而非内存泄漏。
常驻内存与虚拟内存的区分。 分析进程内存占用时,应当关注常驻内存,而非虚拟内存大小。同时还需注意,常驻内存中可能包含与其他进程共享的页面,例如共享库和共享映射,简单累加各进程的常驻内存会高估实际的内存使用量。
结语
虚拟内存是操作系统中最重要的抽象之一。它通过一层由硬件和软件共同维护的地址转换,为每个进程提供了独立、连续、受保护的地址空间,同时让物理内存得以被灵活、高效地共享和复用。
在这一抽象之上,按需分页、写时复制、内存映射、页面置换等机制,让程序启动更快、进程创建更廉价、文件访问更便捷、内存利用率更高。而 TLB、多级页表、大页等技术,则在保证这些功能的同时,尽可能降低地址转换带来的性能代价。
对于大多数程序而言,虚拟内存是透明的,无需关心。但当遇到内存占用异常、延迟抖动、进程被意外终止、性能无法解释等问题时,理解虚拟内存的工作原理,往往是找到答案的关键。
- 点赞
- 收藏
- 关注作者
评论(0)