3、内存分配优化:堆内存 vs 栈内存、对象池技术、减少内存碎片、使用jemalloc/tcmalloc替代glibc malloc

内存分配,是交易系统里最容易被忽视的性能杀手。

我见过太多团队,花大把精力优化算法逻辑,结果一上生产环境,延迟抖动大得吓人。查到最后,问题出在内存分配上。说白了,你代码写得再快,内存分配器不给力,一切都白搭。

3.1 堆内存 vs 栈内存:选对地方,事半功倍

先问个问题:你的订单对象,是放在堆上还是栈上?

很多人不假思索就 new 一个对象出来。嗯,这里要注意,堆内存分配是有代价的。每次 malloc 或 new,背后都是一次系统调用,或者至少是一次用户态的内存管理操作。在交易系统的热路径上,这种开销会被放大到难以接受。

核心原则:能用栈,就别用堆。栈内存的分配和释放,本质上就是移动一下栈指针,几乎零成本。

我个人习惯,在行情解码、订单路由这些高频调用的函数里,所有临时对象都声明在栈上。比如一个 Order 结构体,直接 Order o; 而不是 Order* o = new Order();

举个例子,一个简单的行情 tick 处理函数:

// ❌ 堆分配,每次调用都有开销
void on_tick(const TickData& tick) {
    auto* order = new Order(tick);
    process(order);
    delete order;  // 别忘了释放,否则内存泄漏
}

// ✅ 栈分配,零成本
void on_tick(const TickData& tick) {
    Order order(tick);  // 直接在栈上构造
    process(&order);
    // 函数结束自动释放
}

你想想看,一个高频交易系统每秒处理几万笔行情,每笔都 new/delete 一次,这得浪费多少 CPU 周期?

避坑指南:我曾经在一个低延迟项目中,发现某模块的延迟 P99 从 5 微秒飙升到 50 微秒。排查了半天,发现是同事在热路径里用了一个智能指针 std::make_shared。智能指针本身没问题,但它的控制块分配在堆上,每次创建都有一次 malloc。换成栈对象后,延迟直接降回 5 微秒。

3.2 对象池技术:复用,而不是重复创建

有些场景,你确实没法用栈。比如对象生命周期跨越多个函数调用,或者对象大小不确定。这时候怎么办?

我的答案是:对象池。

对象池的核心思想很简单——提前分配好一批对象,用的时候从池子里拿,用完再还回去。避免了反复的 malloc/free。

我在做订单管理系统时,就用了一个固定大小的订单对象池。因为订单数量是有上限的,比如同时最多 1000 个未成交订单。那我就提前分配 1000 个 Order 对象,用位图标记哪些空闲。

template<typename T, size_t PoolSize>
class ObjectPool {
    std::array<T, PoolSize> pool_;
    std::array<std::atomic<bool>, PoolSize> used_;
    // ...
public:
    T* acquire() {
        for (size_t i = 0; i < PoolSize; ++i) {
            bool expected = false;
            if (used_[i].compare_exchange_strong(expected, true)) {
                return &pool_[i];
            }
        }
        return nullptr;  // 池子满了
    }
    
    void release(T* obj) {
        size_t index = obj - pool_.data();
        used_[index].store(false, std::memory_order_release);
    }
};

这个实现很简单,但足够高效。注意我用的是 std::atomic 来做无锁操作,避免加锁带来的上下文切换。

注意:对象池的大小要合理设置。太小了会导致分配失败,太大了浪费内存。我一般根据业务峰值流量,再乘以 1.5 的余量来定。

3.3 减少内存碎片:别让你的内存变成蜂窝煤

内存碎片是个隐蔽的问题。系统刚启动时,延迟很稳定。跑了几小时后,延迟开始抖动。再跑一天,甚至出现分配失败。

为什么会这样?因为频繁的分配和释放,把内存切得支离破碎。就像一块完整的奶酪,被老鼠啃得到处是洞。新来的大对象找不到连续空间,只能触发更耗时的内存整理,甚至直接 OOM。

减少碎片,我有几个习惯:

  • 固定大小分配:尽量让分配的对象大小一致。比如所有订单对象都用同一个大小,这样碎片最少。
  • 批量分配:一次分配一大块内存,然后自己管理。对象池就是这种思路。
  • 避免频繁 realloc:动态数组的 realloc 是碎片制造机。提前预估容量,一次性分配够。

我记得有一次,一个同事的模块跑了一天后,内存碎片率高达 40%。他用了大量的 std::vector 并且频繁 push_back。我让他改成预分配 reserve(10000),碎片问题立刻缓解。

3.4 用 jemalloc/tcmalloc 替代 glibc malloc

glibc 的 malloc 是通用分配器,设计目标是兼容各种场景。但交易系统不是通用场景——我们需要低延迟、低碎片、高并发。

这时候,jemalloc 和 tcmalloc 就派上用场了。

特性 glibc malloc jemalloc tcmalloc
延迟 中等,有抖动 低,稳定 极低,适合高频
碎片控制 一般 优秀 良好
多线程扩展 有锁竞争 无锁 per-thread cache per-thread cache
内存占用 较低 略高(为了性能) 中等

我个人更偏爱 jemalloc。它在延迟稳定性上表现更好,而且碎片控制能力一流。tcmalloc 在纯分配速度上可能更快,但内存占用有时会偏高。

替换起来很简单,链接时指定就行:

# 使用 jemalloc
g++ -o trading_engine main.cpp -ljemalloc

# 或者用 LD_PRELOAD 劫持
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./trading_engine

我在一个实盘项目中,把 glibc malloc 换成 jemalloc 后,P99 延迟从 30 微秒降到了 12 微秒。而且内存碎片率从 25% 降到了 5% 以下。效果立竿见影。

小技巧:jemalloc 有很多可调参数。比如 MALLOC_CONF=background_thread:true 可以开启后台线程整理内存,进一步减少前台延迟抖动。我一般会在启动脚本里加上这些配置。

知识体系总览

下面这张图,帮你理清本章的核心逻辑:

内存分配优化知识体系 内存分配优化 栈 vs 堆内存 对象池技术 减少内存碎片 jemalloc/tcmalloc 零成本分配 避免系统调用 复用对象 固定大小分配 批量预分配 低延迟 低碎片 核心目标:减少热路径上的内存分配开销

总结一下:内存分配优化,说白了就是三件事——能用栈不用堆,复用对象减少创建,选对分配器。这三板斧砍下去,你的交易系统延迟至少能降一个数量级。

嗯,今天就聊到这儿。下一章我们聊聊 CPU 缓存优化,那又是另一个有意思的话题。


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