伙伴算法内存管理


伙伴算法是一种经典的内存管理算法,主要用于分配和回收物理内存页(通常是连续的页框),其核心思想是将内存分割和合并,以尽可能减少外部碎片


1. 核心思想

伙伴算法的核心在于两个操作:分割合并

  • 分割:当请求内存时,如果没有合适大小的空闲块,就将一个大的空闲块对半分割成两个较小的块。这个过程可以递归进行,直到得到所需大小的块。
  • 合并:当释放内存时,算法会检查其“伙伴”块是否也是空闲的。如果是,就将这两个伙伴块合并成一个更大的空闲块。这个过程也可以递归进行,以尽可能地减少碎片。

关键概念:什么是“伙伴”?
两个内存块被称为“伙伴”,当且仅当它们满足以下所有条件:

  1. 大小相同,记大小为 \(S\)
  2. 物理地址是连续的。
  3. 它们是由同一个大小为 \(2 \times S\) 的块分裂而来。
  4. 其中较低地址的块的起始地址必须是 \(2 \times S\) 的整数倍。

简单来说,一个块的伙伴就是和它“同出一源、大小相等、地址相邻”的那个块。


2. 工作原理

数据结构

算法使用一个空闲链表数组

  • 数组的每一个元素(即每一条链表)都对应一种特定大小的空闲块。例如:
    • free_list[0]:链接所有大小为 \(2^0 = 1\) 个页的空闲块。
    • free_list[1]:链接所有大小为 \(2^1 = 2\) 个页的空闲块。
    • free_list[2]:链接所有大小为 \(2^2 = 4\) 个页的空闲块。
    • ...
    • free_list[n]:链接所有大小为 \(2^n\) 个页的空闲块(最大块)。

内存分配过程(以页为单位)

假设系统需要分配 \(k\) 个页。

  1. 确定阶数:找到满足 \(2^i \geq k\) 的最小 \(i\)。也就是说,找到能容纳 \(k\) 个页的最小 \(2\) 的幂次方块。
  2. 查找空闲链表
    • 检查 free_list[i] 是否为空。
    • 如果非空,直接从该链表头取出一个空闲块,分配出去,过程结束。
  3. 分裂块
    • 如果 free_list[i] 为空,则向上查找,例如检查 free_list[i+1]
    • 如果 free_list[i+1] 也为空,则继续向上查找 free_list[i+2],以此类推。
    • 假设在 free_list[j] (\(j > i\)) 找到了一个空闲块。
    • 将这个大小为 \(2^j\) 的块分裂成两个大小为 \(2^{j-1}\) 的“伙伴”块。
    • 将其中一个 \(2^{j-1}\) 的块放入 free_list[j-1]
    • 对另一个 \(2^{j-1}\) 的块重复此分裂过程,直到得到大小为 \(2^i\) 的块,然后将其分配给请求者。

内存释放过程

  1. 回收块:当释放一个大小为 \(2^i\) 的块时,算法并不立即将其放回 free_list[i]
  2. 查找伙伴:算法首先计算这个被释放块的伙伴块的地址。
  3. 检查合并条件
    • 检查该伙伴块是否存在于 free_list[i] 中(即同样是空闲的)。
    • 并且,确认它确实是当前释放块的伙伴(地址相邻且由同一大块分裂而来)。
  4. 合并
    • 如果伙伴块存在且空闲,则将这两个大小为 \(2^i\) 的伙伴块从 free_list[i] 中移除。
    • 将它们合并成一个大小为 \(2^{i+1}\) 的连续块。
    • 然后,递归地对这个新合并的、大小为 \(2^{i+1}\) 的块执行释放过程(即尝试继续与它的伙伴合并)。
  5. 终止条件:当无法再合并(伙伴块不空闲或不存在)时,将最终合并成的大块放入对应的空闲链表中。

3. 一个简单的例子

假设内存初始为一个连续的 \(16\) 页的块(\(2^4\))。

  1. 请求分配 3 个页

    • 最小满足的 \(2^i\)\(2^2 = 4\) 页。
    • free_list[2] 为空,向上查找。在 free_list[4] 找到 \(16\) 页的块。
    • 分裂1\(16\) -> 两个 \(8\) 页的块。一个放入 free_list[3]
    • 分裂2\(8\) -> 两个 \(4\) 页的块。一个放入 free_list[2]
    • 将得到的这个 \(4\) 页块分配给请求者。
  2. 请求分配 2 个页

    • 最小满足的 \(2^i\)\(2^1 = 2\) 页。
    • free_list[1] 为空,向上查找。在 free_list[2] 找到刚才放入的 \(4\) 页块。
    • 分裂\(4\) -> 两个 \(2\) 页的块(互为伙伴)。一个放入 free_list[1]
    • 将另一个 \(2\) 页块分配给请求者。
  3. 释放第一个 4 页块

    • 计算其伙伴地址。发现其伙伴(另一个 \(4\) 页块)正在被使用(被分裂成了两个 \(2\) 页块,其中一个已分配,一个在空闲链表),无法合并
    • 因此,直接将这个释放的 \(4\) 页块放入 free_list[2]
  4. 释放第二个 2 页块

    • 计算其伙伴地址。发现其伙伴(另一个 \(2\) 页块)正好在 free_list[1] 中,是空闲的。
    • 合并1:将这两个 \(2\) 页块合并成一个 \(4\) 页块。
    • 现在,这个新 \(4\) 页块,它的伙伴(我们之前在步骤3中释放的)正好在 free_list[2] 中,是空闲的。
    • 合并2:将这两个 \(4\) 页块合并成一个 \(8\) 页块,放入 free_list[3]

通过这个过程,内存碎片被有效地合并了。


4. 优缺点

优点

  1. 有效避免外部碎片:通过“伙伴”合并机制,能够快速地将小块合并成大块,极大地减少了外部碎片。
  2. 分配和释放速度快:由于搜索的空闲链表是固定的,算法效率是 \(O(\log n)\),在内存块数量很大时依然表现良好。
  3. 实现相对简单:概念清晰,数据结构不复杂。

缺点

  1. 内部碎片问题:由于分配的内存块大小必须是 \(2\) 的幂次方,如果请求的内存大小刚好比某个 \(2^i\) 大一点,比如 \(129KB\),但系统只能分配 \(256KB\) 的块,这就会导致严重的内部碎片。
  2. 空间浪费:为了满足 \(2\) 的幂次方要求,平均会有 \(25\%\) 的内存浪费。
  3. 拆分与合并开销:频繁的分割和合并操作会带来一定的计算开销。

5. 实际应用

伙伴算法最著名的应用是在 Linux 内核中,用于管理物理页框的分配,即所谓的 “页分配器”

  • Linux 对其进行了优化,形成了 伙伴系统
  • 为了解决内部碎片问题,Linux 在伙伴系统之上增加了 slab 分配器(或 slub/slob)。Slab 分配器从伙伴系统获取大块内存(通常是页的整数倍),然后自己管理,切割成一个个细小的对象(如 task_struct, inode 等)分配给内核使用,从而极大地减少了内核对象的分配和初始化开销,并解决了小内存分配的内部碎片问题。

总结

伙伴算法是一种以空间换时间换规整性的算法。它通过强制使用 \(2\) 的幂次方大小的块,并利用“伙伴”关系进行高效的合并,成功地解决了外部碎片问题,成为许多现代操作系统内存管理的基石。尽管存在内部碎片的缺陷,但通过与其他分配器(如 Slab)协同工作,它在实践中取得了巨大的成功。

posted @ 2025-11-25 17:43  guanyubo  阅读(2)  评论(0)    收藏  举报