zoukankan      html  css  js  c++  java
  • 内存管理:02虚拟存储器

            现代系统提供了虚拟存储器的概念,它是对物理内存的抽象。虚拟存储器是硬件异常,硬件地址翻译,主存,磁盘文件,操作系统的完美交互,它为每一个进程提供了一个大的,一致的私有的地址空间。

            虚拟存储器提供了下面的几种能力,1:它将主存看成磁盘的高速缓存,并且根据需要在主存和磁盘之间传送数据。2:为每个进程提供一致的地址空间,简化存储管理。3:保护每个进程的地址空间不被其他进程破坏。

       

    一:物理和虚拟寻址

            物理寻址就是使用内存实际的地址进行寻址,物理内存的每个字节都有唯一的物理地址。早期的PC,现在的数据信号处理器,嵌入式微控制器等这样的系统仍然使用物理寻址。但是现代的处理器更多的使用虚拟寻址。

            使用虚拟寻址时,CPU利用一个虚拟地址(Virtual Address,VA)访问主存,这个虚拟地址在被送到主存之前,需要先转换成物理地址。这个过程叫做“地址翻译”。地址翻译需要CPU硬件和操作系统的合作,CPU上的MMU(存储器管理单元)这种硬件,利用存放在主存上的“页表”,将虚拟地址翻译成物理地址,该表由操作系统负责管理。如图:

     

    二:虚拟存储器作为缓存的工具

            概念上,虚拟存储器被看成一个由N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址。磁盘上的内容被缓存在主存中。同存储器层次结构中的其他缓存一样,磁盘和主存都被分成大小相同的块,这些块就是磁盘和主存之间的传输单元,称之为“页”。虚拟存储器上的叫做虚拟页,物理存储器上的叫做物理页,虚拟页和物理页总是大小相同的。

     

            任何时刻,虚拟存储器上的页都有下面几种状态:

    未分配的:这些页是没有任何磁盘上的数据和他们相关联,也就不占用磁盘空间。

    缓存的:当前缓存在物理存储器中的已分配页。

    未缓存的:没有缓存在物理存储器中的已分配页。

     

            以DRAM缓存表示和虚拟存储器对应的物理存储器,它在主存中缓存虚拟页。因为虚拟页不命中的话,需要从磁盘中读取文件,所以,DRAM缓存不命中的处罚是相当大的。所以,一般页的大小选取的比较大,为4K,而且DRAM缓存是全相连的,就是说虚拟页可以放在任意的物理页中。而且,操作系统对DRAM缓存使用复杂的页替换算法。最后,因为对磁盘的访问时间较长,DRAM缓存总是用时写回的,而不是直写。

       

    1:页表

            同其他缓存一样,虚拟存储器必须有某种方法判定一个虚拟页是否已经缓存(在物理内存中是否有相应的内容)了。如果是,系统必须确定相应的物理页的位置,如果不命中,系统必须判断这个虚拟页对应的磁盘位置,然后在物理存储器中选择一个牺牲页,将虚拟页从磁盘拷贝到DRAM中,替换这个牺牲页。(每个进程对应一个虚拟存储器,也就是4G的逻辑空间,空间中的有些段,比如代码段、共享对象段等,是需要从磁盘加载到物理内存的,而有些段,比如堆、栈等,在磁盘上是没有对应内容的,只需要开辟物理内存空间即可)。

     

            上述的功能是由软硬件联合实现的。包括操作系统,MMU中的地址翻译硬件和存放在主存中的页表。页表的功能,就是提供虚拟页到物理页的映射。每次,地址翻译硬件都会读取页表来进行地址翻译。操作系统负责维护页表的内容,并且负责磁盘和DRAM缓存之间的页传送。

            页表,是页表条目(PageTable Entry, PTE)的数组。虚拟地址空间中的每个页在页表中的一个固定偏移量处都会有一个PTE

            每个PTE中,由一个有效位和一个n位的地址字段组成。如果设置有效位,表明该虚拟页已经被缓存在物理内存中了,也就是说,有相应的物理页与之相对应。那个地址字段就表明了物理页的起始位置。

            如果没有设置有效位,并且地址字段是空的,就说明该虚拟页是未分配的,就是磁盘上没有数据与虚拟页相对应。如果有地址字段,那么就说明这个虚拟页是已经分配的,但是是未缓存的,地址字段表明了该虚拟也在磁盘上的起始位置。如下图所示:


     

    2:页命中

            页命中的情况如图:

            当CPU需要读取包含在虚拟页VP2中的一个字时,首先,地址翻译硬件将虚拟地址作为索引,来定位页表中相应的PTE,并且从存储器中读取它的值。因设置了有效位,表明该页已缓存,于是根据PTE中的地址字段,构造出该字的实际物理地址。

     

    3:缺页

            缺页的情况如下图,第一张图表明缺页之前的情况,第二张图是缺页之后的情况:

            虚拟存储器系统中的习惯说法,缺页就是指的DRAM缓存不命中。比如CPU引用VP3中的一个字,VP3并未缓存在DRAM中,查看页表中的PTE3,从有效位推断出VP3并未缓存,从而触发了一个缺页异常。

            缺页异常要调用内核中的缺页异常处理程序。该程序选择一个牺牲页,此例中就是存放在PP3中的VP4,如果VP4已经被修改了,那么内核就会将VP4写回磁盘。然后,内核修改VP4的页表条目,反映出VP4已经不再缓存了,接下来,内核从磁盘拷贝VP3到DRAM缓存的PP3,更新相应的PTE3,然后返回。

            当异常处理程序返回时,它会重启那个导致缺页的指令,该指令重新执行,这一次就是页命中的情况了。

     

            虚拟存储器的概念是在20世纪60年代提出的,远在SRAM缓存之前,所以虚拟存储器使用了和高速缓存不同的术语,但是原理上是一致的,比如在虚拟存储系统中,块称为页,磁盘和内存之间的传送叫做页面调度。当缺页时才进行页面调度,这种方法叫做按需页面调度,所有现代操作系统都使用这种策略。还有其他的策略是预测不命中,在实际引用之前就调入页。

     

    4:分配页面

            分配页面的情况如图:

            当调用malloc时,通过在虚拟空间中创建空间VP5,然后更新页表中的PTE5,使它指向虚拟空间中的新创建的VP5。

     

    5:局部性

            程序的局部性原理,使得虚拟存储系统可以工作的很好。但是如果程序的工作集(某一时刻程序的活动区域)的大小超出了物理存储器的大小,那么程序就会处于一种“颠簸”的状态,此时,页面将不断的换入换出,这时,程序的性能就急剧下降。

     

    三:虚拟存储器作为存储器管理的工具

            虚拟存储器大大简化了存储器的管理,并且提供了一种存储器保护的方法。

            操作系统会为每一个进程都提供一个独立的页表,因而也就是一个独立的虚拟地址空间。如下图,进程i的页表将VP1映射到PP2,VP2映射到PP7。进程j的页表将VP1映射到PP7,VP2映射到PP10。多个虚拟页面可以映射到同一个共享物理页面上:

            按需页面调度和独立的虚拟地址空间的结合,对系统中存储器的使用和管理造成了深远的影响,VM简化了链接和加载,代码和数据共享,以及应用程序的存储器分配。虚拟存储器的作用如下:

    1:简化链接

            独立的地址空间允许每个进程的存储器映像都采用相同的基本格式,而不管代码和数据实际存储在物理空间的何处,比如,在linux中,每个进程都采用类似的存储格式。文本段总是从虚拟地址0x08048000(32位)或者0x400000(64位)处开始。数据段和bss段紧跟其后,栈在进程地址空间的最高部分,并且向下生长。这样的一致性极大的简化了链接器的设计和实现,允许链接器生成全连接的可执行文件,这些可执行文件是独立于物理存储器中代码和数据的最终位置的。

    2:简化加载

            虚拟存储器还使得容易向存储器中加载可执行文件和共享对象文件。比如linux下的ELF可执行文件,在创建进程时,linux加载器分配多个连续虚拟页的片,并把这些虚拟页设置为无效,然后将页表条目指向目标文件中适当的位置。这个时候,加载器并不拷贝任何数据从磁盘到存储器,只有在每个页在初次引用的时候,才根据需要进行页置换。

            将一组连续的虚拟页映射到任意的一个文件中的任意位置的表示法称作存储器映射。Unix使用mmap系统调用完成存储器映射。

    3:简化共享

            一般而言,每个进程都有自己私有的代码,数据,堆和栈,不与其他进程共享,这种情况下,操作系统创建页表,将相应的虚拟页映射到不同的物理页面。

            然后某些情况下,多个进程会使用相同的对象,比如每个进程必须调用相同的操作系统内核代码,每个C程序都会调用标准库中的函数如printf。此时,操作系统可以将不同进程的虚拟页映射到相同的物理页中,从而使多个进程共享这部分代码的一个拷贝,而不是每个进程空间都有一份拷贝。

    4:简化存储器分配

            当一个用户进程调用malloc的时候,操作系统分配连续的虚拟空间页面,并且将它们映射到物理存储器中的任意的页面中,由于页表的存在,物理页面不需要连续,可以随机的分散在物理存储器中。

     

    四:虚拟存储器作为存储器保护的工具

            任何现代计算机系统都必须为操作系统提供手段来控制存储器的访问,比如说,不能允许进程修改只读文本段,不能允许应用进程读取或者修改内核中的代码和数据段,不能允许进程读写其他进程的私有数据。

            使用虚拟存储器,可以为进程提供独立的地址空间,而且,地址翻译机制还可以提供访问控制,因为CPU生成一个地址时,地址翻译硬件都会读取PTE,所以,可以在PTE上添加一些额外的访问控制位来控制对虚拟页的访问权限。如下图所示:

            在PTE上扩展下面三个位:SUP,READ,WRITE。SUP表示进程是否必须运行在内核模式下才能访问该页。READ和WRITE表示控制对页的读和写操作。

            如果一条指令违反了这些控制条件,CPU就会触发一个异常处理程序,Unix一般将这种异常报告为“段错误”。

     

    五:地址翻译

            从形式上来说,地址翻译就是一个N元素的虚拟地址空间中的元素和M元素的物理空间的元素之间的映射。如图展示了如何利用页表实现这种映射:

            CPU的一个控制寄存器,页表基址寄存器(PTBR),保存了当前页表的基地址(物理地址)。

            n位的虚拟地址包含两个部分:p位的虚拟页面偏移(VPO),n-p位的虚拟页号(VPN),因为物理页面和虚拟页面的大小是一样的,所以VPO也就是物理页面偏移(PPO)。MMU利用VPN作为索引来选择合适的PTE,比如VPN0选择PTE0,VPN1选择PTE1,以此类推。然后将页表中的物理页号(PPN)和VPO(=PPO)结合起来,就得到了相应的物理地址。

     

            下图展示了当页面命中时,CPU硬件执行的步骤:

            1:处理器生成一个虚拟地址,把它传送给MMU。

            2:MMU生成PTE地址,然后去高速缓存/主存中进行访问。

            3:高速缓存/主存向MMU返回PTE。

            4:MMU构造物理地址,并把它传送给高速缓存/主存。

            5:高速缓存/主存返回所请求的数据字给处理器。

     

            页面命中完全是由硬件来处理的,与之不同的是,处理缺页,要求硬件和操作系统内核协作完成,如下图所示:

            1-3:如同页命中一样。

            4:PTE中的有效位是0,表示该页并没有在主存中,所以MMU触发一个异常,使CPU执行缺页异常处理程序。

            5:缺页异常处理程序确定出物理内存的牺牲页,如果这个页已经修改了,则把它写入磁盘。

            6:缺页异常处理程序调入新的页面,并更新存储器中的PTE。

            7:缺页异常处理程序返回,CPU再次执行导致缺页的指令,再次执行后,因虚拟页面已经缓存了,所以这次的执行是可以命中的。

       

    1:结合高速缓存和虚拟存储器

            既使用高速缓存又使用虚拟存储器的系统中,都存在是使用虚拟地址还是物理地址来访问高速缓存的问题。大多数系统都是选择物理地址的。如图:

            结合高速缓存之后,主要的思路就是地址翻译发生在高速缓存之前,而且,页表也同其他数据一样,可以缓存在高速缓存中。

       

    2:TLB加速地址翻译

            因为每次CPU产生一个虚拟地址,MMU都必须查阅一个PTE。这样在最糟糕的情况下,都会从存储器中取一次数据,代价就是几十到几百个周期。如果PTE碰巧在高速缓存L1中,那么这种开销可以下降到1个或者2个周期。然而,许多系统仍然试图消除这种开销。方法就是在MMU中包含一个PTE的缓存,称为翻译后备缓冲器(TLB)。

            TLB是一个小的,页表的缓存,其中每一行都保存一个PTE。如图所示,虚拟地址的虚拟页号提供了访问TLB的组索引位和行匹配的标记位。

     

            下图展示了当TLB命中和不命中时的步骤,这里的关键是所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。

     

            1:CPU产生一个虚拟地址

            2、3:MMU从TLB中取出相应的PTE

            4:MMU将这个虚拟地址翻译成一个物理地址,并将物理地址发送到高速缓存/主存

            5:高速缓存/主存将请求的数据返回给CPU。

            当TLB不命中的时候,MMU必须从高速缓存/主存中取出相应的PTE,然后新取出的PTE存放在TLB中。

     

    3:多级页表

            假设32位的地址空间,页大小为4K,每个PTE大小为4字节。那么页表的总大小将是:4G/4K * 4 = 4M。

            所以需要压缩页表的大小,一般的方法就是采用多级页表。具体实例如下,假设32位虚拟地址空间,页大小为4K,PTE大小为4字节。

            假设此时,虚拟空间有如下的形式:前2K个页面分配给了代码段和数据段,接下来的6K个页面尚未分配,在接下来的1023个页面也未分配,接下来的1个页面分配给了用户栈。下图展示了如何为这个虚拟地址空间构造一个两级的页表层次结构。

            一级页表中的每个PTE负责映射虚拟地址空间中的一个4MB的片,每一片都是由连续的1024个页构成的。比如PTE0映射第一片,PTE1映射第二片等。这样,对于一级页表来说,一共需要4G/4M  = 1K个PTE已经足够了,也就是4k的大小。

            如果片i中的每个页面都没有分配,那么一级PTE的i就为空。上图中,片2-7都是未分配的。然后,如果片i中至少有一个页是分配了的,那么一级PTEi就指向一个二级页表的基地址。如上图中,片0,1和8的所有或者部分已经分配,所以他们的一级PTE就指向二级页表。

            二级页表中的每个PTE负责映射4k的页。所以,PTE是4字节的话,那么每个一级页表和二级页表都是4K字节。

     

            使用多级页表,可以减少存储器的使用,第一,如果一级页表中的PTE是空的,那么相应的二级页表就根本不存在。这是一种巨大的节约,因为一个典型的程序,4G的虚拟地址空间大部分都将是未分配的。第二,只有一级页表才需要常驻内存。可以在需要的时候创建,调入或者调出二级页表。这就减少了主存的压力,只有最经常使用的二级页表才需要缓存在主存中。

     

            下图描述了k级页表结构。虚拟地址被划分成k个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引,其中1<=i<=k。

            第j级页表的每个PTE,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN或一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE,对于只有一级的页表结构,PPO和VPO是相同的。

            访问k个PTE,看上去仿佛效率很低,但是TLB的存在,使得多级页表的翻译并不比一级页表慢很多。

     

    4:地址翻译实例研究

            实例:运行在有TLB和L1的小系统上,做出如下假设:

    存储器按照字节寻址;

    存储器访问是针对1字节的字;

    虚拟地址14位, 物理地址12位;

    页面大小64字节;

    TLB四路组相连,总共有16个条目;

    L1是物理寻址,直接映射的,行大小为4字节,总共有16组;

     

            基于上面的假设,我们可以得出如下的结论:

            页面大小64字节,也就是VPO和PPO都有6位,因而,VPN有8位,PPN有6位。如图:

            如图:TLB页表和L1缓存。所示:

     

            TLB是利用VPN进行寻址的,因TLB有4个组,所以VPN的低两位就作为组索引。VPN剩下的6位作为匹配标记,用来区别可能映射到同一个TLB组的不同的VPN。

            页表,一级页表,一共有2^8=256个PTE,图中只展示了我们需要的16个PTE。

     

            高速缓存:通过物理地址寻址,因为每个块4字节,所以物理地址的低2位作为块偏移,因为有16组,所以接下来的4位就是组索引,剩下的6位作为匹配标记。

     

            这样,当CPU需要寻址0x03d4时,我们看一下如何定位到相应的物理地址。

    0x03d4 = (00 0011 1101 0100)b

            将上面的二进制,按照页面偏移,TLB组索引,TLB标记重新分段是这样的:(000011 11 010100),组索引是11,标记是000011。所以查找TLB的第3组,标记位为3的有效位是1,说明TLB命中,因而直接查得PPN位0D。所以,物理地址为:001101 010100(0x354),根据这个物理地址查找L1,重新分段是001101 0101 00。所以,查找L1的第5组,标记位是0D的有效位是1,所以L1命中,块偏移为00,所以块0中的数据36就是对应于虚拟地址0x03d4的值。

       

            上面的情况是TLB命中了,如果TLB不命中,可以查看页表,因为VPN位为00001111,所以查看页表也能得到PPN位为0D。

     

    六:linux虚拟存储系统

            Linux为每个进程都维护一个单独的虚拟地址空间,如图所示:

            它包括:代码段,数据段,堆,共享库,栈,还有内核虚拟存储部分。内核虚拟存储部分包含每个进程都共享的内核代码和数据。一组连续的虚拟页面,这样内核就可以方便的访问物理存储器中的页面了。内核存储的剩余部分是每个进程都不同的数据,比如页表,内核栈,进程数据结构等等。

     

            linux将虚拟存储空间组织称“Segment”的集合,Segment是连续页的集合。如图:linux进程数据结构。所示:

            内核为系统中的每个进程都维护了一个单独的任务结构:task_struct。这个结构包含或者指向内核运行该进程所需要的所有信息,比如PID,用户栈指针,可执行文件名称,PC等。

            task_struct的一个条目指向了mm_struct,这个结构描述了虚拟存储器的当前状态。其中有两个字段是我们感兴趣的,pgd和mmap。pgd是进程第一级页表的基地址。mmap则是指向一个vm_area_structs的链表。每个vm_area_structs结构对应着一个Segment。内核运行进程时,他就把pgd放在CR3控制寄存器中。

            vm_area_structs区域结构中的字段有:

            vm_start:段的起始地址。

            vm_end:段的结束地址。

            vm_port:段的读写权限。

            vm_flags:指明该段是共享的还是私有的,以及其他的信息。

            vm_next:指向链表中的下一个vm_area_structs结构。

     

            当MMU试图翻译一个虚拟地址A时,如果触发了一个缺页,执行缺页异常处理程序,该程序执行下面的步骤:

            1:虚拟地址是否合法:它会把A和链表中的每个vm_area_structs结构中的vm_start和vm_end进行比较,如果不合法,就会触发一个段错误。

            2:试图进行的存储器访问是否合法。也就是说是否具有相应的读写权限。如果要进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。

            3:此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的,他会这样处理:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序在返回的时候,CPU会重新启动引起缺页异常的那个指令。

     

    七:存储器映射

            linux以及类unix系统,通过将虚拟地址空间和物理磁盘上的某个文件进行关联映射,以初始化虚拟地址空间。这个过程成为存储器映射

            可以映射的对象有两种:

            a:Unix中的普通文件,比如ELF可执行文件。

            b:匿名文件,也就是内核创建的,内容全是0。CPU第一次引用这样的区域内的虚拟页时,内核就在物理内存中找到一个牺牲页面,如果这个牺牲页面已经被修改,则将这个页面换出来,用二进制0覆盖牺牲页面,并更新页表。注意到磁盘和存储器之间没有实际的数据传送,所以,映射到匿名文件的区域中的页面也叫做请求二进制零的页。

     

    1:共享对象(共享库)

            许多进程可能需要使用同样的只读文本区域,比如共享库等。如果每个进程都在物理内存中保持这些常用代码的复制拷贝,那就是极端的浪费了。存储器映射可以控制多个进程共享对象。

            如果一个对象被映射到虚拟地址空间,它可以是共享对象,也可以是私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间中的一个区域,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的,而且这种变化也会反映在磁盘上的原始对象中。另一方面,对私有对象的改变,对于其他进程是不可见的,并且,进程对这个区域所做的任何写操作都不会反映到磁盘上的对象中。

            如图所示,进程1将一个共享对象映射到它的虚拟空间中的一个区域中,现在假设进程2将同一个共享对象也映射到自己的虚拟空间中(不一定要和进程1在相同的虚拟地址处):

            因为每个对象都有一个唯一的文件名,内核可以迅速的判定进程1已经映射了这个对象,这就可以使进程2中的页表指向同一个物理页面。这样物理内存中只需保存一个共享对象的拷贝就可以了。

     

            私有对象映射,如图所示:

            私有对象使用一种叫做写实拷贝的技术,一个私有对象的开始生命周期的方式基本上与共享对象一样的,物理内存中只保存私有对象的一份拷贝。两个进程将一个私有对象映射到它们各自的虚拟空间中的不同区域,但是共享这个对象同一个物理拷贝。对于每个映射私有对象的进程,相应的页表条目标记为只读,并且区域结构被标记为私有的写实拷贝。只要没有进程试图修改该私有区域,那么他们就可以继续共享一份拷贝。

            但是,当有进程试图修改该私有区域时,就会触发一个保护故障,保护故障处理程序注意到保护异常是由于进程试图写私有的,写实拷贝的区域中的页面引起的,他就会在物理内存中创建这个页面的一个新拷贝,然后更新页表条目指向这个新的拷贝,然后恢复这个页面的写权限。然后故障处理程序返回,CPU重新执行写操作,这样就可以在新创建的页面上正常执行了。

     

    2:fork函数:

            当调用fork时,内核会为新进程创建数据结构,分配PID。它创建当前进程的mm_struct,区域结构和页表的原样拷贝。它将两个进程中的每个页面都标记为只读,还将区域结构标记为私有的写时拷贝。

            当fork返回时,新进程的虚拟空间刚好和调用fork时存在的虚拟空间相同,当父子进程的任意一个进行写操作的时候,写时拷贝机制就会创建新的页面。因此,也就为每个进程保持了私有空间的概念。

     

    3:execve函数:

            execve("a.out",NULL, NULL)

            调用这个函数的时候,加载并运行a.out,需要下面的步骤:

            1:删除已经存在的用户区域,也就是删除当前进程虚拟地址空间中的用户部分中已经存在的区域结构。

            2:映射私有区域,为新程序的文本,数据,bss和栈区域创建新的区域结构。所有这些区域都是私有的,写时拷贝的,如下图所示:文本段和数据段被映射为a.out文件中的文本和数据区域。bss段是请求二进制零的,映射到匿名文件,他的大小是包含在a.out中的。堆和栈区域也是请求二进制零的,初始长度为0.

            3:映射共享区域,如果a.out与共享对象链接,比如标准C库libc.so。那么这些对象都是动态链接的,会映射到用户虚拟地址空间中的共享区域内。

            4:设置PC,execve做的最后一件事就是设置进程上下文中的程序计数器PC,使之指向文本区域的入口。下次调度这个进程的时候,他就会从这个入口点开始执行,然后linux就会根据需要换入代码和数据页面。

    八:动态存储器分配

            进程通常使用malloc等库函数,在运行的时候动态申请内存,这就需要动态内存分配器的帮助。

            动态内存分配器维护者一个进程的虚拟存储区域,也就是堆,一般来说,它是向上增长的,紧接在未初始化的bss段的后面。对于每个进程,内核都会维护着一个指针brk,它指向堆的顶部。

            分配器将堆视为一组大小不同的块进行维护。每个块就是一个连续的虚拟存储器片,这些块要么是已分配的,要么是空闲的。

            分配器有两种基本风格,这两种风格都要求应用程序都显示的分配块,但是不同之处在于释放块。显示分配器要求应用程序显示的释放已分配的块。比如c语言中的free,c++中的delete。隐式分配器要求分配器检测一个已分配块不再使用时,就释放这个块。隐式分配器也叫垃圾收集器。像java等语言就是用这种分配器。

       

    1:malloc和free

            void *malloc(size t size);

            void free(void *ptr);

            malloc用来分配内存,free用来释放,如果malloc出错,那么就会返回NULL,并且设置errno。malloc并不初始化这块内存,如果需要初始化的话,可以使用calloc,它分配内存,并且把这块内存初始化为0。realloc可以改变之前已分配内存的大小。

            注意,在分配内存的时候,需要填充字节来使块保持边界对其。

            malloc,可以通过mmap和munmap函数,显示的分配和释放堆空间,或者可以使用sbrk函数:void *sbrk(int incr);

            sbrk函数可以将内核的brk指针增加incr个字节来扩展和收缩堆。如果成功,就返会brk指针的旧值,否则返回-1。如果incr为0,那么sbrk就会返回brk指针的当前值。incr可以是负的,这样就可以收缩堆了。

            free函数的参数ptr,必须是指向一个从malloc,calloc或realloc获得的已分配的起始位置。如果不是,那么free的行为就是未定义的。

       

    2:分配器的要求和目标

            分配器的要求如下:

            a:处理任意请求序列,分配器只要求每个释放请求必须对应于一个当前已分配块,因此,分配器不能假设分配和释放请求的顺序,一个应用可以有任意的分配请求和释放请求序列。

            b:立即响应请求:分配器必须立即响应分配请求,不能为了提高性能而重新排列或者缓冲请求。

            c:只是用堆:分配器分配的块必须保存在堆中。

            d:对齐要求:分配器必须对其块,多数系统中,要求分配器返回的块是8字节边界对其的。

            e:不修改已分配的块:块一旦分配了,就不允许分配器修改它或者移动它。

     

            分配器的目标是实现吞吐率最大化和存储器使用率最大化,这两个目标通常是相互矛盾的。

            吞吐量是单位时间内完成的请求数,同时,存储空间是有限的,必须尽可能提高存储空间的使用率。

     

    3:碎片

            碎片是造成一个堆使用率低的很重要的原因。当虽然有未使用的存储器,但不能用来满足分配请求时,就会发生这种现象。碎片有两种,一种是内部碎片,一种是外部碎片。

            内部碎片是由于分配了有效载荷之外的填充字节引起的,比如对齐。

            外部碎片是当空闲块部分合起来已经足够满足一个分配请求,然而这些空闲部分却是分散的块组成的。

     

    4:实现问题

            要实现一个分配器,就需要考虑下面的问题:

    空闲块的组织:如何记录空闲块

    放置:如何选择一个合适的空闲块来放置一个已分配的块。

    分割:将分配块放置在某个空闲块之后,如何处理空闲块中的剩余部分。

    合并:如何处理刚刚释放的块。

     

    5:隐式空闲链表

            任何实际的分配器都会需要一个数据结构,来区别块边界,以及区别分配块和空闲块。大多数分配器将这些信息内嵌在块本身中。如图:

     

            这种情况下,块是由一个头部,有效载荷,填充部分组成。在头部中,编码了这个块的大小(包括头部和填充位),以及这个块是已分配的还是空闲的。

            如果我们要求块是要双字对齐的,那么块大小就总是8的倍数,即块的大小最低3位总是0。所以,大小通常在头部的高29位中存储,剩余3位编码其他信息,比如是否已分配。比如一个已分配块大小为24字节,也就是0x18, 那么他的头部编码就是0x18 | 1 = 0x19。再比如一个空闲块大小为40字节,也就是0x28,那么它的头部编码就是0x28|0 = 0x28。

            头部后面就是malloc请求的有效载荷。有效载荷后面就是一片不使用的填充位。其大小可以是任意的

     

            如图所示:

            可以将堆组织成为一个连续的已分配块和空闲块的序列,称之为隐式空闲链表,是因为空闲块是通过头部中的大小字段来隐含的链接者的。分配器可以遍历堆中的所有块,从而间接的遍历整个空闲块。注意,一般我们还需要一些特殊标记的块来标记结束块。本例中已一个设置了已分配位而大小是0的块为结束块。

            隐式空闲链表的优点是简单,缺点是任何操作的开销都与堆中已分配块和空闲块的总数呈线性关系。

     

    6:放置已分配的块

            当一个应用请求k个字节的块的时候,分配器搜索空闲链表,查找一个足够大可以放置请求大小的空闲块。分配器执行这种搜索的的方式是通过放置策略确定的。一些常见的策略有首次适配,下一次适配,最佳适配。

            首次适配:从头开始搜索,选择第一个合适的块。

            下一次适配:与首次适配类似,区别是从上一次查询结束的地方开始搜索。

            最佳适配:检查每个空闲块,选择最合适的空闲块。

     

    7:分割空闲块

            当选择了合适的空闲块之后,就需要另一个策略决定需要分配这个空闲块的多少空间。一个选择是整个空闲块,虽然这种方式比较块,但容易造成内部碎片。另一种策略是将空闲块分割,第一部分是分配块,剩余的部分成为新的空闲块。

       

    8:获取额外的堆空间

            如果分配器不能为请求块找到合适的空闲块,一种选择是合并那些相邻的空闲块来创建一个更大的空闲块,但是如果生成的空闲块还是不够大,或者说空闲块已经最大程度的合并了,那么分配器就会通过调用sbrk函数,向内核申请更大的堆空间。

       

    9:合并空闲块

            当分配器释放一个已分配块的时候,可能有其他的空闲块与这个新释放的空闲块相邻。这些相邻的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成了小的,无法使用的空闲块。

            为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块。这就牵扯到什么时候合并的问题了,分配器可以选择立即合并,也可以选择推迟合并。立即合并简单明了,但是容易产生抖动,也就是块反复的合并和分割。一般来说,快速的分配器通常会选择某种形式的推迟合并

     

    10:带边界标记的合并

            如果采用上面的块结构,那么当合并时,容易判断当前块的下个块是否是空闲块,但是当前块的上一个块是否空闲就比较难了,唯一的方法就是搜索整个链表。

            因而提出了一种新的块格式,就是边界标记。如图所示:就是在每个块的尾部加一个脚部。脚部是头部的一个副本。这样,每个分配器就可以查看前一个块的脚部来判断前一个块的起始位置和是否空闲了。这个脚部总是在距离当前块开始位置一个字的距离。

            尽管边界标记的概念简单优雅,但是它也有个缺陷,就是每个块都保持一个头部和尾部,造成了显著的存储器开销,特别是针对申请的空间较小的时候。

            有一种优化方法,可以使已分配块不必有脚部,因为脚部的作用就是看前一个块是否是空闲块,那么可以把前一块的已分配/空闲标记位放在当前块的头部中,这样,当块是已分配块的时候,就不必拥有脚部了。因为释放一个已分配块的时候,可以查看该分配快的头部,判断前一块是已分配块,还是空闲块。

            不过,空闲块还是需要脚部的。

       

    11:显示空闲链表

            隐式空闲链表虽然简单,但是它的块分配是和块的总数成线性关系的,所以通用的内存分配器,通常不使用隐式空闲链表。

            一种更好的方法是将空闲块组织成某种形式的显示数据结构。如图:

            因为程序不需要空闲块中的主体,所以可以在主体中存放指针,比如可以存放指向前一个和后一个空闲块的指针。

            使用双向链表而不是隐式空闲链表,这样首次适配的分配时间从块总数的线性时间减少到了空闲块数的线性时间。但是,释放块的时间可以是线性的,也可以是常数的,这取决于空闲链表块中的排序策略。

     

            一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法,是分离存储。就是维护多个空闲链表,每个链表的块有大致相等的大小。

            一般来说,系统都会对最小块大小做出规定。比如linux2.6中,最小块大小为16字节,这样,即使申请1个字节的大小,块的大小也会是16字节。而且是双字(8字节)对其的。具体可以参见glibc的内存管理机制。

     

    《深入理解计算机系统》第十章:虚拟存储器笔记

  • 相关阅读:
    kvm初体验之四:从Host登录Guest的五种方式
    kvm初体验之三:vm的安装及管理
    CentOS Wifi Connection
    kvm初体验之二:安装
    kvm初体验之一:参考文档
    有6种不同颜色的球,每种球有无数个。现在取5个球,求取出5、4、3、2种不同颜色球的概率分别为多少
    求两个字符串的最长连续子串
    不用除法来实现整数的除法运算
    抽象类和接口的区别
    o(n)的时间复杂判断回文数
  • 原文地址:https://www.cnblogs.com/gqtcgq/p/7247029.html
Copyright © 2011-2022 走看看