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 核心逻辑流程图

说了这么多,咱们用一张图把整个流程串起来。这张图展示了一个内存块从分配到释放再到回收的完整生命周期。

内存池分配·释放·回收流程图 分配请求 FreeList 为空? 扩容操作 取头部节点 分配完成 使用中... 释放请求 空闲率 > 60%? 回收连续块 还给OS 头插法 插回FreeList 图例: 操作节点 判断节点 成功路径 扩容/回收路径 使用中状态

从这张图可以看得很清楚:分配和释放都是O(1)的,只有回收操作可能涉及批量处理。但回收是低频操作,不影响整体性能。

4.5 实战中的避坑指南

最后,分享几个我在实战中踩过的坑:

  • 内存对齐问题:我曾经在ARM平台上跑回测,忘了做内存对齐,结果程序直接崩了。记住:分配出去的地址,一定要按最大对齐要求(通常是16字节)对齐。
  • 多线程竞争:如果多个线程同时分配,FreeList的头指针会被竞争。我建议用无锁队列或者线程本地缓存(TLS)来解决。
  • 内存泄漏检测:在调试阶段,可以给每个分配出去的内存块打上标记。释放时检查标记,如果发现重复释放或者释放了不属于池子的内存,立刻报错。

我的经验:内存池的调试比普通内存管理要难。因为错误往往不是立即暴露的,而是运行几小时后才出现。我习惯在池子里加一个「校验和」字段,每次分配和释放时校验一下,能快速定位问题。

好了,这一章的内容就到这里。分配和释放算法是内存池的基石,理解了O(1)的原理,后面讲多线程优化和缓存友好性时,你就能更轻松地跟上节奏。


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