内存(RAM)是计算机中需要认真管理的重要资源,虽然现在的内存动辄几十上百G,但是不管多大的存储器,程序都可以将他填满。经过多年的探索人们提出了分层存储器体系(Memory Hireachy)的概念。在这个体系中,计算机拥有如果兆(MB)的快速、昂贵且易失性的高速缓存(cache),数千兆(GB)的速度与价格适中的且具有易失性的内存,以及几千吉(TB)的低速、廉价、非易失性的磁盘存储,另外还有诸如USB,DVD等可移动存储装置。操作系统中管理分层存储器体系的部分称之为存储管理器(Memory Manager)。它的任务是有效的管理内存,即记录那些内存正在被使用,那些内存区域处于空闲的;在进程需要内存时为其分配内存,再进程结束时释放内存。下面我们将展开对内存的管理的介绍,管理好了内存能使我们的机器性能得到提升。
存储器抽象:地址空间
地址空间
地址空间是一个进程可用于寻址内存的一套地址集合,每个进程都有自己的地址空间,并且这个进程独立于其他进程的地址空间(除非一些特许情况下进程之间需要共享地址空间)。地址空间的概念非常通用,例如IPv4地址是32位的数字,因此它的地址空间是0-2^32-1(也有一些保留)。地址空间也可以不是数字的,以“.com”结尾的网络域名的集合也是地址空间,地址空间可以用于表示对存储器的存储空间的抽象。比较难的问题在于,每个进程都有自己的地址空间,如何使得一个程序的地址空间中的28对应的物理地址和另一个程序的地址空间中的28对应的物理地址不同。下面我们将讨论这个问题。
-
基址寄存器和界限寄存器
最简单的方法就是使用动态重定位,简单地把每个进程的地址空间映射到物理内存的不同部分。使用的经典方法是为每个CPU配置两个特殊的硬件寄存器,通常叫做基址寄存器和界限寄存器。当使用基址寄存器和界限寄存器的使用,程序装载到内存中连续的空闲位置且装载期间无需重定位。当一个进程运行时,程序在内存中的起始地址装入到CPU的基址寄存器中,程序的长度装载到CPU的界限寄存器中。例如一个进程的起始地址是16384KB,程序大小为4096KB,那么相应的基址寄存器的置为16384,界限寄存器的置为20480。每次一个进程访问内存,取一条指令,读或写一个内存字,CPU在把地址发送到内存总线前,自动的把进程的起始值加到进程发出的地址值上,同时,它检查程序要访问的内存地址是否大于等于界限寄存器的值。如果访问超出了界限,会产生错误并终止访问。例如该进程发出JMP 20指令,硬件会把这条指令发送给内存总线前翻译成 JMP 16404。
使用基址寄存器和界限寄存器给每个进程提供使用地址空间的非常容易的方法。但使用基址寄存器和界限寄存器重定位的缺点:每次进程进行内存访问时都需要进行加法和比较运算。比较运算可以做的很快,但是加法运算由于进位传递时间的问题,在没有特殊电路的情况下会显得很慢。
如果计算机物理内存足够大,那么刚刚提到的基址寄存器和界限寄存器重定位的方法或多或少是可行的的。但是实际上进程对于RAM的需求总量远远超出力物理内存的能够支持的范围。因此如果要把进程一直放在内存中需要很大的内存,如果内存不够,就做不到这一点。两种用于处理内存超载的方法是交换技术(swapping)和虚拟内存(virtual memory),下面我们讨论它们。
-
交换技术
交换技术:把进程完整的调入到内存,让进程运行一段时间,然后把它整个换回到磁盘。空闲进程主要存储在磁盘上,所以当它们不允许的时候就不会占用内存。如下是交换技术的操作。
交换会在内存中参数很多的空闲区(hole,也称空洞),可以通常可以把所有的进程向一个方向移动来使得所有的空闲区合成一个大的空闲区,这种技术称之为内存压缩(memory compaction)。但是通常不这么做,因为这回浪费很多的CPU时间。
对于上图的情况,如果进程的数据段可以增长,例如某个进程随着越来越多的用户操作产生了很多数据,就会产生很多问题,因为此时需要为进程在分配更多的内存用于存数据。如果与进程相邻的是空闲区,那么可以直接将空闲区分配给进程用于存储增长的数据。另一方面,如果一个需要更多内存来存储数据的进程的相邻位置也是进程,那么就可以采用把相邻的进程复制到其他位置的方法来为进程提供额外的空闲区用于存储数据。如果一个进程在内存中无法增长,并且磁盘的交换区已满,那么该进程就会被挂起,直到有空间空闲(或者结束该进程)。例如上图b中的进程A想要更多的空闲区用于存储增长的数据,那就需要移动进程B的位置来留给一定的空闲区给进程A。如果大部分进程的数据区都是可增长的,那么在创建进程或者将进程换入时可以考虑为进程分配一些额外的空间。然而,在进程被换出时只需要换出有数据的部分,没有数据的部分不需要换出。
-
空闲区管理
在动态分配内存时,操作系统必须清楚当前内存中区域被使用了,那些区域是空闲的。基本上有两种方法可用于管理内存的空闲区域:位图和空闲区链表。
- 使用位图的存储管理
在使用位图时,我们将内存划分为多个分配单元,分配单元大小在几字节到几千字节之间。每个空闲区对应位图中的一位,0代表空闲,1代表占用(或者相反)。分配单元的大小是一个重要的因素,分配单元越小,位图越大。然而只要确定了内存的大小和分配单元的大小,就可以用固定大小的内存区域记录整个内存的使用情况。下图中(b)表示使用位图记录(a)中进程和空闲区。使用位图的缺点:将一个需要k个连续的分配单元的进程换入到内存时,存储器必须搜索位图,在位图中找到连续k个0的串,但查找k个连续的0串是耗时的操作。
- 使用链表进行存储管理
另一种记录内存使用情况的方法是,维护一个链表,链表记录了已分配了的内存和空闲区内存的信息。链表中的节点要么是进程,要么是空闲区。链表段是按照地址排序的,其好处是当进程终止时或者被换出时进程的更新十分直接。P表示进程,H表示空闲区。通常对于会使用双链表来记录内存的使用情况,因为再进程释放内存时如果需要和它的前后节点融合时效率较高。上图中(c)表示用链表进行存储器管理。
当按照地址顺序在链表中存放进程和空闲区时的进程),有几种算法可以用来为新创建的进程(或者刚从交换区域换入到内存 的进程)分配内存。
最简单的首次适配算法(first fit)。存储管理器沿着链表进行搜索,直到找到足够大的空闲区(空闲区大小大于等于进程需要的内存)。如果空闲区大小和进程需要的内存大小一样,那么就将全部空闲区分配给进程。如果空闲区更大,则将空闲区分给进程和一个小的空闲区。首次适配算法是一种速度很快的算法,因为它尽可能少的搜索链表节点。
对于首次适配算法的小的改动的算法是下次适配算法(next fit)。它的工作方式和首次适配算法相同,不同之处在每次找到匹配的空闲区,记录空闲区的位置,然后下一次从上一次匹配的位置开始往下搜索合适的空闲区而不是从头开始。Bays(1977)的仿真程序证明下次适配算法的性能略低于首次适配算法。
另一个广泛使用的算法是最佳适配算法(best fit)。最佳适配算法搜索整个链表(从头开始到结束),找出能容纳进程的最小空闲区。最佳适配算法试图找出最接近实际需求的空闲区,以最好的匹配请求和可用的空闲区,而不是拆分一个以后可能用到的更大空闲区。让人意外的是,最佳适配算法比首次适配算法或下次适配算法浪费更多的内存,因为这种算法会在进程之间产生大量无用的的小的空闲区。不可用的空闲由于最佳适配算法产生了很多小的区,为了避免这个问题,可用考虑最差适配算法(worst fit)。即总是分配最大的空闲区,使新的空闲区依然较大而可以继续使用。但实验表明这种算法不是一个好的主意。
如果为空闲区和进程各自维护链表,如上的四种链表都会有性能上的提升。这样在分配内存时只需要将精力放在空闲区链表上,但这种分配速度的提高带来的是增加复杂度和内存释放变慢,因为必须将一个回收的段从进程链表中删除并加入到空闲区链表,并且有时还需要进行空闲区的合并。如果进程和空闲区分别使用不同链表,则可以按照大小对空闲区链表排序,以便提高适配算法的速度。对于按空闲区大小排序的链表,最佳适配算法只需要找到第一个满足的空闲区,就能结束搜索。在这种情况下,最佳适配算法和首次适配算法的速度一样,而下一次适配算法没有意义。
另一个种适配算法是快速适配算法(quick fit)。他是为那些常用大小的空闲区维护单独的链表。例如一个由n项的表,该表的第一项指向大小为4KB的空闲区链表表头的指针,第二项指向大小为8KB的空闲区链表表头的指针......以此类推,向21KB大小的空闲区可以放入到20KB的空闲区链表中。该算法对于寻找一个指定大小的空闲区是十分快速的。
如上五中适配算法(first/next/best/worst/quick fit)都有一个值得注意的问题:在一个进程终止或者被换出时,寻找它相邻的空闲区块并查看是否能够合并是十分耗时的,但是如果不合并内存很快就会分裂成大量进程无法利用的小的空闲区。