4、堆内存管理:malloc/free实现原理、内部碎片与外部碎片、堆管理算法
堆内存管理,说白了就是动态内存分配。你写程序时不知道要多大空间,运行时才决定。这时候就得靠 malloc 和 free 这对好兄弟了。
我刚开始做嵌入式时,觉得堆管理很简单——不就是申请释放嘛。直到有一次产品跑着跑着就崩了,查了三天才发现是堆碎片惹的祸。嗯,从那以后我再也不敢小看堆管理了。
4.1 malloc/free 的实现原理
malloc 和 free 不是魔法,它们背后有一套机制在运作。我习惯把堆内存想象成一个大仓库,malloc 就是去仓库里划一块地,free 就是把地还回去。
具体怎么实现的?核心是维护一个「空闲链表」。每次 malloc 时,系统遍历这个链表,找到一块足够大的空闲内存,切一块给你,剩下的继续挂在链表上。free 时,把这块内存重新挂回链表,必要时还会和相邻的空闲块合并。
关键点:每次 malloc 返回的地址前面,其实藏着一个小「头信息」。里面记录了这块内存的大小、是否空闲等元数据。你申请 100 字节,系统实际可能消耗 108 字节——多出来的 8 字节就是管理开销。
// 简化版的内存块结构
typedef struct mem_block {
size_t size; // 块大小(包含头信息)
int free; // 是否空闲
struct mem_block *next; // 指向下一个块
// 实际数据从这里开始
} mem_block_t;
你想想看,每次 malloc 都要遍历链表,效率能高吗?所以频繁申请释放小内存,性能会很难看。我在项目中遇到过,一个通信协议栈每秒申请释放上千次,结果 CPU 全耗在链表遍历上了。
4.2 内部碎片与外部碎片
碎片问题是堆管理的头号杀手。分两种:内部碎片和外部碎片。
4.2.1 内部碎片
内部碎片,说白了就是「浪费在自己肚子里」。比如你申请 30 字节,但系统最小分配单位是 32 字节,那 2 字节就浪费了。或者你申请 100 字节,系统给你 108 字节(含头信息),那 8 字节也是内部碎片。
| 申请大小 | 实际分配 | 内部碎片 | 浪费比例 |
|---|---|---|---|
| 1 字节 | 16 字节(对齐) | 15 字节 | 93.75% |
| 30 字节 | 32 字节 | 2 字节 | 6.25% |
| 100 字节 | 108 字节 | 8 字节 | 7.4% |
看到没?申请越小,浪费比例越高。所以我建议,尽量避免频繁申请小内存,能合并就合并。
4.2.2 外部碎片
外部碎片更隐蔽,也更致命。它指的是:总空闲内存够用,但被分割成很多小块,没有一块连续空间能满足你的申请。
举个例子:堆里有 100 字节空闲,但分成了 10 块,每块 10 字节。这时候你申请 20 字节,虽然总空闲够,但 malloc 会失败。这就是外部碎片。
我曾经踩过的坑:一个数据采集项目,运行 72 小时后突然申请内存失败。查到最后,堆里空闲块有 200 多个,但最大的只有 16 字节。外部碎片把堆搞成了「蜂窝煤」。从那以后,我养成了定期监控堆碎片的习惯。
4.3 堆管理算法
不同的算法,决定了你怎么从空闲链表中找合适的块。我常用的有三种:首次适应、最佳适应、最差适应。这里重点讲前两个。
4.3.1 首次适应算法
这个算法最简单:从头遍历空闲链表,找到第一个大小够用的块就分配。
// 首次适应算法伪代码
void *first_fit_malloc(size_t size) {
mem_block_t *curr = free_list_head;
while (curr != NULL) {
if (curr->free && curr->size >= size) {
// 找到了!切一块出来
split_block(curr, size);
curr->free = 0;
return (void*)(curr + 1);
}
curr = curr->next;
}
return NULL; // 没找到
}
优点:速度快,不需要遍历整个链表。找到第一个就停。
缺点:容易在链表头部产生很多小碎片。你想想看,每次都从头部找,大的块被切得越来越小,最后头部全是碎渣。
我个人习惯,在内存比较充裕、申请次数不多的场景用首次适应。简单粗暴,够用就行。
4.3.2 最佳适应算法
最佳适应算法更「精打细算」:遍历整个空闲链表,找到大小最接近你需求的块。
// 最佳适应算法伪代码
void *best_fit_malloc(size_t size) {
mem_block_t *curr = free_list_head;
mem_block_t *best = NULL;
size_t min_diff = SIZE_MAX;
while (curr != NULL) {
if (curr->free && curr->size >= size) {
size_t diff = curr->size - size;
if (diff < min_diff) {
min_diff = diff;
best = curr;
// 如果完全匹配,直接退出
if (diff == 0) break;
}
}
curr = curr->next;
}
if (best != NULL) {
split_block(best, size);
best->free = 0;
return (void*)(best + 1);
}
return NULL;
}
优点:碎片最小化,每次分配都「刚刚好」。
缺点:慢!每次都要遍历整个链表。而且容易留下很多极小的碎片(比如差 1 字节那种),这些小碎片几乎没法再用。
我的经验:在实时性要求高的嵌入式系统里,我很少用最佳适应。遍历链表的时间不确定,可能造成任务超时。我一般用首次适应 + 定期碎片整理,或者干脆用固定大小内存池。
4.3.3 两种算法对比
| 对比项 | 首次适应 | 最佳适应 |
|---|---|---|
| 查找速度 | 快(找到即停) | 慢(必须遍历完) |
| 内存利用率 | 中等 | 较高 |
| 碎片情况 | 头部碎片多 | 微小碎片多 |
| 适用场景 | 通用场景 | 内存紧张、申请次数少 |
其实还有第三种——最差适应算法,专门找最大的块来分配。目的是留下较大的空闲块,减少小碎片。但实际效果嘛,我个人觉得不如前两种实用。
最后说一句:堆管理没有银弹。不同的算法适合不同的场景。我建议你在项目初期就评估好内存分配模式,选对算法,能省去后期很多头疼的问题。