9、内存池初始化与销毁:预分配策略、按需增长、内存池的销毁与资源回收
内存池这东西,说白了就是一块你提前圈好的地。你想想看,每次要内存都去系统调用 malloc,那开销可不小。我早年做嵌入式项目时,就吃过这个亏——频繁分配小对象,性能直接崩了。后来自己写了个内存池,世界清净了。
今天咱们就聊聊内存池的初始化与销毁。这两个操作看似简单,但坑不少。我踩过的坑,今天一并告诉你。
9.1 预分配策略:一次申请,多次使用
预分配,就是内存池在创建时,一次性向操作系统申请一大块连续内存。之后所有小对象分配,都在这块内存里切分。
我个人习惯的做法是:先估算峰值使用量,再乘以一个安全系数(比如1.5倍)。为什么?因为内存池一旦创建,再扩容就比较麻烦。你总不希望运行到一半,内存不够用吧?
核心原则:预分配的大小 = 预估峰值 × 安全系数。安全系数建议1.2~2.0,视业务场景而定。
来看一个简单的预分配实现:
// 内存池结构体
typedef struct mempool_s {
void *pool_start; // 池起始地址
size_t pool_size; // 池总大小
void *free_ptr; // 当前空闲位置指针
size_t free_size; // 剩余空闲大小
} mempool_t;
// 预分配初始化
mempool_t* mempool_create(size_t size) {
mempool_t *mp = (mempool_t*)malloc(sizeof(mempool_t));
if (!mp) return NULL;
mp->pool_start = malloc(size);
if (!mp->pool_start) {
free(mp);
return NULL;
}
mp->pool_size = size;
mp->free_ptr = mp->pool_start;
mp->free_size = size;
return mp;
}
嗯,这里要注意:free_ptr 和 free_size 是分配器的核心状态。每次分配时,我们只需要移动指针、减少剩余大小即可。这比 malloc 快得多——没有链表遍历,没有系统调用。
9.2 按需增长:内存不够了怎么办?
预分配再好,也架不住业务突发。我在一个网络服务器项目中就遇到过:平时内存池够用,但某次流量洪峰,内存池直接爆了。程序崩溃,线上事故。
从那以后,我设计的内存池都支持按需增长。说白了就是:当池内内存不够时,自动再申请一块新的内存块,挂到池的链表上。
我的经验:按需增长时,新块的大小建议是当前池大小的1.5倍或2倍。太小了频繁扩容,太大了浪费内存。我一般用2倍,简单粗暴。
来看扩容的实现思路:
// 内存块节点(用于链表管理)
typedef struct memblock_s {
struct memblock_s *next;
void *start;
size_t size;
void *free_ptr;
size_t free_size;
} memblock_t;
// 内存池(支持增长)
typedef struct mempool_grow_s {
memblock_t *head; // 第一个块
memblock_t *current; // 当前正在使用的块
size_t block_size;// 默认块大小
} mempool_grow_t;
// 扩容:追加一个新块
int mempool_grow(mempool_grow_t *mp) {
size_t new_size = mp->block_size * 2; // 每次翻倍
memblock_t *block = (memblock_t*)malloc(sizeof(memblock_t));
if (!block) return -1;
block->start = malloc(new_size);
if (!block->start) {
free(block);
return -1;
}
block->size = new_size;
block->free_ptr = block->start;
block->free_size = new_size;
block->next = NULL;
// 挂到链表尾部
mp->current->next = block;
mp->current = block;
mp->block_size = new_size; // 更新默认大小
return 0;
}
为什么翻倍?因为这样扩容次数是 O(log N),而不是 O(N)。你想想看,如果每次只加固定大小,扩容100次后,链表上挂了一堆小碎片,分配效率直线下降。
注意:按需增长虽然解决了内存不够的问题,但也会带来碎片。每个块内部是连续的,但块与块之间不连续。所以大对象分配时,可能跨块失败。我一般建议:大对象走单独的内存池,或者直接用 malloc。
9.3 内存池的销毁:资源回收的艺术
销毁内存池,听起来简单——free 掉不就完了?但实际项目中,坑多着呢。
我曾经在一个多线程项目中,销毁内存池时忘了加锁,结果另一个线程还在分配内存,直接 double free,程序崩溃。排查了一整天,最后发现是销毁顺序的问题。
销毁的核心原则就一条:先停止使用,再释放资源。
来看销毁的实现:
// 销毁内存池(单块版本)
void mempool_destroy(mempool_t *mp) {
if (!mp) return;
if (mp->pool_start) {
free(mp->pool_start);
mp->pool_start = NULL;
}
free(mp);
}
// 销毁内存池(多块版本,支持增长)
void mempool_grow_destroy(mempool_grow_t *mp) {
if (!mp) return;
memblock_t *block = mp->head;
while (block) {
memblock_t *next = block->next;
if (block->start) {
free(block->start);
block->start = NULL;
}
free(block);
block = next;
}
free(mp);
}
嗯,这里有个细节:销毁时一定要把指针置 NULL。为什么?因为如果代码中有 bug,销毁后还有人访问内存池,置 NULL 后至少能快速崩溃,而不是产生难以排查的野指针问题。
避坑指南:我曾经在销毁后忘记置 NULL,结果另一个模块的定时器回调还在使用内存池。数据写到了已释放的内存上,导致另一个完全不相关的对象被篡改。这种 bug 极难复现,排查了整整三天。从那以后,我销毁任何资源后,都会把指针置 NULL。
9.4 资源回收的完整流程
一个完整的内存池生命周期,应该是这样的:
- 创建:预分配一块或多块内存
- 使用:从池中分配、释放对象
- 扩容(可选):按需追加新块
- 销毁:释放所有内存块,清理状态
我习惯在销毁函数中加一个断言检查:如果池中还有未释放的对象,就打印警告。这能帮你发现内存泄漏。
// 带检查的销毁
void mempool_safe_destroy(mempool_t *mp) {
if (!mp) return;
// 检查是否有未释放的对象
size_t used = mp->pool_size - mp->free_size;
if (used > 0) {
fprintf(stderr, "WARNING: mempool still has %zu bytes allocated!\n", used);
// 这里可以记录调用栈,方便排查
}
free(mp->pool_start);
mp->pool_start = NULL;
mp->free_ptr = NULL;
mp->pool_size = 0;
mp->free_size = 0;
free(mp);
}
小技巧:在调试版本中,我还会在销毁前遍历所有已分配对象,检查是否有内存越界。具体做法是:在每个对象前后加哨兵字节(比如 0xDEADBEEF),销毁时检查哨兵是否被改写。如果被改写了,说明有人写越界了。
9.5 知识体系总览
下面这张图,把内存池初始化与销毁的核心逻辑串起来了。你可以看到:预分配是起点,按需增长是弹性机制,销毁是终点。三者缺一不可。
这张图里,三个主流程是串行的:先预分配,不够了就按需增长,最后统一销毁。每个流程下都有具体的实现要点。我个人建议你把这个图打印出来贴在工位上,写代码时对照着看,不容易漏掉关键步骤。
好了,内存池的初始化与销毁就聊到这儿。记住:预分配要留余量,按需增长要翻倍,销毁时要置 NULL。这三个习惯,能帮你省下不少调试时间。
公众号:蓝海资料掘金营,微信 deep3321