虚拟内存是计算机领域中一个很重要的概念,不管是对于日常工作排查问题,还是技术面试,出现的频率都很高。之前对这块内容的理解一直只存在于头脑里,现在把它拿出来,形成书面记录。

需要说明下,虚拟内存有两种理解:一种是操作系统把硬盘的一部分当内存使用,以达到扩大内存的目的,大学或者很多入门级电脑书籍里都这么说,这其实是不全面的,或者只是表象。另一种理解是计算机系统中对物理内存的一种抽象,这种理解更底层,更深层次,或者说本质的东西,本文介绍的是第二种。

内存结构

我们知道,冯·诺依曼结构的计算机中,任何一个程序想要运行,必须首先加载进内存,计算机的内存可以看成是一个连续的线性空间,同数组一样,也有下标,称之为内存地址,或物理地址。内存地址从 0 到 $N$,表示系统可以寻址的范围,例如,一个 4GB 的内存,内存地址范围为 $[0-2^{32}]$,也把这个范围称之为地址空间。程序只有加载到内存中,才能被 CPU 执行,如下图是一个简易的内存示例,每个格子代表一个内存地址,有 A、B 两个程序被加载到内存,A 占用了 [0-7] 这段内存区域,B 占用了 [17-28] 这段内存区域。其他空白的区域,可以被其他程序所使用。

没有虚拟内存的年代

早期的计算机系统,程序可以直接访问整个内存,就如上图所示,程序员必须清楚的知道如何使用和管理主存,这是一个很麻烦的工作,程序员得确保他的程序在加载时,不能超过可用内存。当计算机发展成多任务系统时,即加载多个程序到内存,他们必须考虑以下问题:

  • 内存布局:多个程序如何共享主存的内存空间?例如上图, A,B 两个程序,如何保证它们在内存能“和平共存”?程序员必须知道 A 占用了哪些内存地址,B 占用了哪些内存地址,避免它们地址冲突、覆盖的情况。
  • 内存碎片:随着多个程序的加载,内存开始出现一些碎片,如上图,A,B 两个程序中间的地址是空的,假如 [10-15] 这段内存又加载了一个程序 C,那么 [8-9],[16] 地址剩下来,现在程序 D 想要一段大小为 5 的内存,但已经没有合适的地址分配给它了。这样 [8-9], [16] 就成了内存碎片。随着内存变大,程序加载数增多,碎片也变得越来越多,这是内存的极大浪费。
  • 安全:由于程序可以访问整个内存空间,A 程序会不会有意或者无意读取 B 程序的数据?例如密码等一些敏感的信息?无论如何,这都是无法容忍的。

可以看到,早期编程是很痛苦的,假想下,如果有一个自动的内存分配和管理机制,让程序员不用操心内存布局、碎片、安全等问题,那一定是极好的。于是,虚拟内存应运而生。

虚拟内存

在虚拟内存体系下,一个进程不能直接访问物理内存,而只能访问虚拟内存,它看到的是一个被称为虚拟地址空间的东西,进程只和虚拟地址打交道,然后由操作系统和硬件通过某种方式把虚拟地址转为实际的物理地址。这样,进程只需读写虚拟内存地址即可,根本不用管从虚拟地址到物理地址的转换,如下图所示。注意到,进程 2 使用的物理内存实际可以是不连续的。

虚拟内存的好处

从上图的例子中,可以得出虚拟内存至少有以下好处:

  • 每个程序都有一个从 0 开始的,独立的虚拟地址空间,这样程序员就不用处理内存偏移这些琐碎的事情了,极大降低了编程的工作难度。
  • 虚拟内存总是连续的,尽管实际映射的物理内存可能不连续。操作系统会把底层那些可用的、碎片化的内存整合成一个统一的虚拟内存地址,这就充分利用了碎片化的物理内存。
  • 每个程序看到的虚拟地址空间大小是一致的,近似于无限,这样程序员就不用担心自己的程序太大而无法加载进内存。
  • 虚拟内存保证了安全性,程序 A 和程序 B 的虚拟地址空间是独立的,它们无法访问到对方的地址,即使通过某种恶意手段访问到,也能被操作系统检测到并触发异常。同时,进程对自己虚拟地址空间的操作也不是为所欲为,如果对一个只读的地址写操作,也会引发操作系统异常,极大的保证了内存读写的安全性。

页表

上面我们也说到,虚拟地址最终会转换成物理地址,即给定一个虚拟地址 X,系统能把他转换成物理地址 Y。因此系统得保存虚拟地址到物理地址的映射关系。很容易想到维护一个 1 : 1 的映射表,即虚拟地址和物理地址一一对应,然而实际不可行,保存这样的一个映射表这需要占用大量的存储空间。

解决办法是通过把虚拟内存和物理内存分成连续、固定大小的块。虚拟内存中,我们把块称之为虚拟页(Virtual Page),物理内存中,我们把块称之为物理页(Physical Page,有的地方也称之为帧,Frame)。绝大部分情况下,页的大小是 4KB,寻址时,以页为单位进行操作。同时,处理器上有个称为 MMU(Memory Management Unit)的部件,它的功能是将虚拟地址转换为物理地址,虚拟页和物理页间的映射关系保存在一个被称为页表的数据结构中,每个进程都有自己的页表,页表存放在内存,由操作系统管理。下图展示了这种映射关系,虚拟内存和物理内存中每个格子表示一个页。当应用程序读写一个虚拟地址时,MMU 会计算它的虚拟页索引,并在页表中找这个虚拟页对应的物理页,一旦找到,就返回给应用程序,这样就完成了一次实际的内存读写。

缺页异常

上图 3 中,可以看到有的虚拟页并没有映射到物理页(编号6、7的虚拟页),当应用程序访问这种还没有映射的页时,找不到对应的物理页,这种情况被称为缺页(Page Missing),缺页就会触发一个缺页异常(Page Fault),MMU 会检测到缺页异常,并把控制权交给操作系统,操作系统会执行缺页异常处理程序,它会尽可能为这个虚拟页映射一个可用的物理页,如果找不到,就牺牲一个已经映射的物理页,把它回写到硬盘,然后把该页映射到新的虚拟页。当缺页异常处理程序返回时,它会重新执行导致缺页的指令,该指令把导致缺页的虚拟地址重新发给 MMU,由于现在页表已经有了映射关系,因此不会再引发缺页了。

页面调度

上述的这种在硬盘和内存交换页的行为,称为页面调度(Paging),或者页交换(Swapping)。页面调度有可能发生在缺页异常时,也可能发生在其他场景。由于内存总是稀缺资源,当一个应用程序暂时不活动,或者某些映射的物理页暂时未使用到,操作系统会把它先保存到硬盘里,等需要的时候,再从硬盘换到内存。这实际就提供了一种“部分加载”或“懒加载”的机制——直到应用需要某些数据时,才从硬盘中加载,否则先不加载。

页面调度对应用程序是透明的,应用程序不用考虑内存是否足够能加载它,正因为如此,在应用程序看来,内存是无限的,所以,在一个 4G 内存的机器上,可以运行需要 5G 或更多内存的程序。

但凡事都有两面性,换页也有负面作用。我们知道硬盘的速度远慢于内存(10 万倍的数量级差距)。因此换页时,操作系统把数据从硬盘拷贝到内存是一个很耗时的工作,表现就是“电脑突然卡了下”。当系统运行的程序过多,缺页异常频繁发生,系统不停的进行页面调度工作,换页操作花费的时间甚至比运行程序本身还要多,这时系统表现就是卡顿,这种情况称为系统颠簸(Thrashing)。处理方式就是对症下药:要么加大内存,或者关掉一些程序。

内存保护

虚拟内存机制也很好的解决了进程的安全问题,有了 MMU 和页表,通过给页表加一些标志,可以实现一个进程只能访问属于它自己的虚拟内存,以及控制页的读写权限。任何试图访问一个不属于进程自己的的内存地址,或者对一个只读的内存地址进行写操作,都会被操作系统检测到,并抛出一个错误,Unix 系统中叫段错误(Segmentation Fault),Windows 系统中叫非法访问(Access Violation),通常此时操作系统会中止或杀掉进程。

虚拟内存还有很多其他的使用方式,例如内存映射文件。读写一个文件时,一般我们会通过 read/write 等系统调用的方式,把文件拷贝到内存。但如果使用内存映射的方式,可以省掉拷贝的操作,直接访问文件,就好像它已经被加载到了内存。当真正需要读写时,虚拟内存会保证把必要的数据从硬盘搬到内存,这种方式可以显著提高文件访问效率。

例如还有共享物理内存,当多个进程需要使用某个数据时,可以在物理内存中只放一份,然后在把各自的虚拟页指向它,这样就能达到共享内存的目的。典型的如 fork 系统调用创建子进程,实际就只是复制了一份父进程的页表,再配合写时复制机制,因此效率是很高的。

总结

虚拟内存是对内存的一种抽象,使用虚拟内存时,CPU 寻址方式是虚拟寻址,即将虚拟地址转换为物理地址,这需要 MMU 硬件和操作系统的密切配合完成。虚拟内存系统简化了内存管理、链接、加载、代码和数据的共享以及访问权限的保护,是计算机系统中最重要的概念之一。理解了虚拟内存原理,可以帮助我们写出安全、高效的程序,对排查系统问题也大有裨益。也就理解了“把一部分硬盘当内存使用”只是操作系统实现虚拟内存的一个方式而已。

参考