4、内存池的分配与释放算法:O(1)分配算法、O(1)释放算法、内存回收与复用策略
好,咱们接着聊。上一章我们把内存池的布局搭好了,就像盖好了仓库,货架也摆好了。但仓库管理员怎么高效地存取货物?这就是分配与释放算法要解决的问题。
我个人习惯把内存池的核心操作总结成三个字:快、准、省。快,就是分配和释放必须O(1);准,就是不能出错,不能内存泄漏;省,就是碎片少,复用率高。今天咱们就围绕这三点展开。
4.1 O(1)分配算法:FreeList的妙用
先问个问题:为什么传统malloc分配慢?因为它得遍历空闲链表,找一块大小合适的。最坏情况O(n),你想想看,回测系统里每秒可能要分配几百万次内存,这哪受得了?
内存池的O(1)分配,核心就四个字:从哪拿,补哪。
具体做法是这样的:每个固定大小的内存块,我们用一个FreeList来管理。FreeList本质上就是个单向链表,把所有空闲块串起来。分配时,直接从链表头部取一块,然后头指针指向下一块。就这么简单。
// O(1)分配算法核心逻辑
void* pool_alloc(MemoryPool* pool) {
if (pool->free_list == NULL) {
// 空闲链表为空,需要扩容
expand_pool(pool);
}
// 从链表头部摘下一块
FreeNode* node = pool->free_list;
pool->free_list = node->next;
// 返回这块内存的起始地址
return (void*)node;
}
你看,就几行代码。没有遍历,没有比较,时间复杂度O(1)。我在项目中遇到过一个问题:一开始用了个双向链表,结果发现没必要。因为分配时只需要取头部,释放时只需要插回头部,单向链表完全够用,还省了一个指针的内存。
关键点:分配操作的本质就是「头指针后移」。这个操作是原子性的,在多线程环境下只需要一个CAS指令就能搞定。
4.2 O(1)释放算法:头插法的艺术
释放算法更简单。说白了就是把用完了的内存块,重新插回FreeList的头部。嗯,这里要注意:释放时不需要检查这块内存是不是属于这个池子,因为我们在设计时就保证了——所有从池子里拿出去的内存,释放时一定还回来。
// O(1)释放算法核心逻辑
void pool_free(MemoryPool* pool, void* ptr) {
if (ptr == NULL) return;
// 把这块内存当作FreeNode
FreeNode* node = (FreeNode*)ptr;
// 头插法:插回FreeList头部
node->next = pool->free_list;
pool->free_list = node;
}
我曾经犯过一个错误:在释放时对内存块做了清零操作。结果发现回测速度直接掉了30%。后来想明白了,清零是O(n)的,而释放本身是O(1)的。如果你真的需要安全擦除,可以放到后台线程去做,或者干脆不做——反正下次分配时会覆盖。
小技巧:释放时不做任何数据清理,只做链表操作。这就是O(1)的秘诀。数据残留问题,由分配时的「使用即覆盖」策略来解决。
4.3 内存回收与复用策略
分配和释放都O(1)了,是不是就万事大吉了?不是的。还有一个大问题:内存碎片和内存膨胀。
你想想看,回测系统跑个几天几夜,如果只分配不回收,内存池会越来越大。虽然操作系统有虚拟内存,但物理内存是有限的。所以我们需要一套回收与复用策略。
4.3.1 惰性回收策略
我个人比较推荐「惰性回收」。什么意思呢?就是不到万不得已,不把内存还给操作系统。因为还回去再申请,代价太大了。
具体做法:
- 每个内存池维护一个「空闲块数量」计数器
- 当空闲块数量超过某个阈值(比如总块数的50%),触发回收
- 回收时,只回收连续的空闲块,不回收零散的
// 惰性回收策略示例
void pool_reclaim(MemoryPool* pool) {
size_t total_blocks = pool->total_blocks;
size_t free_blocks = count_free_blocks(pool);
// 空闲率超过60%才触发回收
if (free_blocks > total_blocks * 0.6) {
// 只回收连续的空闲块
reclaim_contiguous_blocks(pool);
}
}
4.3.2 分代复用策略
这个策略是我从JVM垃圾回收里借鉴来的。把内存块分成几代:
| 代别 | 定义 | 回收策略 |
|---|---|---|
| 年轻代 | 刚释放不久的内存块 | 优先分配,不回收 |
| 老年代 | 长时间未被使用的内存块 | 定期回收,还给OS |
| 持久代 | 常驻内存的热点数据块 | 永不回收 |
为什么这么分?因为回测系统里,有些内存块是高频使用的(比如K线数据缓冲区),有些是低频使用的(比如日志缓冲区)。让高频的留在池子里,低频的回收掉,这样内存利用率最高。
避坑指南:我曾经在分代回收上栽过跟头。当时把「最近最少使用」策略用在了内存池上,结果发现回测结果不稳定。后来排查发现,回测数据访问模式是「顺序扫描」而非「局部性访问」,LRU在这里完全失效。所以,策略一定要跟业务场景匹配。
4.4 核心逻辑流程图
说了这么多,咱们用一张图把整个流程串起来。这张图展示了一个内存块从分配到释放再到回收的完整生命周期。
从这张图可以看得很清楚:分配和释放都是O(1)的,只有回收操作可能涉及批量处理。但回收是低频操作,不影响整体性能。
4.5 实战中的避坑指南
最后,分享几个我在实战中踩过的坑:
- 内存对齐问题:我曾经在ARM平台上跑回测,忘了做内存对齐,结果程序直接崩了。记住:分配出去的地址,一定要按最大对齐要求(通常是16字节)对齐。
- 多线程竞争:如果多个线程同时分配,FreeList的头指针会被竞争。我建议用无锁队列或者线程本地缓存(TLS)来解决。
- 内存泄漏检测:在调试阶段,可以给每个分配出去的内存块打上标记。释放时检查标记,如果发现重复释放或者释放了不属于池子的内存,立刻报错。
我的经验:内存池的调试比普通内存管理要难。因为错误往往不是立即暴露的,而是运行几小时后才出现。我习惯在池子里加一个「校验和」字段,每次分配和释放时校验一下,能快速定位问题。
好了,这一章的内容就到这里。分配和释放算法是内存池的基石,理解了O(1)的原理,后面讲多线程优化和缓存友好性时,你就能更轻松地跟上节奏。
公众号:蓝海资料掘金营,微信deep3321