4. 固定大小内存块管理(伙伴算法)

伙伴算法,这个名字听起来挺有「团队合作」的感觉。说白了,它是一种专门用来管理固定大小内存块的算法。你可能会问:为什么需要固定大小的管理?动态分配不是更灵活吗?

嗯,这个问题我当年也困惑过。直到我在一个物联网项目里,发现系统频繁地分配和释放小内存块,导致内存碎片化严重,系统跑着跑着就挂了。那时候我才意识到,对于实时系统来说,确定性比灵活性更重要。伙伴算法,就是为解决这个问题而生的。

4.1 伙伴算法的核心思想

伙伴算法的思路其实很朴素:把内存不断地对半切分,直到切出合适的大小。就像你有一块大蛋糕,要分给不同食量的人——先切成两半,如果还不够小,再切一半,直到大小合适。

我习惯把这种结构想象成一棵二叉树。根节点是整块内存,左子树和右子树互为「伙伴」。每次分配时,从根节点往下找,找到大小合适的块就切出来。释放时,如果发现自己的「伙伴」也是空闲的,就把两块合并回去。

关键概念:伙伴(Buddy)是指由同一块大内存对半切分出来的两个大小相等、地址相邻的内存块。它们互为伙伴,可以合并回原来的大块。

举个例子。假设我们有 1MB 的连续内存,系统支持的最小块是 64KB。那么内存会被组织成这样的层级:

层级 块大小 块数量
0 1MB 1
1 512KB 2
2 256KB 4
3 128KB 8
4 64KB 16

每个层级维护一个空闲链表。分配时从对应层级取,释放时尝试合并。

4.2 分配流程:从大到小找合适块

分配流程其实就三步,我总结为「找、切、给」:

  1. :从请求大小对应的层级开始,向上查找空闲块。如果当前层级有空闲块,直接取走。
  2. :如果当前层级没有空闲块,就向上找更大的块。找到后,把大块对半切分,一半放入下一层级,另一半继续切,直到切出合适大小。
  3. :把最终切出来的块分配给请求者。

举个例子。假设要分配 64KB 的内存,而当前 64KB 层级没有空闲块,但 256KB 层级有一个空闲块。那么:

  • 把 256KB 块切成两个 128KB 块(互为伙伴)
  • 一个 128KB 块放入 128KB 层级
  • 把另一个 128KB 块再切成两个 64KB 块
  • 一个 64KB 块分配给请求者,另一个放入 64KB 层级

你看,整个过程就像剥洋葱,一层层切下去。我当年第一次实现这个算法时,被递归搞得头晕。后来发现用循环加位运算其实更高效。

我的经验:分配时一定要检查请求大小是否超过最大块。我曾经在一个项目中忘了做这个检查,结果系统试图分配比总内存还大的块,直接崩溃。嗯,从那以后我就在分配函数入口加了个断言。

4.3 释放流程:检查伙伴,能合则合

释放比分配稍微复杂一点,因为要处理合并。核心逻辑是:

  1. 把释放的块放回对应层级的空闲链表
  2. 检查它的伙伴是否也在空闲链表中
  3. 如果是,把两个块从链表移除,合并成一个大块,放入上一层级
  4. 重复步骤 2-3,直到无法合并为止

这里的关键是:如何快速找到伙伴的地址?

伙伴算法有个数学上的巧妙之处。假设块大小为 size,块起始地址为 addr,那么它的伙伴地址就是:

buddy_addr = addr ^ size

没错,就是一个异或操作。为什么?因为伙伴块和当前块的地址,只在第 log2(size) 位上不同。用异或把这一位翻转,就得到了伙伴的地址。

举个例子。假设 64KB 块 A 的起始地址是 0x10000,那么它的伙伴 B 的地址就是:

0x10000 ^ 0x10000 = 0x20000

是不是很优雅?我每次看到这个设计都忍不住感叹:计算机科学里真的有很多美丽的数学。

注意:伙伴算法要求所有内存块的大小必须是 2 的幂次。这意味着你可能会浪费一些内存。比如你只需要 30KB,但系统只能给你 64KB 的块。这就是所谓的「内部碎片」。

4.4 优缺点分析

任何算法都有取舍。伙伴算法也不例外。我根据自己的项目经验,总结如下:

优点

  • 分配和释放速度快:时间复杂度是 O(log N),N 是内存块数量。对于实时系统来说,这个确定性非常重要。
  • 外部碎片少:因为块大小是固定的,而且伙伴之间可以合并,所以不会出现小碎片散落各处的情况。
  • 实现简单:核心逻辑就是二叉树遍历加位运算,代码量不大。
  • 可预测性强:最坏情况下的执行时间是可以计算的,这对硬实时系统很关键。

缺点

  • 内部碎片:这是最大的问题。比如你只需要 10KB,但系统只能给你 16KB 的块,浪费了 6KB。在内存紧张的系统里,这个浪费可能很致命。
  • 内存对齐要求高:所有块的起始地址必须是其大小的整数倍。这限制了内存布局的灵活性。
  • 合并操作可能耗时:虽然平均情况很快,但最坏情况下(比如释放一个块后一路合并到根节点),需要遍历多个层级。
  • 不适合任意大小分配:如果你需要分配各种不同大小的内存,伙伴算法的效率会下降。这时候可能需要考虑 slab 分配器或其他方案。

我的建议:伙伴算法最适合那些内存分配大小相对固定、对实时性要求高的场景。比如网络协议栈的报文缓冲区、文件系统的缓存池。如果你需要分配各种大小的内存,可以考虑把伙伴算法和 slab 分配器结合起来使用。

4.5 一个简单的实现示例

下面是一个简化版的伙伴算法实现,只展示核心逻辑:

#define MAX_ORDER 10  // 最大层级
#define MIN_BLOCK_SIZE 64  // 最小块大小

struct buddy_block {
    struct buddy_block *next;
    int order;  // 层级
    int free;   // 是否空闲
};

struct buddy_system {
    struct buddy_block *free_lists[MAX_ORDER + 1];
    void *base_addr;
    size_t total_size;
};

// 分配:从指定层级开始找
void *buddy_alloc(struct buddy_system *sys, size_t size) {
    int order = get_order(size);  // 计算需要的层级
    if (order > MAX_ORDER) return NULL;

    // 从当前层级向上找空闲块
    for (int i = order; i <= MAX_ORDER; i++) {
        if (sys->free_lists[i] != NULL) {
            // 找到空闲块,从链表移除
            struct buddy_block *block = sys->free_lists[i];
            sys->free_lists[i] = block->next;

            // 如果层级比需要的大,切分
            while (i > order) {
                i--;
                // 把块对半切,一半放回空闲链表
                struct buddy_block *buddy = (struct buddy_block *)((char *)block + (1 << i));
                buddy->order = i;
                buddy->free = 1;
                buddy->next = sys->free_lists[i];
                sys->free_lists[i] = buddy;
            }

            block->free = 0;
            block->order = order;
            return block;
        }
    }
    return NULL;  // 没有足够内存
}

// 释放:检查伙伴,尝试合并
void buddy_free(struct buddy_system *sys, void *ptr) {
    struct buddy_block *block = (struct buddy_block *)ptr;
    int order = block->order;

    while (order < MAX_ORDER) {
        // 计算伙伴地址
        struct buddy_block *buddy = (struct buddy_block *)((char *)block ^ (1 << order));

        // 检查伙伴是否空闲且层级相同
        if (buddy->free && buddy->order == order) {
            // 从空闲链表移除伙伴
            remove_from_list(sys->free_lists[order], buddy);
            // 合并:取地址较小的块作为新块
            if (buddy < block) block = buddy;
            order++;
        } else {
            break;
        }
    }

    // 把合并后的块放回空闲链表
    block->free = 1;
    block->order = order;
    block->next = sys->free_lists[order];
    sys->free_lists[order] = block;
}

这个实现省略了一些细节,比如地址对齐检查、链表操作函数等。但核心逻辑都在了。你想想看,整个算法最巧妙的地方就是那个异或操作——一行代码就找到了伙伴的地址。

避坑指南:我曾经在一个项目中,因为忽略了伙伴块必须「大小相同且地址相邻」这个条件,导致合并时把两个不相邻的块合并了,结果内存管理彻底乱掉。调试了两天才找到问题。所以,一定要验证伙伴关系

4.6 伙伴算法的可视化

为了让你更直观地理解,我画了一张图。这张图展示了 1MB 内存的伙伴树结构,以及分配和释放的过程。

伙伴算法内存管理示意图 1MB (层级0) 512KB (层级1) 512KB (层级1) 256KB 256KB 256KB 256KB 128KB 128KB 128KB 128KB 128KB 128KB 128KB 128KB 分配示例:请求64KB 从层级3(128KB)切分 → 得到两个64KB块,一个分配,一个放入空闲链表 释放示例:释放64KB块 检查伙伴是否空闲 → 是则合并为128KB → 继续检查上一级伙伴 → 直到无法合并

这张图展示了伙伴算法的核心结构。你可以看到,内存被组织成二叉树的形式。分配时从叶子节点往上找,释放时从叶子节点往下合并。整个过程就像在玩一个「内存俄罗斯方块」——只不过这里的方块大小都是 2 的幂次。

4.7 总结

伙伴算法是实时系统中非常经典的内存管理方案。它的核心优势在于确定性和高效性,特别适合那些分配大小相对固定的场景。当然,内部碎片问题是它的软肋,使用时需要权衡。

我个人觉得,伙伴算法最值得学习的地方不是它的实现细节,而是它背后的思想:用空间换时间,用确定性换灵活性。在实时系统中,有时候「慢但可预测」比「快但不可预测」更重要。这个道理,我是在经历过几次系统崩溃后才真正理解的。

如果你正在设计一个实时系统,不妨考虑把伙伴算法作为内存管理的基础。配合 slab 分配器处理小对象,配合页分配器处理大块内存,可以构建一个高效且可靠的内存管理体系。


公众号:蓝海资料掘金营,微信deep3321