8. 内存碎片:内部碎片与外部碎片、碎片化管理

说到动态内存管理,有个绕不开的话题——内存碎片。

我刚开始用C写嵌入式程序时,总觉得malloc/free挺简单的。直到有一次,一个跑了三天的系统突然挂了。查了半天,发现是内存碎片把堆空间撕成了“蜂窝煤”。嗯,从那以后,我再也不敢小看这玩意儿了。

8.1 什么是内存碎片?

说白了,内存碎片就是“用不了的空闲内存”。

你想想看,你有一整块连续的空闲空间,但分配和释放的顺序不对,就会留下很多小空隙。这些小空隙单个看都不大,但加起来可能很可观。更气人的是,你明明还有不少空闲内存,却分配不出一个大的连续块。

内存碎片分两种:内部碎片外部碎片。我习惯把它们比作“浪费在内部的”和“浪费在缝隙里的”。

8.2 内部碎片

内部碎片,是分配器给你的一块内存比你实际需要的要大,多出来的那部分就浪费了。

为什么会这样?因为分配器通常按固定大小的块来管理内存。比如,你申请了35字节,但分配器的最小单位是16字节,它就会给你48字节(3个16字节块)。多出来的13字节就是内部碎片。

内部碎片 = 分配块大小 - 实际请求大小

浪费在分配块内部,无法被其他请求使用。

我在项目中遇到过这样一个场景:一个结构体刚好37字节,但分配器按8字节对齐,结果每次分配都浪费3字节。几百次分配下来,浪费的内存就很可观了。

8.3 外部碎片

外部碎片更隐蔽,也更难对付。

它指的是:空闲内存总量足够,但被已分配的内存块分割成很多小碎片,导致无法满足一个大块的分配请求。

举个例子:

初始堆: [ 空闲 1000 字节 ]

分配 200 字节: [A:200][ 空闲 800 ]
分配 300 字节: [A:200][B:300][ 空闲 500 ]
释放 A:      [ 空闲 200 ][B:300][ 空闲 500 ]
分配 400 字节: ❌ 失败!虽然有 700 字节空闲,但没有连续 400 字节

你看,明明还有700字节空闲,却分配不出400字节。这就是外部碎片的典型表现。

注意:外部碎片是动态内存分配中最头疼的问题之一。我曾经在一个长期运行的服务器程序里,因为外部碎片导致malloc失败,最终服务崩溃。从那以后,我对碎片管理格外上心。

8.4 碎片化管理策略

既然碎片这么讨厌,怎么对付它?我总结了几种常用策略:

8.4.1 内存池(Memory Pool)

这是我最推荐的方法,尤其适合嵌入式系统。

思路很简单:预先分配一大块内存,然后按固定大小切成小块。每次分配都从池里取一块,释放时还回去。因为所有块大小一样,不会产生外部碎片。

// 简单的内存池实现
#define POOL_SIZE 1024
#define BLOCK_SIZE 64

static char pool[POOL_SIZE];
static int used[POOL_SIZE / BLOCK_SIZE];

void* pool_alloc() {
    for (int i = 0; i < POOL_SIZE / BLOCK_SIZE; i++) {
        if (!used[i]) {
            used[i] = 1;
            return pool + i * BLOCK_SIZE;
        }
    }
    return NULL;  // 池已满
}

void pool_free(void* ptr) {
    int index = (char*)ptr - pool;
    if (index >= 0 && index < POOL_SIZE) {
        used[index / BLOCK_SIZE] = 0;
    }
}

我的经验:如果程序中有大量固定大小的对象(比如网络包、任务控制块),用内存池效率最高。分配和释放都是O(1)的,而且完全没有外部碎片。

8.4.2 伙伴系统(Buddy System)

伙伴系统是另一种经典方案。它把内存按2的幂次分成块,分配时找大小最合适的块,如果找不到就分裂更大的块。

举个例子:

  1. 初始有1024字节的空闲块
  2. 申请100字节 → 找128字节的块,没有,分裂1024为512+512,再分裂512为256+256,再分裂256为128+128
  3. 分配其中一个128字节块(内部碎片28字节)
  4. 释放时,检查它的“伙伴”是否也空闲,如果是就合并回去

伙伴系统的优点是合并相对容易,碎片控制得不错。缺点是内部碎片依然存在(因为只能分配2的幂次大小)。

8.4.3 紧凑(Compaction)

这是最彻底的方法——移动已分配的内存块,把所有空闲空间合并成一个大块。

但代价也很大:

  • 需要修改所有指针,因为内存地址变了
  • 需要暂停程序执行(stop-the-world)
  • 实现复杂,C语言里尤其麻烦

我一般只在特殊场景下用紧凑,比如某些实时系统在安全时刻做一次内存整理。

8.5 碎片率评估

怎么知道你的系统碎片严重不严重?我常用这个公式:

碎片率 = 1 - (最大连续空闲块 / 总空闲内存)

如果碎片率接近0,说明内存很整齐。如果接近1,说明内存已经碎成渣了。

碎片率 状态 建议
< 0.3 良好 正常使用
0.3 - 0.6 注意 考虑优化分配策略
> 0.6 危险 立即采取措施

我曾经踩过的坑:一个通信设备跑了两个月后,碎片率到了0.85。明明还有200KB空闲,却连一个4KB的包都分配不出来。后来加了内存池和定期碎片整理,问题才解决。

8.6 知识体系总览

下面这张图总结了内存碎片的完整知识结构:

内存碎片知识体系 内存碎片 内部碎片 分配块内浪费 对齐导致 固定块大小 外部碎片 空闲块不连续 分配/释放顺序 长期运行恶化 碎片化管理策略 内存池 伙伴系统 紧凑(Compaction)

8.7 小结

内存碎片是个慢性病,初期没什么感觉,等发现问题时往往已经晚了。

我个人习惯在项目初期就做好规划:

  • 评估对象的生命周期和大小分布
  • 选择合适的分配策略(内存池、伙伴系统等)
  • 定期监控碎片率

记住一句话:预防胜于治疗。在内存管理上,这句话尤其正确。

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