内存池核心设计:固定大小块分配器、空闲链表、内存碎片消除
好,咱们进入正题。这一章讲的是内存池最核心的三个东西:固定大小块分配器、空闲链表、还有内存碎片消除。说白了,这就是内存池的骨架。你把这几个搞明白了,后面那些花哨的优化才有根基。
我个人习惯,设计任何系统都先问自己一个问题:我要解决什么痛点? 高频交易里,内存分配最烦人的就是两件事:一是慢,二是碎。慢是因为系统调用(malloc/free)有锁,碎是因为频繁申请释放导致内存像蜂窝煤一样。内存池就是来收拾这个烂摊子的。
固定大小块分配器
先讲固定大小块。这个思路其实很简单:既然你高频交易里用的对象大小都差不多(比如订单、报价、成交记录),那我干脆提前切好一堆固定大小的块,你来了直接拿,用完还回来。
我在项目中遇到过一种情况:有人想搞通用内存池,支持任意大小分配。结果呢?性能反而比 malloc 还差。为什么?因为通用方案要处理各种对齐、合并、分裂,复杂度上去了,延迟就下不来。所以我的建议是:别贪心,固定大小就是最快的。
来看一个最简单的实现:
// 固定大小块分配器核心结构
struct FixedBlockAllocator {
char* pool; // 预分配的大块内存
size_t block_size; // 每个块的大小(比如64字节)
size_t block_count; // 块的总数
void* free_list; // 空闲链表头
};
这里有个关键点:block_size 怎么选? 我一般会取对象大小的上对齐到 8 或 16 字节。比如你的 Order 结构体是 38 字节,那就对齐到 48 字节。为什么?因为 CPU 喜欢对齐访问,不对齐会罚站(性能惩罚)。
空闲链表
接下来是空闲链表。这是内存池的「调度中心」。每个空闲块里存一个指针,指向下一个空闲块。这样你就不需要额外的数据结构来管理空闲块了——块本身就成了链表节点。
你想想看,这多巧妙?内存块在被分配出去之前,里面存的是下一个空闲块的地址。分配出去后,这块内存就归用户用了。等用户归还时,我们再把它插回链表头部。
核心操作就两个:
- 分配(allocate): 从链表头取一个块,头指针后移。
- 释放(deallocate): 把块插回链表头,更新头指针。
代码实现大概长这样:
void* allocate(FixedBlockAllocator* alloc) {
if (alloc->free_list == nullptr) {
return nullptr; // 内存耗尽,需要扩容
}
void* block = alloc->free_list;
alloc->free_list = *((void**)alloc->free_list);
return block;
}
void deallocate(FixedBlockAllocator* alloc, void* block) {
*((void**)block) = alloc->free_list;
alloc->free_list = block;
}
嗯,这里要注意:分配和释放都是 O(1) 的。 没有遍历,没有排序,没有锁(单线程下)。这就是高频交易要的速度。
内存碎片消除
最后聊内存碎片。这是内存池的「终极目标」。固定大小块分配器天然就能消除外部碎片——因为所有块一样大,不存在「大块被切成小块,小块释放后拼不回大块」的问题。
但是,内部碎片还是有的。 比如你的对象是 38 字节,块大小是 48 字节,那 10 字节就浪费了。这就是内部碎片。怎么处理?
- 方法一: 精确匹配对象大小。比如你只有一种对象,那就把块大小设成 sizeof(Object)。
- 方法二: 多级大小池。比如 32 字节、64 字节、128 字节各一个池,按需分配。这就是后面要讲的「伙伴系统」的雏形。
- 方法三: 接受内部碎片。在高频交易里,延迟比内存更重要。多浪费几个字节,换来 O(1) 分配,值了。
我个人倾向方法三。为什么?因为高频交易场景下,内存通常不是瓶颈。你一个订单才几十字节,一秒钟几百万笔订单也就几十 MB。相比延迟抖动,这点浪费完全可以接受。
我曾经在一个低延迟交易系统里,把原来的 malloc/free 换成了固定大小块分配器。结果呢?平均分配延迟从 200 纳秒降到了 20 纳秒,而且没有一次超过 50 纳秒的抖动。这就是内存池的威力。
最后,我用一张图来总结这一章的核心逻辑:
这张图把整个流程串起来了:预分配的大块内存被切成固定大小的块,空闲块通过链表串起来。分配时从头部取,释放时插回头部。整个过程没有碎片,没有遍历,没有系统调用。
好了,这一章的核心就这些。记住一句话:固定大小 + 空闲链表 = 高性能内存分配的基石。 下一章我们会在这个基础上,加入线程局部缓存和批量操作,让性能再上一个台阶。