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 可以开启后台线程整理内存,进一步减少前台延迟抖动。我一般会在启动脚本里加上这些配置。
知识体系总览
下面这张图,帮你理清本章的核心逻辑:
总结一下:内存分配优化,说白了就是三件事——能用栈不用堆,复用对象减少创建,选对分配器。这三板斧砍下去,你的交易系统延迟至少能降一个数量级。
嗯,今天就聊到这儿。下一章我们聊聊 CPU 缓存优化,那又是另一个有意思的话题。
公众号:蓝海资料掘金营,微信deep3321