4. 内存回收与合并:相邻空闲块的合并机制
内存回收,说白了就是把用完了的块还回去。但还回去就完事了吗?没那么简单。你想想看,如果每次回收都不管相邻的块,内存很快就会碎成一片一片的——这就是我们常说的「内存碎片化」。
我个人习惯把内存回收比作拼图。你手里有几块拼图碎片,如果它们刚好能拼在一起,为什么不把它们粘起来呢?合并相邻空闲块,就是这个道理。
4.1 为什么需要合并?
先看一个场景。假设我们有一个 1MB 的显存池,初始是一整块空闲内存。程序依次申请了 256KB、256KB、256KB,然后释放了第一块和第三块。这时候内存布局是这样的:
| 空闲(256KB) | 已用(256KB) | 空闲(256KB) | 空闲(256KB) |
注意看最后两块。它们都是空闲的,而且物理上相邻。如果不合并,你最多只能分配 256KB 的块。合并之后,你就有 512KB 的连续空间了。
核心观点:合并操作直接决定了内存池的抗碎片能力。不合并,大块申请迟早会失败。
我在项目中遇到过这样一个坑:一个深度学习推理服务跑了两天,突然报 OOM。查了半天,发现显存池里碎片化严重,总空闲空间还有 300MB,但最大连续块只有 64MB。而新来的请求需要 128MB——直接挂了。这就是不合并的后果。
4.2 合并的时机
什么时候做合并?有三种策略:
- 立即合并:每次释放内存时,立刻检查相邻块并合并。实现简单,但频繁操作可能影响性能。
- 延迟合并:先标记释放,等到分配失败或达到阈值时再统一合并。性能好,但实现复杂。
- 按需合并:只在分配大块时触发合并。折中方案,我比较常用。
我个人习惯用立即合并。为什么?因为显存池的分配释放频率通常不高,而且立即合并能让内存状态始终保持最优。你想想看,如果等到分配失败才合并,那调用方就得等——这在实时性要求高的场景下是不能接受的。
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 和 C 原本不相邻,则变成两个节点),最终变成一个节点。
4.7 总结
内存回收与合并,是显存池抗碎片化的核心手段。没有合并机制,内存池迟早会退化成一个「看起来有空间,实际上用不了」的废物。
我个人建议:立即合并 + 双向链表 是最稳妥的方案。它实现简单、逻辑清晰、行为可预测。等你把这一套跑通了,再考虑延迟合并之类的优化也不迟。
记住一句话:合并不是锦上添花,而是雪中送炭。 没有合并的内存池,就像没有垃圾回收的系统——迟早会把自己玩死。