4. 内存回收与合并:相邻空闲块的合并机制

内存回收,说白了就是把用完了的块还回去。但还回去就完事了吗?没那么简单。你想想看,如果每次回收都不管相邻的块,内存很快就会碎成一片一片的——这就是我们常说的「内存碎片化」。

我个人习惯把内存回收比作拼图。你手里有几块拼图碎片,如果它们刚好能拼在一起,为什么不把它们粘起来呢?合并相邻空闲块,就是这个道理。

4.1 为什么需要合并?

先看一个场景。假设我们有一个 1MB 的显存池,初始是一整块空闲内存。程序依次申请了 256KB、256KB、256KB,然后释放了第一块和第三块。这时候内存布局是这样的:

| 空闲(256KB) | 已用(256KB) | 空闲(256KB) | 空闲(256KB) |

注意看最后两块。它们都是空闲的,而且物理上相邻。如果不合并,你最多只能分配 256KB 的块。合并之后,你就有 512KB 的连续空间了。

核心观点:合并操作直接决定了内存池的抗碎片能力。不合并,大块申请迟早会失败。

我在项目中遇到过这样一个坑:一个深度学习推理服务跑了两天,突然报 OOM。查了半天,发现显存池里碎片化严重,总空闲空间还有 300MB,但最大连续块只有 64MB。而新来的请求需要 128MB——直接挂了。这就是不合并的后果。

4.2 合并的时机

什么时候做合并?有三种策略:

  1. 立即合并:每次释放内存时,立刻检查相邻块并合并。实现简单,但频繁操作可能影响性能。
  2. 延迟合并:先标记释放,等到分配失败或达到阈值时再统一合并。性能好,但实现复杂。
  3. 按需合并:只在分配大块时触发合并。折中方案,我比较常用。

我个人习惯用立即合并。为什么?因为显存池的分配释放频率通常不高,而且立即合并能让内存状态始终保持最优。你想想看,如果等到分配失败才合并,那调用方就得等——这在实时性要求高的场景下是不能接受的。

4.3 合并的条件与实现

合并需要满足两个条件:

  • 两个块在物理地址上相邻
  • 两个块都是空闲状态

具体实现时,我们通常用双向链表来管理空闲块。每个块头部保存大小和前后指针。释放一个块时,检查它的前驱和后继:

// 伪代码:合并相邻空闲块
void free_block(Block* block) {
    block->free = true;
    
    // 检查前一个块是否空闲
    if (block->prev != NULL && block->prev->free) {
        // 合并当前块到前一个块
        block->prev->size += block->size;
        block->prev->next = block->next;
        if (block->next != NULL) {
            block->next->prev = block->prev;
        }
        block = block->prev;  // 更新当前块指针
    }
    
    // 检查后一个块是否空闲
    if (block->next != NULL && block->next->free) {
        // 合并后一个块到当前块
        block->size += block->next->size;
        block->next = block->next->next;
        if (block->next != NULL) {
            block->next->prev = block;
        }
    }
}

小技巧:合并时记得更新链表指针。我曾经漏掉了更新 prev 指针,结果链表断开了,排查了半天才发现。

4.4 合并的边界情况

嗯,这里要注意几个边界情况:

  • 头尾块:第一个块没有前驱,最后一个块没有后继。检查时要做空指针判断。
  • 单一块:如果整个池子只有一个空闲块,合并操作什么都不做。
  • 连续释放:连续释放多个相邻块时,合并操作会逐级传递。比如释放块 B,它和 A 合并;再释放块 C,它和 (A+B) 合并。最终三个块合成一个。

我曾经在写合并逻辑时犯过一个低级错误:释放块时只检查了后一个块,没检查前一个块。结果内存池里出现了「背靠背」的空闲块——明明相邻,却各自独立。这个 bug 藏了两周才被发现。

4.5 合并的代价

合并不是免费的。每次合并都要:

  • 读取相邻块的元数据(内存访问)
  • 修改链表指针(写操作)
  • 更新空闲块大小(计算)

这些操作虽然都是 O(1) 的,但频繁执行也会累积开销。我建议在性能敏感的场景下,可以加一个「合并阈值」:只有当空闲块数量超过某个值(比如 100 个)时才触发合并。这样既能控制碎片,又不会过度消耗性能。

警告:不要在高频分配释放的路径上做合并。比如每帧分配释放数千次小块的图形渲染场景,立即合并会拖垮性能。这时候用延迟合并更合适。

4.6 可视化:合并流程

下面这张图展示了释放块 B 时,合并前驱 A 和后继 C 的完整流程:

相邻空闲块合并流程 合并前: 块 A(空闲) 块 B(刚释放) 块 C(空闲) 合并中: 合并中... 更新链表指针 + 累加大小 合并后: 合并块(A+B+C,大小 = 原A + 原B + 原C)

从图中可以看到,合并的本质就是:把三个相邻的空闲块「粘」成一个更大的块。链表指针从三个节点变成两个节点(如果 A 和 C 原本不相邻,则变成两个节点),最终变成一个节点。

4.7 总结

内存回收与合并,是显存池抗碎片化的核心手段。没有合并机制,内存池迟早会退化成一个「看起来有空间,实际上用不了」的废物。

我个人建议:立即合并 + 双向链表 是最稳妥的方案。它实现简单、逻辑清晰、行为可预测。等你把这一套跑通了,再考虑延迟合并之类的优化也不迟。

记住一句话:合并不是锦上添花,而是雪中送炭。 没有合并的内存池,就像没有垃圾回收的系统——迟早会把自己玩死。