9.9.13 显式空闲链表
隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不合适的(尽管对于堆块数量预先就知道是很小的特殊的分配器来说它是可以的)
一种更好的方法是将空闲块组织为某种形式的显示数据结构。根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。 例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)组织,图9-48
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用 后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,潜在地提高了内部碎片的程度。
9.9.14 分离的空闲链表
正如看到的,一个使用单向空闲块链表的分配需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少时间分配的方法,称为 分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。有很多种方式来定义大小类。例如,可以根据 2 的幂来划分块大小:
{1},{2},{3,4},{5~8},,,,,{1025~2048},{2048~4096},{4097~∞}
或者可以将小的块分配到它们自己的大小类里,而将大块按照2的幂分类:
{1},{2},{3},,,,,{1023},{1024},{1025~2048},{2048~4096},{4097~∞}
分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为 n 的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。
有关动态内存分配的文献描述了几十种分离内存的方法,主要的区别在于它们如何定义大小类,何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割,等等。为了使你大致了解有哪些可能性,我们会描述两种基本的方法:简单分离存储和分离适配。
1.简单分离存储
使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类种最大元素的大小。例如,如果某个大小类定义为{17-32},那么这个类的空闲链表全由大小为 32 的块组成。
为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。 如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片(通常是页大小的整数倍),将这个片分成大小相等的块,并将这些块链接起来形成一个新的空闲链表。要释放一个块,分配器只需要简单地将这个块插入到相应的空闲链表的前部。
这种简单的方法有许多优点,分配和释放块都是很快的常数时间操作。而且,每个片中都是大小相等的块,不分割,不合并,这意味着每个块只有很少的内存开销。由于每个片只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有合并,所以已分配块的头部不需要一个已分配