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 语言里做这个比较麻烦,因为指针是裸的,没有间接层。
不过,如果你自己实现了内存池,可以在池子内部做整理。我做过一个日志系统,日志记录全部在环形缓冲区里。缓冲区满了就覆盖最旧的记录。这种设计天然没有外部碎片。
一张图看懂内存碎片
下面这张图展示了内部碎片和外部碎片的区别,以及减少碎片的常用策略。
实战建议:我踩过的坑
说了这么多理论,最后分享几个实战经验。
避坑指南 1:我曾经在一个网络代理程序里,每个连接都动态分配缓冲区。连接频繁建立和断开,运行一天后内存碎片率高达 40%。后来改成连接池 + 固定大小缓冲区,碎片率降到 5% 以下。
避坑指南 2:嵌入式系统里,我见过有人用 malloc/free 管理一个只有 64KB 的堆。结果程序跑了一个月,malloc 返回 NULL 了。检查发现堆里全是 4 字节、8 字节的小碎片。解决方案:改用静态分配 + 内存池。
重要提醒:不要迷信「万能分配器」。每个分配器都有自己的适用场景。你想想看,Linux 内核用伙伴算法,jemalloc 用多级缓存,tcmalloc 用线程本地缓存。它们各有各的绝活,但没有一个能解决所有问题。
最后说一句:减少内存碎片的核心思路就两条——要么让分配更精确(减少内部碎片),要么让释放更有序(减少外部碎片)。具体怎么做,取决于你的应用场景。多测试,多观察,别偷懒。
公众号:蓝海资料掘金营,微信deep3321