第 17 章 空闲空间管理

本章讨论内存管理系统(如 malloc 库或操作系统分段管理)中的空闲空间管理(Free-space Management)

当空闲空间由不同大小的单元构成时,会出现外部碎片(External Fragmentation):空闲空间被分割成许多不连续的小块,导致即使总空闲空间足够,也无法满足较大的连续分配请求。

核心问题

如何高效管理变长的分配请求?采用什么策略能最小化碎片,并平衡时间和空间开销?

核心假设

本章主要讨论用户级内存分配库(如 mallocfree)的空闲空间管理,并基于以下假设:

  • 基本接口malloc(size) 请求指定字节空间并返回指针;free(ptr) 释放空间。库必须能仅凭指针推断出内存块大小。
  • 空闲列表(Free List):用于追踪堆中所有空闲内存块的数据结构。
  • 关注外部碎片:主要解决外部碎片问题。虽然分配过大块会导致内部碎片(已分配块内部的浪费),但本章重点在于如何避免无法满足连续分配请求。
  • 不可重定位:一旦内存分配给程序,其位置固定,库无法通过**紧凑(Compaction)**操作来减少碎片。
  • 固定区域:假设分配程序管理的是一块大小固定的连续字节区域。

底层机制

在深入策略之前,需要理解分配程序的三个通用机制:分割、合并以及空间追踪。

分割与合并(Splitting and Coalescing)

空闲列表(Free List)记录堆中未分配的区域。

  • 分割(Splitting):当请求的空间小于某个空闲块时,分配程序将该块分割,一部分返回给用户,剩余部分留在空闲列表中。
    • 示例:在 10 字节的空闲块中申请 1 字节,该块起始地址由 20 变为 21,长度变为 9。

  • 合并(Coalescing):当释放内存时,如果新归还的空间与原有的空闲块地址相邻,分配程序会将它们合并为一个较大的连续空闲块,以解决“假碎片”问题。
    • 示例:释放中间 10 字节后,若不合并会产生三个 10 字节碎片;合并后则恢复为 30 字节的连续空间。

追踪已分配空间的大小

free(ptr) 接口不包含大小参数,因此库需要通过**头块(Header)**来追踪分配信息。头块通常存放在返回给用户的内存块之前。

  • 头块内容:至少包含内存块的大小,通常还包含一个**幻数(Magic Number)**用于完整性检查。
typedef struct header_t { 
    int size; 
    int magic; 
} header_t; 
  • 释放流程:调用 free(ptr) 时,库通过指针运算找到头块,验证幻数并获取块大小。
void free(void *ptr) { 
    header_t *hptr = (void *)ptr - sizeof(header_t); 
}

额外开销

实际释放和分配的空间是“用户请求大小 + 头块大小”。这意味着头块会带来一定的空间开销。

嵌入空闲列表

在内存分配库中,无法通过 malloc() 来创建空闲列表节点。相反,我们需要在空闲空间内部建立列表。

1. 初始化

假设有一个 4KB 的堆(通过 mmap() 获取),我们需要在其中初始化第一个空闲节点。

typedef struct node_t {
    int size;
    struct node_t *next;
} node_t;
 
// 初始化堆:将第一个空闲节点嵌入空闲空间起始处
node_t *head = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE, -1, 0);
head->size = 4096 - sizeof(node_t);
head->next = NULL;

2. 分配与分割

当请求 100 字节时,库会找到足够的空闲块并进行分割:

  • 分配 108 字节(100 字节数据 + 8 字节头块)。
  • 剩余空间更新为一个新的 node_t,留在空闲列表中。

3. 释放与碎片化

当内存被释放时,库将释放的块插回空闲列表。如果没有 合并(Coalescing) 机制,空闲列表会变得非常破碎。

4. 合并

通过遍历列表并合并物理地址相邻的块,可以将破碎的堆重新恢复为连续的整体。

让堆增长

当堆空间耗尽时,分配程序通常有两种选择:

  • 返回失败:返回 NULL 指针。
  • 向操作系统申请更多空间:大多数系统支持通过系统调用(如 UNIX 中的 sbrk)来增加堆的大小。操作系统会映射新的物理内存页到进程地址空间,分配程序随后将新空间纳入空闲列表管理。

基本策略

理想的分配程序应兼顾速度碎片最小化。以下是几种常见的空闲空间搜索策略:

策略描述优点缺点
最优匹配 (Best Fit)遍历列表,选择能满足请求且最小的空闲块。尽量减少空间浪费。遍历开销大;容易产生大量微小碎片。
最差匹配 (Worst Fit)遍历列表,选择最大的空闲块进行分割。剩余块较大,可能更有用。遍历开销大;研究表明碎片化严重。
首次匹配 (First Fit)找到第一个足够大的块即返回。查找速度快。列表开头容易堆积小碎片。
下次匹配 (Next Fit)类似首次匹配,但从上次查找结束的位置开始。查找负载均匀分布。性能与首次匹配接近。

策略示例

假设空闲列表包含 10、30、20 字节的块,请求 15 字节:

  • 最优匹配:选择 20 字节块,剩余 5 字节。

  • 最差匹配:选择 30 字节块,剩余 15 字节。

  • 首次匹配:在本例中也会选择 30 字节块(第一个满足条件的块),但查找开销更低。

其他方式

除了基本策略,还有一些更高级的技术来优化分配性能和简化合并。

分离空闲列表(Segregated List)

  • 核心思想:为频繁申请的特定大小的对象维护独立的空闲列表。
  • 优点:分配和释放极快;在该特定大小范围内无碎片。
  • Slab Allocator:Solaris 中的厚块分配程序。它在内核启动时为常用对象(如 inode)创建对象缓存。当缓存不足时,向通用分配程序申请“厚块(slab)”;当 slab 内对象引用归零时,回收空间。此外,它通过保持对象在“预初始化”状态来降低开销。

伙伴系统(Buddy System)

  • 二分伙伴分配程序:空闲空间被视为 的大块。
  • 分配流程:递归地将块一分为二,直到刚好满足请求大小。
  • 示例:64KB 空间切分以满足 7KB 请求(最终分配 8KB)。

  • 合并流程:释放块时,检查其“伙伴”块是否也空闲。如果是,则合并并继续递归上溯。
  • 优点:合并极其简单(伙伴块地址仅有一位不同)。
  • 缺点:由于只能分配 2 的幂次大小,存在内部碎片

其他优化方向

  • 可扩展性:使用平衡二叉树、伸展树等复杂数据结构加速查找。
  • 多核支持:针对多线程环境优化分配程序,减少锁竞争。

小结

内存分配程序需要在速度空间效率可扩展性之间进行权衡。了解具体的工作负载有助于选择或调整最合适的分配策略。在现代多核系统中,构建高效的分配程序仍是挑战。