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 夹在中间
  • 不同生命周期对象混用:长期存活的对象和短期对象放在同一个堆里,短期对象释放后留下的空洞被长期对象隔开
  • 分配大小变化大:频繁分配不同大小的块,导致空闲块大小不一,难以合并
我的经验:外部碎片最常出现在通信协议栈里。比如一个网络任务频繁分配 64 字节、128 字节、256 字节的包缓冲区,释放顺序又和分配顺序不一致。跑上几个小时,堆里全是 16-32 字节的小空洞,连一个 128 字节的包都分配不出来。

3.3 内部碎片 vs 外部碎片:对比一览

对比维度 内部碎片 外部碎片
成因 分配粒度对齐、固定块大小 分配/释放顺序交错、生命周期混用
表现形式 已分配块内未使用的尾部空间 已分配块之间的细小空闲块
影响 内存利用率下降,浪费固定比例 大块分配失败,即使总空闲足够
诊断难度 容易,统计分配大小即可计算 较难,需要分析空闲块分布
典型场景 大量小对象分配(如消息队列节点) 变长缓冲区频繁分配释放
缓解手段 减小对齐粒度、使用 slab 分配器 内存池、伙伴系统、碎片整理

3.4 碎片化的影响:不只是内存不够用

碎片化带来的问题,远不止 malloc 失败这么简单。

  • 分配延迟增加:分配器需要遍历空闲链表寻找合适块,碎片越多,遍历时间越长。我实测过一个系统,碎片率 30% 时,malloc 平均耗时从 5μs 飙升到 80μs
  • 实时性抖动:分配时间不确定,对硬实时任务可能是致命打击
  • 内存利用率虚低:明明还有 30% 空闲,但最大连续块只有 1KB,连一个任务栈都分配不出来
  • 系统稳定性下降:长时间运行后,碎片持续累积,最终导致系统崩溃
避坑指南:我曾经在一个 7×24 小时运行的设备上,因为没做碎片监控,系统在第 47 天突然死机。排查发现堆碎片率已达 68%,最大连续块只剩 256 字节。从那以后,我每个项目都会加一个碎片监控任务,定时上报空闲块分布。

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 碎片化的可视化:一张图看懂

下面这张图展示了堆内存从初始状态到严重碎片化的演变过程。你可以看到,随着分配和释放的进行,空闲块被切割成越来越小的碎片。

堆内存碎片化演变过程 初始状态 全部空闲 (760B) 分配 A(200B)、B(150B)、C(300B) A (200B) B (150B) C (300B) 空闲 (110B) 释放 B → 出现空洞 A (200B) 空闲 (150B) C (300B) 空闲 (110B) 分配 D(100B)、E(80B) → 空洞被切碎 A (200B) D (100B) E (80B) C (300B) 空闲 (110B) 释放 A、C → 碎片化严重 空闲 (200B) D (100B) E (80B) 空闲 (300B) 空闲 (110B) 总空闲: 200+300+110 = 610B,但最大连续块只有 300B,无法分配 400B 的请求

你看,最后一步虽然总空闲有 610 字节,但被 D 和 E 隔成了三块。如果这时要分配 400 字节,malloc 就会失败。这就是外部碎片的典型症状。

3.7 实战诊断流程

我在项目中总结了一套诊断流程,分享给你:

  1. 建立基线:系统刚启动时,记录总堆大小、已分配大小、最大连续空闲块
  2. 周期性采样:每 5 分钟或每次关键操作后,记录碎片率
  3. 设置阈值告警:碎片率超过 0.6 或最大连续块小于某阈值时触发告警
  4. 分配失败时全量 dump:记录所有已分配块的地址、大小、分配时间戳
  5. 离线分析:用脚本分析 dump 数据,找出哪些模块是碎片制造者
小技巧:在开发阶段,可以给每个分配加上模块 ID 标签。这样在分析碎片时,一眼就能看出哪个模块分配了哪些块,以及它们的释放模式。我习惯用高 4 位存储模块 ID,低 28 位存实际大小,这样只浪费 4 位,但调试信息量巨大。

碎片化诊断不是一次性工作。它应该成为你系统健康监控的一部分。嗯,记住一句话:没有监控的堆,就是一颗定时炸弹。