3. 内存体系与压缩动机
内存碎片化,说白了就是你的堆内存被切得七零八落,像一块被狗啃过的蛋糕。明明总空闲空间还够,但就是分配不出一块连续的大内存。我早年做一款工业控制器时,系统跑了三天突然 malloc 失败,查了一整天才发现是碎片在作祟。从那以后,我对碎片问题就特别敏感。
碎片分两种:内部碎片和外部碎片。很多人搞混,其实区分很简单——内部碎片是“分给你的你没用完”,外部碎片是“空闲的总和够,但拼不成一块”。
3.1 内部碎片:分配粒度带来的浪费
内部碎片,是分配器给你的一块内存比你实际需要的多出来的那部分。为什么会这样?因为分配器通常按固定粒度(比如 8 字节、16 字节)对齐。
举个例子:你申请 30 字节,分配器按 32 字节对齐给你一块。那 2 字节就是内部碎片。你想想看,如果系统里有一千个这样的分配,就白白浪费了 2KB。
- 对齐策略:分配器按 4/8/16 字节对齐,申请量不是对齐倍数的部分就浪费了
- 固定块分配:某些分配器(如 Buddy System)只分配 2 的幂次大小,申请 65 字节可能给你 128 字节
- 头部开销:每个分配块前面都带一个控制头(通常 8-16 字节),对小对象来说占比很高
内部碎片的影响很直接——内存利用率下降。在 64KB RAM 的 MCU 上,如果内部碎片率达到 15%,你就损失了近 10KB 可用空间。我见过一个项目,就因为用了默认的 16 字节对齐分配器,小对象分配导致内部碎片高达 22%,最后不得不换成 4 字节对齐的定制分配器。
3.2 外部碎片:空闲块无法合并的噩梦
外部碎片更隐蔽,也更难对付。它指的是:总空闲内存足够,但这些空闲块散布在已分配块之间,无法满足一个大块分配请求。
为什么会这样?
- 分配与释放顺序不匹配:先分配 A、B、C,然后释放 B,空闲块就被 A 和 C 夹在中间
- 不同生命周期对象混用:长期存活的对象和短期对象放在同一个堆里,短期对象释放后留下的空洞被长期对象隔开
- 分配大小变化大:频繁分配不同大小的块,导致空闲块大小不一,难以合并
3.3 内部碎片 vs 外部碎片:对比一览
| 对比维度 | 内部碎片 | 外部碎片 |
|---|---|---|
| 成因 | 分配粒度对齐、固定块大小 | 分配/释放顺序交错、生命周期混用 |
| 表现形式 | 已分配块内未使用的尾部空间 | 已分配块之间的细小空闲块 |
| 影响 | 内存利用率下降,浪费固定比例 | 大块分配失败,即使总空闲足够 |
| 诊断难度 | 容易,统计分配大小即可计算 | 较难,需要分析空闲块分布 |
| 典型场景 | 大量小对象分配(如消息队列节点) | 变长缓冲区频繁分配释放 |
| 缓解手段 | 减小对齐粒度、使用 slab 分配器 | 内存池、伙伴系统、碎片整理 |
3.4 碎片化的影响:不只是内存不够用
碎片化带来的问题,远不止 malloc 失败这么简单。
- 分配延迟增加:分配器需要遍历空闲链表寻找合适块,碎片越多,遍历时间越长。我实测过一个系统,碎片率 30% 时,malloc 平均耗时从 5μs 飙升到 80μs
- 实时性抖动:分配时间不确定,对硬实时任务可能是致命打击
- 内存利用率虚低:明明还有 30% 空闲,但最大连续块只有 1KB,连一个任务栈都分配不出来
- 系统稳定性下降:长时间运行后,碎片持续累积,最终导致系统崩溃
3.5 诊断方法:如何量化碎片化程度
诊断碎片化,不能靠感觉。我们需要量化指标。
3.5.1 碎片率计算公式
最常用的指标是 外部碎片率:
外部碎片率 = 1 - (最大连续空闲块大小 / 总空闲内存大小)
这个值越接近 1,说明碎片越严重。比如总空闲 10KB,最大连续块只有 2KB,碎片率就是 0.8。
3.5.2 空闲块分布直方图
更细致的做法是统计空闲块的大小分布:
// 伪代码:统计空闲块分布
void dump_free_block_histogram() {
int buckets[8] = {0}; // 按 2 的幂分桶
struct free_block *fb = heap_first_free();
while (fb) {
int size = fb->size;
int bucket = 0;
while (size > 16) { size >>= 1; bucket++; }
if (bucket > 7) bucket = 7;
buckets[bucket]++;
fb = fb->next;
}
// 输出:0-16B: 23个, 17-32B: 15个, ...
}
如果小桶(16-64 字节)里的空闲块数量持续增长,说明碎片在恶化。
3.5.3 分配失败回溯
当 malloc 返回 NULL 时,不要只打印错误。记录当时的堆状态:
void* debug_malloc(size_t size) {
void *p = malloc(size);
if (!p) {
printf("[FATAL] malloc(%u) failed\n", size);
printf(" Total free: %u\n", heap_total_free());
printf(" Max contig: %u\n", heap_max_contig_free());
printf(" Fragmentation ratio: %.2f\n",
1.0 - (float)heap_max_contig_free() / heap_total_free());
// 打印最近 10 次分配/释放记录
dump_recent_alloc_history(10);
// 触发系统重置或进入安全模式
}
return p;
}
3.6 碎片化的可视化:一张图看懂
下面这张图展示了堆内存从初始状态到严重碎片化的演变过程。你可以看到,随着分配和释放的进行,空闲块被切割成越来越小的碎片。
你看,最后一步虽然总空闲有 610 字节,但被 D 和 E 隔成了三块。如果这时要分配 400 字节,malloc 就会失败。这就是外部碎片的典型症状。
3.7 实战诊断流程
我在项目中总结了一套诊断流程,分享给你:
- 建立基线:系统刚启动时,记录总堆大小、已分配大小、最大连续空闲块
- 周期性采样:每 5 分钟或每次关键操作后,记录碎片率
- 设置阈值告警:碎片率超过 0.6 或最大连续块小于某阈值时触发告警
- 分配失败时全量 dump:记录所有已分配块的地址、大小、分配时间戳
- 离线分析:用脚本分析 dump 数据,找出哪些模块是碎片制造者
碎片化诊断不是一次性工作。它应该成为你系统健康监控的一部分。嗯,记住一句话:没有监控的堆,就是一颗定时炸弹。