内存管理

136 阅读17分钟

内存是计算机系统的重要资源,在具体学习内存管理机制之前,我们先来思考这样一个问题:当多个应用同时运行时,操作系统如何让他们共同使用物理内存资源呢?>

  1. 一种简单的方法是:当应用程序A运行时,允许他访问所有的内存资源;再切换到内一个应用程序B运行的过程中,操作系统将应用程序A所有的内存数据保存到磁盘中,然后将应用程序B中的数据从存储设备加载到内存中。但是这种方法存在明显的弊端,由于读写存储设备的速度很慢,这将导致切换程序的时间开销太大。
  2. 另一种简单的办法是:让每个应用程序独立使用物理内存的一部分,数据一直驻留在内存中,在程序切换时不再需要操作存储设备。该方法在性能上优于前一种方法,但是也存在两个严重的弊端:第一,无法保证不同应用程序所使用的物理内存之间的隔离性,比如应用程序A在运行过程中可能意外的写了应用程序B的物理内存,进而导致后者运行错误。第二无法保证应用程序所用地址空间是连续的和统一的,增加了程序编写和编译的复杂性。
  3. 那么操作系统是如何让不同的应用程序高效、安全的共同使用物理内存资源了?现代操作系统一个普遍做法是在应用程序与物理内存间加入一个新的抽象:虚拟内存。应用程序是面向虚拟内存编写而不是面向物理内存编写。应用程序在运行时只能使用虚拟地址,CPU负责将虚拟地址翻译成物理地址,操作系统负责物理地址与虚拟地址之间的映射。操作系统仅仅将应用程序所使用的虚拟地址映射到物理地址,从而提高内存资源的利用率;每个应用程序只能看到自己的虚拟地址内存空间,从而保证不同应用程序所用内存之间的隔离。每个应用程序的虚拟地址空间是统一的,连续的,降低了编程的复杂性。

虚拟内存的设计具有以下三个目标:

  • 高效性,一方面虚拟内存在应用程序运行中不能造成明显的性能开销;另一方面,虚拟内存抽象不应该占用过多的存储资源,导致物理内存的有效利用率明显降低。
  • 安全性,虚拟内存抽象需要不同应用程序的内存相互隔离;即一个应用只能访问属于自己的物理内存区域。
  • 透明性,虚拟内存抽象需要考虑对于应用程序的透明性,使得应用程序开发者在编程时无需考虑虚拟内存抽象。

虚拟内存地址与物理内存地址

如图1所示,逻辑上,我们可以把物理内存看成一个大数组,其中每个字节都可以通过与之唯一对应的地址进行访问,这个地址就是物理地址。在应用程序或操作系统运行过程中,CPU通过总线发送访问物理地址的请求,从内存中读取数据或者像内存中写入数据。

image.png

图1 CPU地址翻译示意图

在引入虚拟内存的抽象后,应用程序使用虚拟地址访问存储在内存中的数据和代码。在程序执行过程中,CPU会把虚拟地址换成物理地址,然后通过后者访问物理内存。虚拟地址转换成物理地址的过程,通常被称为地址翻译。

CPU中重要部件,内存管理单元,负责虚拟地址到物理地址的转换。如图1所示,程序在CPU核心上运行期间,它使用的虚拟地址都会由MMU进行翻译。当需要访问物理内存设备时,MMU翻译出的物理地址就会通过总线传到相应的物理内存设备,从而完成相应的物理内存读写请求。

以运行Hello World程序的第一条指令为例:操作系统首先把程序从磁盘上加载到物理内存中,然后让CPU执行程序的第一条指令,但是此时指令存放在内存中。在使用虚拟内存的情况下,CPU取指令时发出的是指令的虚拟地址,该虚拟地址被MMU翻译成对应的物理地址,包含物理地址的内存读请求通过总线发送到物理内存设备,然后物理内存设备把物理地址对应的内容发送给CPU。

为了加速地址翻译的过程,CPU引入了转址旁路缓存(Translation Lookaside Buffer, TLB)。TLB是属于MMU内部的单元。

分段与分页机制

MMU将虚拟地址映射为物理地址的方式主要有两种:分段机制和分页机制。在分段机制下,操作系统以“段”(一段连续的物理内存)的形式管理和分配物理内存。应用程序的虚拟空间由若干个不同大小的段组成,如代码段、数据段等。当CPU访问虚拟地址空间的某一个段时,MMU会查询段表得到该段对应的物理内存区域,如图2所示。具体来说,虚拟地址分为两个部分,第一个部分表示段号,表示着该虚拟地址属于整个虚拟地址空间的哪一个段,第二个是段内偏移,即相对于该段的起始地址的偏移量。段表储存着一个虚拟地址空间的每一个分段的信息,其中包括段起始地址和段长。在翻译虚拟地址的过程中,MMU首先通过段表基址寄存器找到段表的位置,结合待翻译虚拟地址的段号,可以在段表中定位到对应段的信息,然后取出该段的起始地址,再加上待翻译地址中的段内地址,就能得到最终的物理地址。段表中的段长主要用来判断虚拟地址是否合法。

在分段机制下,不仅虚拟地址空间被划分为不同的段,物理内存也以段为单位进行分配。在虚拟地址空间中,相邻的段所对应的物理内存中的段可以不相邻,操作系统可以实现物理内存资源的离散分配。这种分配方式很容易产生外部碎片,即段与段之间存在碎片。

image.png

图2 分段机制下地址翻译规则示意图

另一种机制(即分页机制)被现代操作系统广为采用,如图3所示,分页机制的基本思想是把应用程序的虚拟地址空间分成等长的、连续的虚拟页,同时物理内存也被分割成等长的、连续的物理页。虚拟页和物理页等长且相等,它们之间的映射通过内存管理单元(MMU)进行管理,分页机制下,逻辑地址被划分为两个部分:第一部分表示虚拟地址的虚拟页号,第二部分表示虚拟地址的页内偏移量。 在具体的地址翻译过程中,MMU首先得到虚拟地址的虚拟页号,通过虚拟页号查询应用程序的页表,找到该页号对应的物理地址(起始地址),再通过加上对应的页内偏移量,得到最终的物理地址。

image.png

图3 分页机制下,地址翻译规则示意图。 图中所示页表为单级页表

在分页机制下,操作系统虚拟地址空间上的任意虚拟页被映射到物理内存的任意物理页上,因此操作系统可以实现物理内存资源的离散分配。同时分页机制减少了物理内存的外部碎片问题。

基于分页的虚拟内存

前文提到,页表是分页机制中的关键组成部分,负责记录虚拟页到物理页的映射关系,操作系统负责对页表进行配置。现在我们思考这个问题:如果操作系统按照图3所示,使用一张单级页表表示映射关系,假设每页大小为4KB,页表中的每一项占8字节,对于64位的操作系统而言,一张页表的大小就是(264/4KB)*8字节,换算过后就是33554432GB!

为了压缩页表的大小,操作系统引入了多级页表结构,用来满足操作系统虚拟内存在空间高效性方面的需求。当使用简单的单级页表时,一个虚拟地址被划分为两部分:虚拟页号、页内偏移。当采用多级页表(假设有k级)时,一个虚拟地址中依然包含虚拟页号和页内偏移量,其中虚拟页号被进一步划分为k个部分,对应该虚拟地址在第i级页表的索引。当任意一级页表的某一个条目为空时,他的下一级页表就不需要存在,依次类推,接下来的页表同样不需要存在。因此多级页表的方式极大的减小了页表占用的空间大小。换句话说,多级页表允许整个页表空间存在空洞,而单级页表则需要每一项都存在。并且,在实际使用过程中,应用程序的虚拟地址空间实际上大部分都没有被分配,使用多级页表可以极大地节约所占空间。

那么为什么单级页表中的每一项都需要存在?主要原因在于单级页表可以看成是虚拟地址的虚拟页号作为索引的数组,整个数组的起始地址存储在页表基地址寄存器中。翻译某个虚拟地址即根据其虚拟页号找到对应的数组项,因此需要整个页表在内存中连续,其中没有用到的数组项也需要存在。

AArch64架构下的4级页表

image.png

图4 4级页表的地址翻译

物理内存被划分为连续的、4KB大小的物理页,每个页表项使用8个字节,所以一个页表页包含512个页表项(4KB/8B=512),由于29=512,所以虚拟地址中对应于每一级页表的索引都是9位,具体来说一个64位的操作系统被划分成了以下几个部分:

image.png

图5 虚拟地址在逻辑上的划分策略

当MMU翻译一个虚拟地址的时候,首先根据页表基地址寄存器中的物理地址找第0级页表,然后再根据虚拟地址的L0 index作为页表项索引,读取第0级页表中相应的页表项,得到下一级页表页的基地址。然后再通过L1 index找到下一级页表页的基地址,以此类推,MMU将在L3 index中找到存储具体指令的页表的基地址,再根据页内偏移量可以获得最终的物理地址。

加速地址翻译的重要硬件:TLB

多级页表结构可以显著的压缩页表大小,但是会导致地址翻译的时间增长,为了加快地址翻译的速度,在MMU中引入了TLB,我们称通过TLB能够直接完成地址翻译的过程为TLB命中,反之则为未命中。作为CPU的内部组件,TLB的体积很小,它可以缓存的数据量很少,对于普通电脑来说,每个CPU核心大概只有约1000条左右的TLB缓存项。尽管TLB容量小,但是其任有较高的命中率,主要是由于程序具有空间局部性和时间局部性。

换页与缺页异常

换页机制思想:当物理内存容量不够的时候,操作系统应该把若干物理页的内容写到类似于磁盘这种容量更大更加便宜的存储设备中,然后就可以回收这些物理页供其他程序使用了。

缺页异常:它是和换页机制密不可分的,也是换页机制能够工作的前提,当应用程序访问已分配但未映射至物理内存的虚拟页时,就会发生缺页异常。此时操作系统会运行操作系统预先设置好的缺页异常处理函数,该函数会找到一个空闲的物理页,将之前写到磁盘上的数据内容重新加载到该物理页中,并且在该程序的页表中填写虚拟地址到这一物理页的映射,该过程被称为换入。

按需页分配:当应用程序申请分配内存时,操作系统可选择将新分配的虚拟页标为已分配但未映射至物理内存状态,而不必为这个虚拟页分配对应的物理页。然后当应用程序访问这个虚拟页时,就会触发缺页异常,此时操作系统才真正为这个虚拟页分配对应的物理内存,并且在页表中填入对应的映射。这种按需分配的机制,使得操作系统能在应用程序真正需要使用物理内存的时候再分配物理页,这使得操作系统能够有效地节约物理内存,提高资源的利用率。

image.png

图6 换页机制中的换入与换出

页替换策略

MIN/OPT策略:MIN(Minimum)策略又称为OPT策略(Optimal策略,最优策略),是一种理想化的换页策略。在选择被换出的页时,优先选择未来不会再访问的页,或者在最长时间内不会再访问的页。这个策略是理论最优的页替换策略,在实际场景中很难实现。因为页访问的顺序取决于应用程序。但是它可以作为一个标准来衡量其他替换策略的优劣。 FIFO策略:FIFO是先进先出的策略。其策略直观、开销低,但是在实际使用中往往表现不好,因为页换入换出顺序与使用是否频繁通常没有关联。

Second Chance策略:这个是FIFO的改进版本,为每个物理页号维护一个访问标志位,如果访问的页号已经在队列中,则为其置上标志位。在寻找要换出的页时,优先寻找队头的页号,如果它的标志位没有被置上,就换出,否则将其标志位清零,放到队尾。那么最坏情况下(初始时全部页都已经被访问过),它会暂时退化为FIFO,把队头的页面换出。

Belady异常:通常来说,系统分配给一个应用程序的物理页数量越多,换页次数越少。但是有的时候更多的可用物理内存会导致更多的换页、更低的性能,这就是Belady异常。可能在操作系统采用FIFO或者Second Chance等页替换策略时发生。

LRU策略:LRU(Least Recently Used)策略在选择被换出的页时,优先选择最久未被访问的页。该策略的出发点在于:过去数条指令很可能在后续的数条指令种被频繁访问。

MRU策略:MRU(Most Recently Used)策略在替换内存页时,优先换出最近访问的内存页。该策略基于的假设是:程序不会反复的访问相同的地址,如视频播放器。

时钟算法策略:时钟算法是将换入的物理内存页号排成时钟的形状,时钟有一个针臂,指向新换入内存的页号的后一个。同时也为每个页号维护一个访问标志位。每次选择要换出的页号时,从针臂开始检查。如果当前页号的访问位没有设置,则将其替换。如果已经被置上访问位,那就将其清空,并且针臂移动到下一个页号。这个与Second Chance有相似之处,但是在确实还是有区别的。比如插入的位置不同、不需要将页号移到队尾。

工作集模型:如果选择的内存替换策略与实际的工作负载不匹配,就可能导致颠簸(thrashing)现象,造成严重的性能损失。工作集模型能有效地避免颠簸现象的发生。工作集是“一个程序在时间t的工作集W为它在时间区间[t-x,t]使用的内存页集合,也被视为它在未来(下一段x时间内)会访问的内存页集合”。该模型认为应当将应用程序的工作集同时保持在物理内存中,优先将非工作集中的页换出。

共享内存,写时拷贝,内存去重,内存压缩

共享内存就是使得多个进程可以访问同一块内存空间,即不同进程的虚拟地址所映射的物理地址是相同的,这样这块区域的数据就可以被不同的进程所共享,如图7所示。

image.png

图7 共享内存

写时拷贝:考虑这样一个场景,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,操作系统引入了“写时拷贝“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

image.png

图8 以写时拷贝共享内存

写时复制的原理大概如下:创建子进程时,将父进程的 虚拟内存与物理内存映射关系复制到子进程中,并将内存设置为只读(设置为只读是为了当对内存进行写操作时触发缺页异常)。当子进程或者父进程对内存数据进行修改时,便会触发写时拷贝机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写。

内存去重:写时复制技术另外一个应用就是对内存数据去重工作,内存去重是针对操作系统内存的多个程序进程内存数据去重,工作原理就是操作系统会有一个定时器会定时对操作系统上的进程进行扫描,查看内存是否有数据重复的程序进程,如果有进程的内存重复,那么这些程序重复的内存就被合并到一个内存页上,这样就能达到节省内存空间的效果,这个在Linux系统上有应用叫KSM技术。

内存压缩:这个在Linux内核中也有应用叫zswap,这是一种内存数据页压缩技术,zswap中提供一个叫作内存页压缩区域,当有需要换出的内存数据页的,操作系统会将这些内存数据页数据压缩到这个zswap空间中,然后批量输入到硬盘中,从而实现更高效的io操作,避免频繁的磁盘io操作;