4. 内存池与预分配:预分配策略,避免频繁的内存申请与释放

内存分配,说白了就是跟操作系统要内存。每次 mallocnew,都是一次系统调用。系统调用有多慢?我做过一个测试,在低延迟交易场景下,一次 malloc 平均耗时 200-300 纳秒。看起来不多?但你的策略引擎一秒钟可能要处理几万笔订单,每笔订单又涉及多个对象创建。嗯,积少成多,延迟就上去了。

更麻烦的是,频繁分配释放会导致内存碎片。碎片多了,明明还有空闲内存,但连续空间不够,分配就会失败。我在一个高频交易项目中遇到过,系统跑了 4 个小时后,突然报 std::bad_alloc。查了半天,不是内存泄漏,是碎片太多。从那以后,我对预分配策略就格外重视了。

4.1 为什么需要预分配?

交易系统的核心诉求是「可预测的低延迟」。你想想看,如果每次行情来了,系统才临时去申请内存,那延迟的抖动会非常大。行情密集时,内存分配更频繁,延迟反而更高——这完全违背了我们的目标。

预分配的核心思想很简单:在系统启动时,一次性申请足够的内存,后续所有对象都在这个预分配的内存池中创建和销毁。说白了,就是把「运行时分配」变成「启动时分配」。

预分配带来的三个好处:

  • 延迟确定性:分配操作变成 O(1) 的指针移动,没有系统调用
  • 缓存友好:连续内存空间,CPU 缓存命中率大幅提升
  • 零碎片:固定大小的内存块,不会产生外部碎片

4.2 固定大小内存池

我个人习惯,对于交易系统中频繁创建的小对象(比如订单、成交回报、行情快照),使用固定大小的内存池。每个池子只管理一种固定大小的对象。

来看一个简单的实现思路:

// 固定大小内存池的简化实现
template<typename T>
class FixedPool {
private:
    char* pool;           // 预分配的内存块
    size_t block_size;    // 每个对象的大小
    size_t capacity;      // 最大对象数量
    size_t free_head;     // 空闲链表头
    
public:
    FixedPool(size_t count) 
        : capacity(count), block_size(sizeof(T)) {
        // 一次性分配所有内存
        pool = new char[block_size * capacity];
        
        // 初始化空闲链表
        for (size_t i = 0; i < capacity - 1; ++i) {
            *(size_t*)(pool + i * block_size) = i + 1;
        }
        *(size_t*)(pool + (capacity-1) * block_size) = -1;
        free_head = 0;
    }
    
    T* allocate() {
        if (free_head == -1) return nullptr;
        T* obj = (T*)(pool + free_head * block_size);
        free_head = *(size_t*)obj;
        return new (obj) T();  // placement new
    }
    
    void deallocate(T* obj) {
        obj->~T();
        size_t index = ((char*)obj - pool) / block_size;
        *(size_t*)obj = free_head;
        free_head = index;
    }
};

这段代码里,我用了空闲链表来管理可用块。分配时从链表头取一个,释放时插回链表头。都是 O(1) 操作,没有系统调用,没有锁竞争(单线程场景下)。

我的经验: 对于订单对象,我一般预分配 10 万个槽位。为什么是 10 万?因为我的系统峰值并发订单数不会超过 5 万,留一倍余量。预分配太多浪费内存,太少又不够用。这个数字需要根据你的业务压测来确定。

4.3 变长对象的内存池

固定大小池子好是好,但有些场景下对象大小不固定。比如网络消息的缓冲区,有的消息 64 字节,有的 1KB。怎么办?

我常用的策略是「分级池」:把对象按大小分成几个档次,每个档次一个固定池。比如:

档次 大小范围 预分配数量
≤ 64 字节 100000
64 - 256 字节 50000
256 - 1024 字节 10000

分配时,根据请求大小找到对应的池子。如果超过最大档次,才回退到系统 malloc。不过说实话,在交易系统里,超过 1KB 的临时对象很少见。如果真的需要,我会考虑用共享内存或者 mmap 来管理。

4.4 预分配策略的避坑指南

我曾经踩过的坑:

  • 预分配不等于零成本:启动时一次性分配大块内存,操作系统可能不会立即分配物理页。第一次访问时会有缺页中断,导致延迟抖动。解决办法:分配后立即遍历一遍,触发所有页的物理映射。
  • 池子大小要留余量:我见过有人把池子大小设成压测峰值的 100%,结果线上流量稍微波动就溢出了。建议留 20%-50% 的余量。
  • 注意线程安全:多线程场景下,内存池的分配和释放需要加锁。但别用全局锁,可以用线程本地池(Thread Local Pool)来避免竞争。

4.5 实战:订单处理中的预分配

拿一个实际的订单处理流程来说。系统收到一个订单请求,需要创建 Order 对象、填充字段、放入订单簿、生成回执。如果每个步骤都临时分配内存,延迟会很难看。

我的做法是:

  1. 启动时预分配 10 万个 Order 对象池
  2. 预分配 10 万个 OrderReceipt 对象池
  3. 预分配 5 万个 OrderBookEntry 对象池
  4. 每个网络连接预分配 64KB 的读写缓冲区

这样,整个订单处理链路中,零动态内存分配。所有对象都从池子里取,用完归还。延迟从原来的微秒级抖动,变成了稳定的几百纳秒。

核心原则: 交易系统的内存分配,应该像「借书」一样——从固定的书架上取,看完放回原处。而不是像「买书」——每次都要去书店(操作系统)买一本新的。

4.6 知识体系总览

下面这张图,是我对内存池与预分配策略的总结。你可以把它当作一个快速参考:

内存池与预分配策略 预分配策略 固定大小内存池 空闲链表管理 O(1) 分配/释放 分级内存池 小对象池 (≤64B) 中对象池 (64-256B) 大对象池 (256-1KB) 线程本地池 (TLP) 无锁分配 避免线程竞争 核心目标:零动态分配,延迟确定性 启动时分配 → 运行时复用 → 避免系统调用和碎片

这张图展示了三种主要的预分配策略。我个人最常用的是「固定大小池 + 线程本地池」的组合。固定池处理小对象,线程本地池避免锁竞争。至于分级池,一般用在网络层处理变长消息时。

最后说一句:预分配不是银弹。如果你的系统对象生命周期差异很大,或者对象大小分布极不均匀,预分配可能会浪费大量内存。这时候,你可能需要结合内存池和智能内存回收策略。但那是另一个话题了。

一句话总结: 预分配的核心,就是用启动时的一次性开销,换取运行时稳定、可预测的低延迟。在交易系统里,这个交换非常值得。

专注资料整理