一、虚拟内存
现代系统提供了一种对主存的抽象概念,即虚拟内存【Virtual Memory,VM】,以便于更加有效的管理内存,减少出错。VM使得每个进程使用统一的线性地址空间,同时又保持内存独立。
1.1 地址空间
计算机系统的主存被组织成一个连续字节大小单元的数组。每个字节都有唯一的物理地址【Physical Address,PA】。CPU访问内存的最自然的方式就是使用物理地址,称为物理寻址。
现代处理器使用的是虚拟寻址的寻址形式,CPU通过生成虚拟地址【Virtual Address,VA】来访问主存。虚拟地址在被送到内存之前先转换成适当的物理地址,这种转换任务称为地址翻译,其需要CPU硬件和操作系统之间的紧密合作。CPU使用内存管理单元【Memory Management Unit,MMU】专业硬件,利用存放在主存中的查询表来动态翻译虚拟地址。
地址空间是一个非负整数地址的有序集合,如果地址空间中的整数时连续的,称之为线性地址空间。在理论讨论中一般假设使用的总是线性地址空间。在一个虚拟内存系统中,CPU从 N = 2 n N = 2^n N=2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间,即 { 0 , 1 , 2 , . . . , N − 1 } \{0, 1, 2, ..., N - 1\} { 0,1,2,...,N−1}。一个系统还有一个物理地址空间,对于系统中物理内存的M个字节,即 0 , 1 , 2 , . . . , M − 1 {0, 1, 2, ..., M - 1} 0,1,2,...,M−1,这里不要求 M M M是2的幂,但是一般情况下假设 M = 2 m M = 2^m M=2m。
地址空间清楚的区分了数据对象,即其数据,和其属性,即其地址。因此,一个数据对象可以有多个独立的地址,而每个地址选自不同的地址空间。这也是虚拟地址的基本思想,主存中的每个字节都有一个选自虚拟地址空间的虚拟地址与一个选自物理地址空间的物理地址。
1.2 虚拟内存缓存
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。磁盘上的数组的内容被缓存在主存中,根据存储器层次结构,较低层次的磁盘上的数据被分割成块,作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页【Virtual Page,VP】的大小固定的块来处理该问题,类似的,物理内存被分割为物理页【Physical Page,PP】,也成为页帧,大小与虚拟页的大小相同,均为 P = 2 p P = 2^p P=2p字节。
磁盘上的数组内容被缓存在物理内存,即DRAM中。由于存储器层次结构的特性,DRAM缓存的组织结构完全由巨大的不命中开销驱动。DRAM缓存为全相联结构,使用复杂精密的替换算法,以及大的虚拟页,在写操作中总是使用写回。
页表是一个页表条目【Page Table Entry,PTE】的数组,将虚拟页地址映射到物理页地址。页表中每个固定偏移量处都有一个PTE,由有效位与n位的地址组成。有效位表明了虚拟页是否被DRAM缓存,若缓存,则PTE的地址指向物理页起始位置;否则使用空地址表示虚拟页未被分配,或地址指向磁盘的起始位置。故虚拟地址对应数据分为三种状态:
-虚拟地址存放了数据,且数据被缓存;
-虚拟地址存放了数据,但未被缓存,存放在磁盘中;
-虚拟地址未被使用,即无效地址,其在磁盘中不会被分配。
由于DRAM是全相联的,因此任何物理页都可以包含任何虚拟页。
考虑VP2缓存在DRAM中,当CPU想要读VP2中的虚拟内存的一个字时,其通过虚拟地址定位到PTE2,如果VP2已经缓存在DRAM中,就得到VP2对应的物理地址,称为页命中。反之,如果VP2在磁盘中未被缓存,则发生了缺页。当缺页发生时,会触发缺页异常,通过缺页异常处理选择一个牺牲页进行替换,并重新执行读操作。
由于页表的机制,使得可以利用DRAM缓存更大虚拟地址空间的页面。
操作系统通过调用malloc分配页面,在磁盘中创建空间并更新PTE,使其指向磁盘上新创建的页面。
虚拟内存由于局部性,在任意时间,程序都趋于在一个较小的活动页面集合上工作,称为工作集。在工作集超过物理内存时,就会发生不断的页面更换,形成抖动,导致程序极大的变慢。
1.3 虚拟内存管理
虚拟内存极大的简化了内存管理,并提供了一种自然的保护内存的方法。
操作系统为每个进程提供了独立的页表,使得每个进程具有独立的地址空间,且不同进程的不同虚拟地址可以映射到相同的物理地址。
按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深远的影响。
虚拟内存简化了链接,独立的地址空间允许每个进程的内存映像使用相同的基本格式,如64位地址空间中,代码段总是从虚拟地址0x400000开始。栈占据用户进程地址空间最高的部分,向下生长。这样的一致性极大地简化了连接器的设计和实现。
虚拟内存简化了加载,其使得容易向内存中加载可执行文件和共享文件。要把.text和.data加载到一个新创建的进程中,Linux加载器为代码和数据分配虚拟页,并标记为无效的,即未被缓存的,将页表条目指向目标文件中适当的位置。有趣的是,加载器不从磁盘到内存实际复制任何数据,而是通过虚拟内存机制调用数据页。
虚拟内存简化了共享,独立的地址空间使得不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个副本。
虚拟内存简化了内存分配,当运行在用户进程中的程序需要额外的堆空间,操作系统分配适当个连续的虚拟内存页面,可以映射到物理内存的任意位置,因为页表的工作方式,页面可以随机的分散在物理内存中。
1.4 虚拟内存保护
现代计算机系统必须为操作系统提供手段控制对内存系统的访问。
地址翻译机制以一种自然的方式扩展到提供更好的控制访问,通过在PTE上添加一些额外的许可位来控制对虚拟页面内容的访问十分简单,即每个PTE的许可为包括:
-SUP,在置1时表示进程必须运行在内核模式才能访问该页;
-READ,在置1时表示可以对页面进行读操作;
-WRITH,在置1时表示可以对页面进行写操作。
如果访问有效位为0时会产生缺页异常,在解决异常后在原址令继续执行;如果进行许可位为0的对应操作时,会产生段错误异常,并使得当前进程被终止。
1.5 地址翻译
形式上来说,地址翻译是一个 N N N元素的虚拟地址空间 V A S VAS VAS中的元素和一个 M M M元素的物理地址空间 P A S PAS PAS中元素之间的映射,形如 M A P : V A S → P A S ∪ ∅ MAP:VAS \rightarrow PAS\ \cup\ \varnothing MAP:VAS→PAS ∪ ∅其中, ∅ \varnothing ∅表示虚拟地址 A A A处的数据没有位于物理内存中,可能是无效地址或储存在磁盘中。
MMU利用页表实现映射如下
其中:
-CPU中有一个控制寄存器,页表基址寄存器【Page Table Base Register,PTBR】会指向当前页表的首部;
-虚拟地址的前p位虚拟页号作为页表的索引,得到虚拟地址的PTE;
-PTE的有效位为1时,其地址PPN作为物理地址的前p位物理页号;
-虚拟地址的后n-p位虚拟页面偏移作为物理页面偏移。
将物理页号与物理页面偏移串联起来,就得到了相应的物理地址。
在页面命中时,硬件执行如下操作:
-CPU处理器生成虚拟地址,传给CPU的MMU;
-MMU使用PTBR生成PTE的地址并访问内存,内存向MMU返回PTE;
-MMU将PTE的物理地址传给内存;
-内存返回所请求的数据字给处理器。
页面命中完全由硬件处理,但是处理缺页需要硬件和操作系统的协同,包括如下操作:
在页面命中时,硬件执行如下操作:
-CPU处理器生成虚拟地址,传给CPU的MMU;
-MMU使用PTBR生成PTE的地址并访问内存,内存向MMU返回PTE;
-MMU读取到PTE的有效位为0,因此MMU触发缺页异常,将CPU的控制传递给异常处理程序;
-缺页处理程序确定物理内存中的牺牲页,如果页面被修改,则执行写操作;
-缺页处理程序更新页面与内存中的PTE;
-CPU重新执行引起缺页的指令。
在既使用虚拟内存又使用SRAM高速缓存的系统中,大多数都是用物理寻址。因为地址翻译发生在最高速缓存查找之前,并且无需处理保护问题。
1.6 TLB加速地址翻译
要注意的是,PTE可以缓存,如同其他数据字一样。当CPU每产生一个虚拟地址,MMU就必须查阅一个PTE。在最糟糕的情况下,可能要求从低层内存中取一次数据。许多系统试图消除即使是这样的开销,其在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器【Translation Lookaside Buffer,TLB】。
TLB是一个小的、虚拟寻址的缓存,其每一行保存一个由单个PTE组成的块,TLB通常有高度的相连度。其将虚拟地址划分为三个段,分别为TLB标记、TLB索引、VPO。TLB的缓存组织中,TLBI用于选择缓存组,TLBT用于匹配缓存行,而PTE则作为行的数据。当TLB命中时,所有地址翻译步骤将都在芯片的MMU中执行,步骤为:
-CPU处理器生成虚拟地址,传给CPU的MMU;
-MMU使用虚拟地址查找TLB,将PTE传回MMU;
-MMU将PTE的物理地址传给内存;
-内存返回所请求的数据字给处理器。
而当TLB命中时,步骤为:
-CPU处理器生成虚拟地址,传给CPU的MMU;
-MMU使用虚拟地址查找TLB,查找失败;
-MMU使用PTBR生成PTE的地址并访问内存,内存向TLB返回PTE;
-MMU使用虚拟地址查找TLB,将PTE传回MMU;
-MMU将PTE的物理地址传给内存;
-内存返回所请求的数据字给处理器。
1.7 多级页表
当页面过多,地址空间过大时,页表的尺寸也变得庞大,常用的解决办法为多级页表,形如
每一个PTE指向该虚拟地址段的下一级页表,直到到达物理地址页号。
二、虚拟内存系统
通过简单的系统地址翻译与运行Linux的Intel core i7系统对虚拟内存进行系统级的讨论。
2.1 虚拟内存系统
考虑这样的系统:
-内存按字节寻址;
-内存访问的字长为1字节;
-虚拟地址14位长;
-物理地址12位长;
-页面大小64字节;
-TBL是四路组相联,共有16个条目;
-cache行大小4个字节,共有16组。
那么根据这些参数,可以划分地址如下:
-页面有64个字节的数据,决定了物理地址与虚拟地址的PPO与VPO均为6位,而虚拟地址的高8位为VPN,物理地址的高6位为PPN;
-TLB有4个组,那么TLBI的字段需要2位;TLBT则有6位;
-cache行大小为4个字节,故使用PA的低2位作为块偏移;cache有16个组,那么接下来的低4位作为组索引;其余作为标志位。
考虑系统状态如下
那么考虑虚拟地址0x03D4的划分,其比特模式被解释为
MMU使用VPN检查TLB,其组为0x03,标记为0x03,得到的条目有效位为1,即命中,则将PPN值0x0D返回MMU。MMU得到PPN后,将其与VPO连接,得到PA值0x354。
然后,考虑物理地址0x354的划分,其比特模式被解释为
故查找cache的5组0x0D标记行的0偏移处的块,得到数据字节0x36,返回给CPU处理器。
2.2 Core i7地址翻译
Core i7采用四级页表层次结构。每个进程有自己私有的页表层次结构。当一个Linux进程在运行时,虽然Core i7体系结构允许页表换进换出,但是与已分配了的页相关联的页表是驻留在内存中的。CR3控制寄存器指向第一级页表的起始位置,其属于进程上下文的一部分。
Core i7的内存系统形如
2.3 Linux虚拟内存系统
一个虚拟内存系统要求硬件和内核软件之间的紧密协作,且版本与版本之间细节都不尽相同。Linux为每个进程维护了一个单独的虚拟地址空间,形如
Linux将虚拟内存组织成段的集合。已分配的虚拟内存页以某种方式相关联,形成区域,例如代码段、数据段、运行时堆、共享库段以及用户栈,都是不同的区域。每个存在的虚拟页面保存在某个区域中,而不属于虚拟区域的虚拟页不存在,并且不能被进程引用。区域使得虚拟地址不需要连续,内核也不需要记录不存在的虚拟页。
内核为每个进程维护一个单独的任务结构,源代码中称为task_struct。任务结构中包含或指向运行该内存所需要的所有信息,包括PID,指向用户栈的指针,可执行目标文件名,程序计数器等。其中比较感兴趣的是如下的数据结构:
其中:
-mm_struct描述了虚拟内存的当前状态;
-pgd指向第一级页表的基址,在内核运行该进程时,pgd就存放在CR3控制寄存器中;
-mmap指向一个mm_area_struct的链表,该链表描述了当前虚拟地址空间区域;
-vm_start指向区域的起始处;
-vm_end指向区域的结束处;
-vm_prot描述区域包含页得读写许可权限;
-vm_flags描述了页面得区域包含页的私有或共享属性;
-vm_nexy指向链表中的下一元素。
当MMU试图翻译某个虚拟地址时,触发了一个缺页异常。这个异常导致控制跳转到内核的缺页处理程序,处理程序随后执行下面的步骤:
-判断虚拟内存是否合法,如果虚拟地址不在某个区域结构定位的区域内,会触发段错误;
-判断虚拟内存访问是否合法,如果试图进行不合法的访问,会处罚保护异常,并终止进程;
-如果是合法地址的合法操作,则进行牺牲页面的替换,更新页表并重新执行引起缺页的指令。
2.4 内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化虚拟内存区域的内容,这个过程称为内存映射。内存映射分为:
-普通文件,其文件区被分为页大小的片,对虚拟页面进行初始化,如果虚拟内存区比文件区大,则使用零填充。由于按需页面调度,直到使用虚拟页面之前,不会发生物理交换;
-匿名文件,其由内核创建,内容全为二进制零。当CPU第一次引用该区域的虚拟页面时,就会在存在牺牲页的情况下在牺牲页换出后,用零覆盖牺牲页面,并更新页表。
无论哪种情况,一旦虚拟页面被初始化,就在一个由内核维护的专门的交换文件之间换入换出。
如果将虚拟内存系统集成到传统的文件系统中,就能够提供一种简单而高效的把程序和数据加载到内存中的方法。一个对象被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。
多个进程把共享对象映射到虚拟地址空间,那么任何一个进程对该区域的任何写操作,其他进程都是可见的,并且这些变化会反映在磁盘上的原始对象中。映射到共享对象的虚拟内存区域称为共享区域。
对于映射到私有对象的区域做的改变,对于其他进程来说是不可见的,其任何写操作都不会反映在磁盘上的对象中。映射到私有对象的虚拟内存区域称为私有区域。
私有对象使用写时复制的巧妙技术映射到虚拟内存。一个私有对象在物理内存中只保存私有对象的一个副本,对于每个映射私有对象的进程,相应私有区域的页表条目标记为只读。如果由进程试图写私有区域的某个页面,就会在引起保护故障,故障处理程序会在物理内存中创建页面的副本,更新页表及权限,重新执行引起故障的指令,完成写操作。
当fork()被当前进程调用时,内核为新进程创建各种数据结构,分配唯一的PID。其实用当前进程的mm_struct、区域结构和页表的原始副本。两个进程中的页面私有且只读,直到发生写时复制。当fork在新进程中返回时,其虚拟内存与调用进程的内存相同。当任意一个进程执行写操作时,就会发生写时复制,创建新页面,从而使得两个进程又保持了私有地址空间的抽象概念。
考虑调用execve()运行a.out文件,execve()会在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序替代当前程序,步骤包括:
-删除当前进程的用户区域;
-映射私有区域,为新程序的代码、数据、栈创建新的区域结构,且是私有的,写时复制的。其中,代码段、数据段映射为a.out的.text和.data,未初始化区域、堆、栈映射到匿名文件,请求二进制零;
-映射共享区域,将共享库动态链接到程序,并映射到用户虚拟地址空间的共享空间;
-设置程序计数器,将PC指向代码区域的入口;
当控制交给该进程时,就从进程的入口点开始执行。
Linux进程可以使用mmap()函数创建新的虚拟内存区域,并将对象映射到这些区域中,形如
#include
#include
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
其在成功时返回指向映射区域的指针,否则返回-1。其会在fd指定的磁盘文件的offset处映射len个字节到一个新创建的虚拟内存区域,该区域从地址start处开始。使用prot描述映射虚拟区域的访问权限,用flags描述被映射对象类型的位组成是共享的、私有的、或是二进制零的。
三、动态内存分配
在程序运行时,使用动态内存分配器获得虚拟内存。
3.1 动态内存
动态内存分配器维护着进程的虚拟内存区域,称为堆。内与每个进程,内核维护者一个变量brk,指向堆的顶部。分配器将堆视为一组不同大小的块的集合,即连续的虚拟内存片,可以是已分配的或者未分配的。
程序使用动态内存的最重要的原因是经常直到程序实际运行时,才直到某个数据结构的大小。例如一个C程序读一个n个ASCII码整数的链表,每一行一个整数,从stdin到一个C数组。简单的方法是静态的定义数组,其最大数组大小是硬编码的,形如
#define MAXN 15213
int array[MAXN];
int main(){
int i, n;
scanf("%d", &n);
if (n > MAXN)
printf("Input file too big");
for (i = 0; i < n; i++)
scanf("%d", &array[i]);
exit(0);
}
然而,使用硬编码分配数组通常不是一种好想法。MAXN的值是任意的,如果程序的使用者读取一个比MAXN大的文件,就需要使用更大的MAXN值来重新编译整个程序。硬编码数组界限的出现对于拥有百万行代码和大量使用者的大型软件产品而言,会变成一场维护的噩梦。
一种更好的方法是在运行时,在已知了n的值之后,动态的分配这个数组。使用这种方法,数组大小的最大值就只由可用虚拟内存限制了。使用动态内存的C程序如下
int main(){
int i, n, *array;
scanf("%d", &n);
array = (int *)Malloc(n * sizeof(int));
for (i = 0; i < n; i++)
scanf("%d", &array[i]);
free(array);
exit(0);
}
分配器具有两种基本风格:
-显式分配器,要求应用显式的释放任何已分配的块,例如C标准库的malloc()函数;
-隐式分配器,也成为垃圾收集器,诸如Java之类的高级语言就依赖于垃圾收集来释放已分配的块。
内存分配是一个普遍的概念,可以出现在各种上下文中,这里集中于讨论管理堆内存的分配器。
3.2 分配器性能
显式分配器具有两个性能目标,并且通常是相互冲突的:
-最大化吞吐率,假设分配和释放请求的某种序列,希望每个单位时间内完成的请求数最大化。例如分配器在1秒内完成了500个分配请求和500个释放请求,那么吞吐率为每秒1000次操作。一般的,分配请求的最差性能与空闲块的数量成线性关系,而释放请求的性能是常数;
-最大化内存利用率,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交换空间的数量限制的。虚拟内存是有效的空间,必须高效的使用,对于可能被要求分配和释放大块内存的动态内存分配器来说尤其如此。
有很多方式描述分配器使用堆的效率,常用的为峰值利用率,在给定分配和释放序列 R 0 , R 1 , . . . , R n − 1 R_0, R_1, ..., R_{n-1} R0,R1,...,Rn−1中,如果一个应用程序请求p字节的块,那么得到的有效载荷就是p个字节。在请求 R k R_k Rk完成之后,当前已经分配的有效载荷之和为 P k P_k Pk,称为聚集有效载荷,并使用 H k H_k Hk表示堆的当前大小,假设其是单调不减的。那么前 k + 1 k + 1 k+1请求的峰值利用率为 U k = m a x i ≤ k P i / H k U_k = max_{i \le k}P_i/H_k Uk=maxi≤kPi/Hk分配器的性能目标就是使其最大化,由于最大化吞吐率与最大化利用率之间是互相牵制的,因此需要在两个目标之间找到一个适当的平衡。
造成堆利用率低的主要原因是碎片。当有未使用的内存不能满足分配请求时,就会产生碎片。碎片可以分为:
-内部碎片,有效载荷小于块,例如分配器规定了块的最小值,但有效载荷依然小于该最小值;
-外部碎片,空闲内存合计足够满足分配请求,但没有独立连续的空闲块足够处理请求。
内部碎片易于量化,但是外部碎片可能再未来由小的分配请求填充,而难以量化。分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。
3.3 malloc
C标准库提供了一个称为malloc程序包的显式分配器,程序通过调用malloc函数来从堆中分配块。malloc函数形式如下
#include
void *malloc(size_t size);
其在成功时返回指向size字节的内存块,内存块会为包含在块内的任何数据对象类型做对齐,返回块的地址在32位中总是8的倍数,而在64位中总是16的倍数;如果malloc遇到问题,如程序要求的块比可用的虚拟内存大,那么就会返回NULL并设置erron。
malloc不会初始化返回的内存,如果想要初始化的动态内存可以使用calloc()函数,其将分配的内存初始化为零;此外,还有realloc()函数用于改变已分配块的大小。
sbrk()函数可以操作堆指针,形如
#include
void *sbrk(intptr_t incr);
通过将内核的brk指针增加incr达到扩展和收缩堆的目的。在成功时,会返回brk的旧值;否则返回-1,并设置erron。参数可以是负值,用于扩展堆;或为零,用于返回目前的堆指针。
程序通过调用free()释放已分配的堆块,形如
#include
void free(void *ptr);
ptr需要指向malloc、calloc或者realloc获得的已分配块的起始位置。free不返回任何值,所以可能产生令人疑惑的运行时错误。
3.4 隐式空闲链表
任何实际的分配器都需要一些数据结构,允许其区分块的边界,以及区别已分配的块和空闲块。一种简单的方法如下图所示:
其头部编码了块的大小,其包括了头部、有效载荷及所有填充,已经块是否被分配的标志位。在对齐约束的条件下,块的大小总是8的倍数,即块的大小低3位总是0。因此将块大小的32位中的低3位释放出来,用于编码分配情况。例如头部为0x19的块,其大小为0x18,即24字节,而剩余的0x001则标识其已经被分配。
使用这种数据结构,堆可以组织为数据结构的序列,称为隐式空闲链表。
当请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的,包括:
-首次配置,从头开始搜索空闲链表,使用第一个合适的块;
-下一次适配,从上一次查询结束的地方开始搜索空闲链表,并使用第一个合适的块;
-最佳适配,检查所有的空闲块,选择适合所需请求大小的最小空闲块。
下一次适配避免了扫描无用块,比首次适配更快,但利用率远低于首次适配,而最佳适配则拥有更高的利用率。
一旦分配器找到匹配的空闲块,就需要做另一个策略决定,那就是分配这个空闲块中多少空间。由于放置策略趋于产生好的匹配,当匹配不太好时,分配器通常会选择将这个空闲块分割,第一部分作为分配块,而剩下的变成一个新的空闲块。
当把块的已经分配标志置0时,就会释放已分配块,此时可能由其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能会引起假碎片,就是有许多可用的空闲块被切割成为小的、无法使用的空闲块。为了解决假碎片的问题,任何实际的分配器都必须合并相邻的空闲块。目前讨论立即合并的策略,即在块被释放时,就合并所有的相邻块。但实际上,通常使用的是推迟合并的策略,即在稍晚的时候再合并。
当前块释放时,合并下一个空闲块很简单而且高效。但是合并前面的块需要与堆的大小成线性关系的时间来搜索整个链表。一种称为边界标记的技术允许常数时间内进行对前面块的合并,其在块的结尾添加了脚部,其就是头部的一个副本。边界标记使得许多小块会产生显著的内存开销,幸运的是,只有在前面的块是空闲时,才会需要用到脚部。
当分配器不能为请求块找到合适的空闲块,且空闲块已经最大程度的合并了,那么分配器就会通过调用sbrk()函数向内核请求额外的堆内存,并将额外的内存转化成一个大的空闲块,将其插入空闲链表中,从而将被请求的块放置在新的空闲块中。
3.5 显式空闲链表
显式空闲链表将空闲块组织为某种形式的显式数据结构,形如
有效载荷区域存放了指向前驱与后继空闲块的指针,使得堆的空闲块组织成了一个双向链表。此时,首次适配的分配时间将从块总数的线性时间减少到空闲块总数的线性时间,而新释放的块放在空闲链表位置的策略决定了释放块的时间性能,策略包括:
-使用LIFO顺序维护链表,将新释放的块放置在链表的开始处,使得释放一个块只需要常数时间,但碎片程度较高;
-使用地址顺序维护链表,释放一个块需要线性时间搜索合适的前驱,但其使得首次适配具有更高的内存利用率,接近最佳适配。
3.6 分离空闲链表
一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间分配块,而分离储存可用通过维护多个空闲块链表减少分配时间。每个链表的块有大致相等的大小,将所有可能的块大小分成一些等价类,称为大小类。通常根据2的幂来划分块的大小,形如 { 1 } , { 2 } , { 3 , 4 } , { 5 , 6 , 7 , 8 } , . . . , { 1025 , . . . , 2048 } , { 2049 , . . . , 4096 } , { 4097 , . . . , + ∞ } \{1\}, \{2\}, \{3, 4\}, \{5, 6, 7, 8\}, ..., \{1025, ..., 2048\}, \{2049, ..., 4096\}, \{4097, ..., +\infty\} { 1},{ 2},{ 3,4},{ 5,6,7,8},...,{ 1025,...,2048},{ 2049,...,4096},{ 4097,...,+∞}当分配器需要一个大小为n的块时,就搜索响应的空闲链表。如果不能找到合适的块与之匹配,就搜索下一个链表。
简单分离储存是一种简单的分离储存方法,其每个大小类的空闲链表包含大小相等的块,并且不使用分割与合并操作。在分配时,如果相应的空闲块链表为空,其会向操作系统请求额外的内存片,分为大小相等的块形成新的空闲链表,以供请求使用。
简单分离储存减少了额外空间的使用,如标记、头部等,但是由于不分割的性质,很可能造成内部碎片;而由于不合并的性质,会产生极多的外部碎片。
分离适配维护着元素为空闲链表的数组,每个空闲链表与一个大小类相关联,并且被组织成某种类型的显式或隐式空闲链表。在分配时,确定了请求的大小类,就对适当的空闲链表做首次适配,在匹配时分割,把剩余部分插入空闲链表;在不匹配时搜索更大的大小类空闲链表,直到匹配合适的块。在没有合适的块时,就像操作系统请求额外的块。分离时配在释放块时,执行合并。
C的GNUmalloc采用了分离适配,因为其快速又高效。
3.7 垃圾管理
未能释放已分配的块是常见的编程错误,考虑如下函数
void garbage(){
int *p = (int *)Malloc(15213);
return;
}
函数返回时表明不再需要p,不幸的是其在程序的生命周期内都保持为已分配状态,毫无必要的占用着本来可以用来满足后面分配请求的堆空间。
垃圾收集器是一种动态内存分配器,其自动释放程序不再需要的已分配块,这些块称为垃圾,自动回收堆储存的过程叫做垃圾收集。其是现代语言系统的一个重要部分。
垃圾收集器将内存视为有向图,结点包括根结点与堆结点,根节点对应于寄存器,栈等不存在于堆又指向了堆的指针,而每个堆结点对应一个已分配的块。图的有向边代表了数据的引用。
当存在一条从任意结点出发并到达堆结点p的有向路径时,称其为可达的。那么不可达点代表其不被任何堆外的数据引用,即垃圾。
标记清除【Mark&Sweep】算法建立在malloc包上,为C和C++程序提供垃圾收集。其算法步骤分为如下两个阶段:
1.标记,利用块头部地位中空闲的一位,从根节点开始标记所有可达的块;
2.清楚,扫描所有的块,释放没有标记可达的块。
而C与C++只提供了保守的垃圾收集器机制,其收集器不能维持图的精确表示,其正确的标记可达点,但不可达点可能错误的标记为可达。究其原因,标记清除算法需要对一个数据是否是指针进行判断,但C不会用任何类型信息来标记指针,并且指针可能指向的是有效载荷中的一部分,而不是块头部。
已分配块集合维护成一颗平衡二叉树,左子树和右子树分别存放较小地址和较大地址的块,那么就可以查找指针是否落在块内。
然而,例如int和float都可以伪装成指针,分配器无法推断,从而保守的认为其是指针从而是其指向的位置可达,以防止释放了非空闲块。