之前我们介绍过关于程序加载的详细内容,我们知道在其加载执行之前要对程序进行存储器映射,Unix进程可以使用mmap函数来创建新的虚拟存储器区域,并将对象映射到这些区域。
Mmap函数要求内核创建一个新的虚拟存储器区域,最好是从start开始的地址,并将文件描述fd标识对象的一个连续的片映射到这个新的区域。连续的对象片大小为length,从距文件开始处偏移量为offset的地方开始。Prot指定了新创建的虚拟页面的访问位权限(之前提到过的虚拟页面的读写、执行权限)。最后,falgs字段描述的是被映射对象的类型,可以用来标记匿名对象,私有、写时拷贝对象和共享对象。调用mmap函数成功后就会返回对应新区域的地址。
和mmap相对应,munmap函数用来删除虚拟存储器的区域:
Munmap函数删除从虚拟地址start开始的,由接下来length字节组成的区域,对已删除区域的引用会引起段错误。
我们可以通过mmap和munmap来创建和删除虚拟存储器区域,但对开发人员来说使用起来并不方便,况且没有很好的移植性,所以提出了使用动态存储分配器来管理进程空间中的堆区域。
动态存储分配器维护着一个进程的虚拟存储器区域,称为堆。堆是从低位地址向高位向上增长的,对于每个进程,内核维护着一个brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟存储器片,要么是已分配的,要么是空闲的。已分配的显示地保留为供应应用程序使用。空闲块可用来分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是存储分配器隐式执行的,它们的都是显式的来分配存储块的,不同之处在于由哪个实体来负责释放已分配的块。
a) 显式分配器,要求显式的释放已分配的块。如C标准库中的malloc和free,C++中的new和delete操作符。
b) 隐式分配器,要求分配器检测一个已分配的块何时不再被程序使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(Grabage collector)。如java语言就依赖于类似分配器。
下面我们看下malloc和free的实现是如何管理一个C程序的16字的小堆的。每个方框代表一个4字节的字。粗线标出的矩形对应于已分配块(有阴影)和空闲块(无阴影),初始时,堆是由一个大小为16个字的、双字对齐的、空闲块组成的。
a) 程序请求一个4字的块,malloc的响应是:从空闲块的前部切出一个4字的块,并返回一个指向这个块的第一个字的指针p1
b) 程序请求一个5字的块,malloc的响应是:从空闲块的前部分配一个6字的块,返回指针p2,填充的一个额外字是为了保持空闲块是双字边界对齐的。
c) 程序请求一个6字的块,而malloc就从空闲块的前部切出一个6字的块。返回指针p3
d) 程序释放在b中分配的那个6字的块。需要注意的是,在调用free返回之后,指针p2仍然指向被释放的块,在它被一个新的malloc调用重新初始化之前不能在程序中再使用p2.
e) 程序请求一个2字的块。在这种情况下,malloc分配在前一步中释放了的块的一部分,并返回指向新块的指针p4.
显式分配器必须在一些相当严格的约束条件下工作:
(1) 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配的块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。
(2) 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。
(3) 只使用堆。分配器使用的任何非标量数据结构必须保存在堆里。
(4) 对齐块。分配器必须对齐块,使得它们可以保存任何类型的数据对象。在大多数系统中,分配器返回的块是8字节(双字)边界对齐的。
(5) 不修改已分配的块。分配器只能操作或者改变空闲块。特别地,一旦块被分配了,就不能修改或者移动它了。
在这些限制条件下,分配器的编写者视图实现吞吐率最大化和存储器使用率最大化,而这性能目标通常是相互冲突的。
1> 最大化吞吐率。假定n个分配和释放请求的某种序列:
R0,R1,……Rk,…..Rn-1 ,吞吐率的定义为每个单位时间里完成的请求数。例如,如果一个分配器在1秒钟内完成500个分配请求和500个释放请求,那么它的吞吐率就是每秒1000次操作。一般而言,我们可以通过使满足分配和释放请求的平均时间最小化来使吞吐率最大化。
2> 最大化存储器利用率。描述一个分配器使用堆的效率的标准是峰值利用率,如上我们给定的分配和释放请求顺序R0,R1,…Rk,……Rn-1。如果一个应用程序请求一个P字节的块,那么得到的已分配的块的有效载荷是p字节。在请求Rk完成之后,聚集有效载荷表示为Pk,为当前一分配块的有效载荷之和,而Hk表示对的当前的大小。那么,前k个请求的峰值利用率表示为Uk,可以通过下式得到:
Uk=(maxi
可以看出,在最大化吞吐率和最大化利用率是相互牵制的。分配器设计中的挑战之处就是在两个目标之间找到一个合适的平衡。
造成堆利用率很低的主要原因是一种称为碎片的现象,当虽然有未使用的存储器但不能用来满足分配请求时,就会发生这种现象。有两种形式的碎片:内部碎片和外部碎片。
内部碎片是在一个已分配的块比有效载荷大时发生的。例如,在9-34b中,分配器为了满足对其约束添加额外的1字的存储空间,这个1字的空间就是内部碎片。
外部碎片是当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。例如,在图9-34e中请求6个字,那么如果不向内核请求额外的虚拟存储器就无法满足这个请求,即使在堆中仍然有6个空闲的字。但是这6个字是分在两个空闲块中的。
任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入在块本身。如下:
上图的结构中,一个块是由一个字的头部、有效载荷,以及一些可能的填充组成。
头部编码了这个块的大小,以及这个块是已分配还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要存储块大小的29个高位,释放剩余的3位来编码其他信息。例如,假设我们有一个已分配额块,大小为24(0x18)字节。那么它的头部将是
0x00000018 |0x1=0x00000019
那么,根据上面的块格式,我们可以将堆组织为一个连续的已分配块的空闲块序列。
我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含连接着的。
分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块集合。这里,我们设置了已分配位而大小为0的终止头部来代表链表中的结束块。
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所有请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。
首次适配是从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式较简单而快捷,但是缺点是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。
然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。
如果分配器不能为请求块找到合适的空闲块会发生什么呢?一个选择是通过合并那些在存储器中物理上相邻的空闲块来创建一些更大的空闲块。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会向内核请求额外的存储器。分配器将额外的存储器转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
当分配器释放一个已分配块时,可能有其他空闲块于这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成小的、无法使用的空闲块。如下图,我们释放掉9-37中分配的块,结果是两个相邻的空闲块,每个有效负载都为3个字。因此,接下来一个对4个字的有效载荷的请求就会失败,即使两个空闲块的合计大小足够大,可以满足这个请求。
为了解决假碎片的问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并。分配器一般可以选择立即合并,也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并,也就是等到某个稍晚的时候再合并空闲块。需要特别注意的是,立即合并简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种的抖动,块会反复地合并,然后马上分割。
分配器如何实现合并呢?假设我们称想要释放的块为当前块。那么合并下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并。
但我们该如何合并前面的块呢?给定一个带头的隐式空闲链表,唯一的选择将是搜索整个链表。记住前面块的位置,直到我们打到当前块。使用隐式空闲链表,这意味着每次调用free需要的时间都于堆的大小呈线性关系。即使使用更复杂精细的空闲链表组织,搜索时间也不会是常数。
Knuth提出一种聪明而通用的技术,叫做边界标记,允许在常数时间内进行对前面块的合并,这种思想,是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如下所示:
如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当期块开始位置一个字的距离。那么,分配器释放当前块时存在四种可能情况:
(1) 前面的块和后面的块都是已分配的
(2) 前面的块是已分配的,后面的块是空闲的
(3) 前面的块是空闲的,而后面的块是已分配的
(4) 前面的和后面的块都是空闲的。
下图,展示了这四种情况合并的过程:
边界标记帮我们解决了空闲块合并的问题,对于不同类型的分配器和空闲链表组织都是通用的,然而,他还存在一个潜在的缺陷。它要求每个块都保持一个头部和脚部,在应用程序中操作许多小块时,会产生显著的存储器开销。例如,在一个图形应用中反复的调用malloc和free来动态创建和销毁图形节点,并且每个图形节点都只要求两个存储器字,那么头部和脚部将占用每个已分配块的一半空间。
隐私空闲链表为我们提供了一种简单的介绍一些基本分配器概念的方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不合适的。而一种更好的方式是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块位置放在链表的开始出。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的存储器利用率,接近最佳适配的利用率。
一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小。也潜在地提高了内部碎片的程度。
一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般思路是将所有可能的块大小分成一些等价类,也叫做大小类。
有很多方式来定义大小类,例如,我们可以可以根据2的幂来划分块大小:
{1},{2},{3,4},{5~8},…..{1025~2048},{2049~4096},{4096~∞}。
分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果它不能找到合适的块与之匹配,他就搜索下一个链表,以此类推。
对于简单分离存储,每个大小类的空心啊链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。例如,如果某个大小类定义为{17~32},那么这个类的空闲链表全由大小为32的块组成。
为了分配一个给定大小的块,我们需要检查相应的空闲链表。如果链表非空,我们简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统请求一个固定大小的额外存储器片,然后将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表,要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。
使用分离适配,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。
为了分配一个块,我们必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果我们找到了一个,那么我们可以分割它,并将剩余的部分插入到适当的空闲链表中。如果我们找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表没有合适的块,那么就向操作系统请求额外的堆存储器,从这个新的堆存储器中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
作为分离适配的一种特例,伙伴系统中每个大小类都是2的幂。基本的思路是假设一个堆的大小为2m个字,我们为每个块大小2k维护一个分离空闲链表。其中0<=k<=m。请求块大小向上舍入到最接近的幂。最开始时,只有一个大小为2m个字的空闲块。
为了分配一个大小为2k的块,我们找到第一个可用的、大小为2j的块,其中k<=j<=m。
如果j=k,那么我们就完成了。否则,我们递归地而分割这个块,直到j=k。当我们进行这样的分割时,每个剩下的半块(伙伴)被放置在相应的空闲链表中。要释放一个大小为2k块,我们继续合并空闲的伙伴。当我们遇到一个已分配的伙伴时,我们就停止合并。
关于伙伴系统的一个关键事实是,给定地址和块的大小,很容易计算出它的伙伴的地址。例如,地址
xxxx…x00000
他的伙伴的地址为
xxxx…x10000
换句话说,一个块的地址和它的伙伴的地址只有一位不相同。
垃圾收集器(gargagecollector)是一种动态存储器分配器,它自动释放程序不再需要的已分配块。这些块称为垃圾。自动回收堆存储的过程叫做垃圾收集。在java 虚拟机中就使用了类似的机制,应用显式分配堆块,但是从不显式地释放他们。垃圾收集器定期识别垃圾块,并相应地调用free,将这些块放回到空闲链表中。
垃圾收集器将存储器视为一张有向可达图,该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中的一个已分配块。有向边p->q意味着块p中的某个位置指向块q中的某个位置。根节点对应于这样一种不在堆中的位置,他们中包含指向堆中指针。这些位置可以是寄存器、栈里的变量,或者是虚拟存储器中读写数据区域内的全局变量。
当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色就是维护可达图的某种表示,并通过释放不可达节点将它们返回给空闲链表,来定期地回收它们。
像C/C++程序的收集器是保守的,其根本原因是C/C++不会用类型信息来标识存储器位置,因此,像int或者float这样的标量可以伪装成指针。类似这样语言的收集器通常不能维持可达图的精确表示。不过我们可以考虑将一个C程序的保守的收集器加入到已存在的malloc包中,如下图示:
无论何时需要堆空间,应用都会用通常的方式调用Malloc,如果Malloc找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过free函数将它们返回给堆,关键的思想是收集器替代应用去调用free。当对收集器的调用返回时,malloc重试,视图发现一个合适的空闲块。如果还是失败了,那么它就会向操作系统要求额外的存储器。最后,malloc返回一个执行请求块的指针或者返回一个空指针。
Ok,关于动态存储器分配的内容就介绍这么多,可以看到我并没详细介绍其中用到的某些算法,只是描述大概的原理结构,至于像malloc标准库函数或者 GC的详细实现,如果感兴趣可以自行研究,后面我会简单的写出一个模型来对本篇所讲的内容进行一个补充和完善。
更新
基于隐式链表的malloc简单模拟实现参考 http://download.csdn.net/detail/u012960981/7190851