29、内存碎片:内部碎片与外部碎片、如何减少碎片

内存碎片,说白了就是「内存用着用着就乱了」。你明明还有不少空闲内存,但就是分配不出一个连续的大块来。这个问题我早年做嵌入式开发时踩过不少坑,今天咱们好好聊聊。

什么是内存碎片?

内存碎片分两种:内部碎片外部碎片。名字听着像,但本质完全不同。

内部碎片:分配多了,用不完

内部碎片,是分配出去的内存块里,没用上的那部分

举个例子。你申请了 100 字节,但实际只用了 80 字节。那 20 字节就是内部碎片。它已经分配给你了,别人用不了,你自己也没用上。

为什么会这样?最常见的原因是内存对齐。很多系统要求分配的内存地址是 4 字节或 8 字节对齐的。你申请 1 个字节,系统可能给你 4 个字节。多出来的 3 个字节就是内部碎片。

内部碎片的特点

  • 已经分配出去,属于「你的地盘」
  • 你只是没用完,不是别人抢不走
  • 通常由对齐策略或固定大小分配导致

我在项目中遇到过这样一个情况:一个网络协议栈,每个数据包头部都固定分配 64 字节。但实际头部只有 40 字节左右。每个包浪费 24 字节。如果系统同时有 1000 个连接,那就是 24KB 的内部碎片。嗯,积少成多啊。

外部碎片:空闲内存被「切碎」了

外部碎片是另一种情况。空闲内存总量够,但被分割成很多小块,没有一块连续的大块能满足你的需求

你想想看,内存就像一块大蛋糕。你切一块,我切一块,吃完又还回来。时间一长,蛋糕上全是小洞。这时候你想切一块大块的,发现到处是窟窿,根本找不到完整的一块。

外部碎片的特点

  • 空闲内存总量可能很大
  • 但每个空闲块都很小
  • 无法满足大块连续分配请求

我曾经调试过一个服务程序,运行几天后突然报内存分配失败。检查发现总空闲内存还有 200MB,但最大的连续块只有 4KB。这就是典型的外部碎片问题。

内部碎片 vs 外部碎片:一张表说清楚

对比项 内部碎片 外部碎片
碎片位置 已分配块内部 空闲块之间
产生原因 分配粒度、对齐要求 频繁分配释放、大小不一
是否可被利用 不能,已分配出去 理论上可用,但不连续
典型场景 固定大小内存池 长时间运行的服务
解决思路 精确分配、减少对齐浪费 合并空闲块、改用伙伴算法

如何减少内部碎片?

内部碎片的根源是「分配多了」。那我们就想办法少分配

1. 精确计算所需大小

别偷懒。不要总是「多分配一点以防万一」。我见过有人每次分配结构体都多分配 16 字节,理由是「万一以后扩展呢」。结果系统跑了三年,那 16 字节从来没用到过。

// 不好的做法:多分配了
struct msg *p = malloc(sizeof(struct msg) + 16);

// 好的做法:精确分配
struct msg *p = malloc(sizeof(struct msg));

2. 使用柔性数组成员

C99 引入的柔性数组成员,可以让你在结构体尾部动态指定数组大小,避免多分配。

// 柔性数组成员,精确分配
struct flex_array {
    int len;
    int data[];  // 柔性数组,不占结构体大小
};

struct flex_array *fa = malloc(sizeof(struct flex_array) + n * sizeof(int));
fa->len = n;

这样分配出来的内存,刚好够用,没有内部碎片。

3. 内存池 + 固定大小块

如果你知道对象的大小范围,可以用内存池。每个池子只分配固定大小的块。内部碎片是固定的,可控的。

我的习惯:对于频繁分配的小对象(比如 16、32、64 字节),我会用内存池。内部碎片虽然存在,但总量可控,而且分配速度极快。

如何减少外部碎片?

外部碎片更棘手。它跟分配释放的顺序、大小都有关系。

1. 伙伴算法(Buddy System)

伙伴算法把内存按 2 的幂次分成块。分配时找最小能满足的块,释放时尝试合并相邻的「伙伴」块。

这样做的好处是:空闲块的大小总是 2 的幂次,合并起来很方便。Linux 内核的物理内存管理用的就是伙伴算法。

// 伙伴算法分配示意(伪代码)
void *buddy_alloc(size_t size) {
    // 将 size 向上取整到 2 的幂次
    int order = ceil_log2(size);
    // 从对应 order 的空闲链表中取一块
    return get_block_from_free_list(order);
}

void buddy_free(void *ptr) {
    // 找到伙伴块
    // 如果伙伴块也是空闲的,合并
    // 合并后继续尝试向上合并
}

2. 内存池 + 对象复用

如果你能预知对象的生命周期,尽量复用对象。不要频繁分配释放。我做过一个游戏服务器,里面的玩家对象全部用内存池管理。玩家下线后对象不释放,只是放回空闲链表。下次新玩家上线直接复用。

这样做的好处是:内存布局基本不变,不会产生外部碎片

3. 分配策略:大块优先,小块靠边

有些分配器会采用「最佳适应」或「最差适应」策略。我个人建议:大块分配优先从大块空闲区取,小块分配尽量从已切碎的小块区取。这样可以避免大块空闲区被小块分配「切碎」。

注意:没有万能的分配策略。不同的应用场景,适合不同的策略。嵌入式系统、桌面应用、服务器程序,它们的分配模式完全不同。

4. 定期整理(Compaction)

有些系统支持内存整理。把已分配的对象移动到一端,把空闲空间合并到另一端。但这需要对象是可移动的,而且需要更新所有指针。C 语言里做这个比较麻烦,因为指针是裸的,没有间接层。

不过,如果你自己实现了内存池,可以在池子内部做整理。我做过一个日志系统,日志记录全部在环形缓冲区里。缓冲区满了就覆盖最旧的记录。这种设计天然没有外部碎片。

一张图看懂内存碎片

下面这张图展示了内部碎片和外部碎片的区别,以及减少碎片的常用策略。

内存碎片分类与减少策略 内存碎片 内部碎片 外部碎片 已分配块内未使用的空间 由对齐、固定大小分配导致 空闲块被分割成多个小块 总量够,但不连续 减少内部碎片的策略 • 精确计算所需大小,不多分配 • 使用柔性数组成员 • 内存池 + 固定大小块 • 避免过度对齐 • 使用变长结构体 减少外部碎片的策略 • 伙伴算法(Buddy System) • 内存池 + 对象复用 • 大块优先,小块靠边 • 定期整理(Compaction) • 使用 slab 分配器

实战建议:我踩过的坑

说了这么多理论,最后分享几个实战经验。

避坑指南 1:我曾经在一个网络代理程序里,每个连接都动态分配缓冲区。连接频繁建立和断开,运行一天后内存碎片率高达 40%。后来改成连接池 + 固定大小缓冲区,碎片率降到 5% 以下。

避坑指南 2:嵌入式系统里,我见过有人用 malloc/free 管理一个只有 64KB 的堆。结果程序跑了一个月,malloc 返回 NULL 了。检查发现堆里全是 4 字节、8 字节的小碎片。解决方案:改用静态分配 + 内存池。

重要提醒:不要迷信「万能分配器」。每个分配器都有自己的适用场景。你想想看,Linux 内核用伙伴算法,jemalloc 用多级缓存,tcmalloc 用线程本地缓存。它们各有各的绝活,但没有一个能解决所有问题。

最后说一句:减少内存碎片的核心思路就两条——要么让分配更精确(减少内部碎片),要么让释放更有序(减少外部碎片)。具体怎么做,取决于你的应用场景。多测试,多观察,别偷懒。


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