目录
[标准库 malloc 的问题](#标准库 malloc 的问题)
[2.1 FreeList:自由链表](#2.1 FreeList:自由链表)
[2.2 Span:内存跨度管理者](#2.2 Span:内存跨度管理者)
[2.3 SizeClass:内存对齐规则](#2.3 SizeClass:内存对齐规则)
[3.1 TLS 线程局部存储](#3.1 TLS 线程局部存储)
[3.2 分配流程:Allocate](#3.2 分配流程:Allocate)
[3.3 慢启动反馈调节算法](#3.3 慢启动反馈调节算法)
[3.4 释放流程:Deallocate](#3.4 释放流程:Deallocate)
[4.1 桶锁机制](#4.1 桶锁机制)
[4.2 获取 Span:GetOneSpan](#4.2 获取 Span:GetOneSpan)
[4.3 批量获取:FetchRangeObj](#4.3 批量获取:FetchRangeObj)
[5.1 核心职责](#5.1 核心职责)
[5.2 申请 Span:NewSpan](#5.2 申请 Span:NewSpan)
[5.3 Span 合并:ReleaseSpanToPagecache](#5.3 Span 合并:ReleaseSpanToPagecache)
[6.1 为什么弃用 unordered_map](#6.1 为什么弃用 unordered_map)
[6.2 基数树(Radix Tree)原理](#6.2 基数树(Radix Tree)原理)
[7.1 分配流程](#7.1 分配流程)
[7.2 释放流程](#7.2 释放流程)
[9.1 分层解耦](#9.1 分层解耦)
[9.2 缓存友好](#9.2 缓存友好)
[9.3 反馈调节](#9.3 反馈调节)
[9.4 空间换时间](#9.4 空间换时间)
引言:为什么需要内存池?
在高性能服务、后端开发以及基础组件设计中,内存分配效率直接决定程序整体吞吐与延迟。原生 malloc/free 在多线程场景下的短板愈发明显,而内存池正是解决这类问题的经典方案。
标准库 malloc 的问题
在多线程环境下直接使用系统 malloc/free,会面临三个核心痛点:
- 锁竞争严重:所有线程共享同一个堆空间,分配与释放必须加锁,高并发下大量线程阻塞,性能急剧下降。
- 小对象分配效率低:每次申请都触发系统调用,用户态与内核态切换开销巨大,小对象频繁申请尤其浪费资源。
- 内存碎片:大量小块内存随机分配与释放,会产生大量无法复用的外碎片与内碎片,导致内存利用率低下。
解决方案:内存池
内存池的核心思想是预分配 + 重复利用,从根本上减少系统调用与锁竞争:
不用内存池:
malloc() → 系统调用 → 加锁 → 返回
free() → 系统调用 → 加锁 → 返回
用内存池:
malloc() → 从池里拿 → 几乎无锁 → 返回
free() → 放回池里 → 几乎无锁 → 返回
通过提前向系统申请大块内存,内部切分管理,让绝大多数分配、释放都在用户态完成,从而实现高性能并发内存管理。
一、整体架构:三层缓存设计
本项目参考 Google TCMalloc 设计思想,实现一套三层缓存架构的并发内存池,职责清晰、层次分明,兼顾速度与内存利用率。
┌─────────────────────────────────────────────────────────────┐
│ ThreadCache(线程缓存) │
│ • 每个线程独立拥有,通过 TLS 实现 │
│ • 申请:桶里有直接拿,无锁! │
│ • 释放:桶里有位置放,无锁! │
│ • 桶满了/空了才需要找下一层 │
└───────────────────────────┬─────────────────────────────────┘
│ 批量申请 / 批量释放
▼
┌─────────────────────────────────────────────────────────────┐
│ CentralCache(中心缓存) │
│ • 所有线程共享,单例模式 │
│ • 桶锁机制:每个桶独立加锁 │
│ • 管理多个 Span,每个 Span 挂载多个小对象 │
│ • 负责从 PageCache 批量获取 Span │
└───────────────────────────┬─────────────────────────────────┘
│ 整 Span 归还
▼
┌─────────────────────────────────────────────────────────────┐
│ PageCache(页缓存) │
│ • 管理以页(8KB)为单位的内存 │
│ • 负责向系统申请/释放大块内存 │
│ • Span 合并:减少外碎片 │
│ • 核心结构:基数树(Radix Tree)快速查找页号→Span │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 系统堆 │
│ • SystemAlloc / SystemFree │
│ • 只有 PageCache 在大块内存不够时才会调用 │
└─────────────────────────────────────────────────────────────┘
设计哲学
| 层级 | 角色 | 目标 |
|---|---|---|
| ThreadCache | 快速路径 | 99% 的分配 / 释放在这一层完成,完全无锁 |
| CentralCache | 协调者 | 减少锁竞争,批量调度内存对象 |
| PageCache | 管理者 | 管理大块内存,合并碎片,与系统交互 |
二、核心数据结构
内存池的高效依赖简洁、紧凑的数据结构,以下是三大核心结构设计。
2.1 FreeList:自由链表
用于管理切分好的小对象,采用先进后出的栈式管理,保证缓存友好与 O (1) 操作。
cpp
class FreeList {
public:
void push(void* obj); // 头插 O(1)
void* pop(); // 头删 O(1)
void PushRange(...); // 批量插入
void PopRange(...); // 批量删除
bool Empty() { return _size == 0; }
size_t Size() { return _size; }
size_t& MaxSize() { return _maxSize; } // 慢启动上限
private:
void* _freeList = nullptr; // 自由链表
size_t _size = 0; // 链表长度
size_t _maxSize = 1; // 批量申请上限
};
为什么用栈而非队列?
- 后进先出,刚释放的对象大概率仍在 CPU 缓存中,缓存命中率更高;
- 实现极简,仅需修改链表头指针即可完成入队、出队。
2.2 Span:内存跨度管理者
Span 是连续内存页的管理单元,所有小对象最终都挂载在 Span 上。
cpp
struct Span {
PAGE_ID _pageId; // 起始页号(一页 = 8KB)
size_t _n; // 页数
Span* _next = nullptr;
Span* _prev = nullptr;
size_t objectsize; // 切分的小对象大小
size_t _useCount; // 当前正在使用的小对象数量
void* _freeList; // 小对象自由链表
bool _isuse = false; // 是否正在使用
};
**示例:**一个 Span 包含 3 页(24KB),切分成 16 byte 小对象:
cpp
页 100 页 101 页 102
┌──────────┬──────────┬──────────┐
│ [16B][16B│][16B][16B│][16B][16B│]...
│ ↓ │ │ │
│ _freeList → [obj] → [obj] → ... → nullptr
└──────────┴──────────┴───────────┘
↑
_pageId = 100
_n = 3
objectsize = 16
_useCount = 0(全部空闲)
2.3 SizeClass:内存对齐规则
为统一管理不同大小对象,采用分级对齐策略,控制内碎片并简化索引计算。
| 大小范围 | 对齐数 | 说明 |
|---|---|---|
| 1 ~ 128 byte | 8 byte | 最小粒度 |
| 129 ~ 1024 byte | 16 byte | 稍大对象 |
| 1025 ~ 8192 byte | 128 byte | 中等对象 |
| 8193 ~ 65536 byte | 1024 byte | 较大对象 |
| 65537 ~ 262144 byte | 8192 byte | 大对象(上限 256KB) |
对齐的意义:
- 地址计算简单,可直接通过除法定位桶索引;
- 严格控制内碎片,碎片率低于 10%;
- 全局共 208 个桶,覆盖所有常见小对象场景。
三、第一层:ThreadCache(线程缓存)
ThreadCache 是整个内存池的快速路径,完全无锁,支撑绝大多数分配与释放。
3.1 TLS 线程局部存储
每个线程独享一个 ThreadCache,通过 TLS(线程局部存储)实现隔离:
cpp
#if defined(__GNUC__) || defined(__clang__)
#define TLS_DECLSPEC __thread
#elif defined(_MSC_VER)
#define TLS_DECLSPEC __declspec(thread)
#endif
static TLS_DECLSPEC ThreadCache* pTLSThreadCache = nullptr;
**无锁原理:**线程之间互不访问对方缓存,天然无竞争,无需加锁。
线程 A 的 ThreadCache 线程 B 的 ThreadCache
┌─────────────────┐ ┌─────────────────┐
│ 桶[0]: [obj] │ │ 桶[0]: [] │
│ 桶[1]: [] │ │ 桶[1]: [obj] │
│ 桶[2]: [obj] │ │ 桶[2]: [] │
└─────────────────┘ └─────────────────┘
↑ ↑
只有 A 能访问 只有 B 能访问
3.2 分配流程:Allocate
cpp
void* ThreadCache::Allocate(size_t size) {
// 1. 对齐计算
size_t alignSize = SizeClass::RoundUp(size);
size_t index = SizeClass::Index(size);
// 2. 桶里有,直接弹出来
if (!_freeLists[index].Empty()) {
return _freeLists[index].pop();
}
// 3. 桶空了,找 CentralCache 批量申请
return FetchFromCentralCache(index, alignSize);
}
3.3 慢启动反馈调节算法
ThreadCache 向中心缓存申请对象时,使用慢启动动态调整批量大小:
cpp
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) {
// 1. 计算批量申请数量
size_t batchNum = std::min(
_freeLists[index].MaxSize(),
SizeClass::NumMoveSize(size)
);
// 2. 达到上限则扩容,慢启动增长
if (batchNum == _freeLists[index].MaxSize()) {
_freeLists[index].MaxSize()++;
}
// 3. 批量获取
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(
start, end, batchNum, size
);
// 4. 返回一个,其余入桶
if (actualNum == 1)
return start;
else {
_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
return start;
}
}
慢启动价值:
- 避免一次性申请过多导致内存闲置;
- 避免频繁申请导致频繁加锁、性能下降;
- 上限随使用量逐步增长,自适应业务场景。
3.4 释放流程:Deallocate
cpp
void ThreadCache::Deallocate(void* ptr, size_t size) {
// 1. 找到对应桶插入
size_t index = SizeClass::Index(size);
_freeLists[index].push(ptr);
// 2. 桶过长则批量归还中心缓存
if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
ListTooLong(_freeLists[index], size);
}
}
批量归还可大幅减少锁竞争,一次加锁完成大量对象回收。
四、第二层:CentralCache(中心缓存)
CentralCache 作为线程共享的中间层,负责批量调度内存,使用桶锁降低竞争。
4.1 桶锁机制
每个内存大小桶独立一把锁,而非全局锁:
cpp
class SpanList {
std::mutex _mtx; // 每个桶独立锁
Span* _head;
};
SpanList _spanList[NFREE_LISTS]; // 208 个桶,208 把锁
桶锁优势:
- 全局锁:所有分配串行排队,并发极差;
- 桶锁:不同大小对象分配并行进行,锁冲突概率极低。
4.2 获取 Span:GetOneSpan
当线程缓存桶空时,中心缓存负责提供可用 Span,无可用 Span 则向页缓存申请:
cpp
Span* CentralCache::GetOneSpan(SpanList& list, size_t size) {
// 1. 查找当前桶内有空闲对象的 Span
Span* it = list.Begin();
while (it != list.End()) {
if (it->_freeList != nullptr)
return it;
it = it->_next;
}
// 2. 解锁,向 PageCache 申请
list._mtx.unlock();
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(
SizeClass::NumMovePage(size)
);
PageCache::GetInstance()->_pageMtx.unlock();
// 3. 切分 Span 为小对象链表
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
span->_freeList = start;
start += size;
void* tail = span->_freeList;
while (start < end) {
NextObj(tail) = start;
tail = NextObj(tail);
start += size;
}
NextObj(tail) = nullptr;
// 4. 重新加锁,将 Span 挂入桶
list._mtx.lock();
list.PushFront(span);
return span;
}
4.3 批量获取:FetchRangeObj
从 Span 中批量截取一段自由链表,返回给 ThreadCache:
cpp
size_t CentralCache::FetchRangeObj(
void*& start, void*& end,
size_t batchNum, size_t size
) {
size_t index = SizeClass::Index(size);
_spanList[index]._mtx.lock();
Span* span = GetOneSpan(_spanList[index], size);
assert(span && span->_freeList);
// 批量取对象
start = span->_freeList;
end = start;
size_t actualNum = 1;
while (actualNum < batchNum && NextObj(end) != nullptr) {
end = NextObj(end);
actualNum++;
}
// 截断链表
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
_spanList[index]._mtx.unlock();
return actualNum;
}
五、第三层:PageCache(页缓存)
PageCache 是最底层,管理以页为单位的大块内存,负责与系统交互并合并碎片。
5.1 核心职责
- 管理页级 Span;
- 向系统申请 / 释放大块内存;
- Span 前后合并,消除外碎片;
- 提供页号到 Span 的极速映射。
5.2 申请 Span:NewSpan
cpp
Span* PageCache::NewSpan(size_t k) {
assert(k > 0);
// 1. 超大页直接向系统申请
if (k > NPAGES - 1) {
void* ptr = SystemAlloc(k);
Span* span = spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
return span;
}
// 2. 对应桶有直接返回
if (!_spanLists[k].Empty()) {
return _spanLists[k].PopFront();
}
// 3. 更大 Span 切分
for (size_t i = k + 1; i < NPAGES; i++) {
if (!_spanLists[i].Empty()) {
Span* nspan = _spanLists[i].PopFront();
Span* kspan = spanPool.New();
kspan->_pageId = nspan->_pageId;
kspan->_n = k;
nspan->_pageId += k;
nspan->_n -= k;
_spanLists[nspan->_n].PushFront(nspan);
return kspan;
}
}
// 4. 无空闲内存,向系统申请 128 页
Span* NewBigSpan = spanPool.New();
void* ptr = SystemAlloc(NPAGES - 1);
NewBigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
NewBigSpan->_n = NPAGES - 1;
_spanLists[NewBigSpan->_n].PushFront(NewBigSpan);
return NewSpan(k);
}
5.3 Span 合并:ReleaseSpanToPagecache
合并是降低外碎片的关键,释放 Span 时尝试与前后空闲 Span 合并:
cpp
void PageCache::ReleaseSpanToPagecache(Span* span) {
if (span->_n > NPAGES - 1) {
SystemFree((void*)(span->_pageId << PAGE_SHIFT));
return;
}
// 向前合并
while (true) {
PAGE_ID preid = span->_pageId - 1;
Span* prespan = _idSpanMap.get(preid);
if (!prespan || prespan->_isuse) break;
if (prespan->_n + span->_n > NPAGES-1) break;
span->_pageId = prespan->_pageId;
span->_n += prespan->_n;
_spanLists[prespan->_n].Erase(prespan);
spanPool.Delete(prespan);
}
// 向后合并
while (true) {
PAGE_ID nextid = span->_pageId + span->_n;
Span* nextspan = _idSpanMap.get(nextid);
if (!nextspan || nextspan->_isuse) break;
if (nextspan->_n + span->_n > NPAGES-1) break;
span->_n += nextspan->_n;
_spanLists[nextspan->_n].Erase(nextspan);
spanPool.Delete(nextspan);
}
_spanLists[span->_n].PushFront(span);
span->_isuse = false;
}
六、关键优化:基数树
页号到 Span 的映射是释放流程的关键路径,性能直接影响整体效率。
6.1 为什么弃用 unordered_map
- 哈希冲突可能导致退化为 O (n);
- 节点离散,缓存命中率低;
- 全局锁竞争严重,高并发瓶颈明显。
6.2 基数树(Radix Tree)原理
采用两层基数树,通过位分割实现真正 O (1) 查找:
PAGE_ID 结构(32 位 - PAGE_SHIFT = 19 位):
┌─────────────────┬───────────┐
│ 高 11 位 │ 低 8 位 │
│ (根节点索引) │ (叶子索引)│
└─────────────────┴───────────┘
查找逻辑:
cpp
void* get(Number k) {
Number i1 = k >> LEAF_BITS;
Number i2 = k & (LEAF_LENGTH-1);
if (root_[i1] == nullptr) return nullptr;
return root_[i1]->values[i2];
}
优势:
- 纯位运算,无哈希计算;
- 无冲突,稳定 O (1);
- 内存连续,缓存友好;
- 支持无锁读取,并发性能极强。
七、完整数据流图
7.1 分配流程
用户调用 ConcurrentAlloc(size)
│
▼
size <= 256KB?
│
┌────┴────┐
│ 是 │ 否
▼ ▼
ThreadCache PageCache
│ │
▼ │
桶有空间? │
│ 是 │ 否 │
▼ ▼ │
pop Fetch │
│ │
▼ │
CentralCache │
│ │
▼ │
桶有Span? │
│ 是 │ 否 │
▼ ▼ │
取N个 NewSpan
│ │
▼ │
PageCache
│
▼
系统申请
7.2 释放流程
用户调用 ConcurrentFree(ptr)
│
▼
通过地址算页号
PageCache._idSpanMap.get(页号)
│
▼
找到 Span
│
▼
Span._useCount--
│
▼
useCount == 0?
│
┌────┴────┐
│ 是 │ 否
▼ │
还给PageCache │ 完成
│ │
▼ │
向前合并? ──→ 相邻Span空闲?
│ │
▼ │
向后合并? ──→ 相邻Span空闲?
│ │
▼ │
挂回PageCache桶
八、性能测试
测试环境
- 编译器:GCC 13.2.0 (MinGW-W64)
- 优化级别:-O2
- 场景:4 线程,10 轮,每轮 10000 次分配 / 释放
测试结果
| 指标 | ConcurrentAlloc | malloc | 提升 |
|---|---|---|---|
| Alloc (10 万次) | 33 ms | 87 ms | 2.6x |
| Dealloc (10 万次) | 31 ms | 87 ms | 2.8x |
| 总计 | 64 ms | 174 ms | 2.7x |
性能提升来源
- 基数树替代哈希表:核心性能提升点;
- ThreadCache 无锁:绝大多数操作无同步开销;
- 桶锁:大幅降低锁竞争;
- 慢启动 + 批量操作:减少系统调用与锁次数。
九、核心设计思想总结
9.1 分层解耦
- ThreadCache:无锁快速分配
- CentralCache:批量调度、桶锁并行
- PageCache:页管理、碎片合并
9.2 缓存友好
连续内存结构 + LIFO 策略,最大化 CPU 缓存命中率。
9.3 反馈调节
慢启动算法自适应业务负载,平衡速度与内存占用。
9.4 空间换时间
多层缓存与基数树,用可控内存开销换取极致性能。
十、未来改进方向
- 对基数树实现读写锁,进一步提升并发读性能;
- 扩大 ThreadCache 容量,减少跨层交互;
- 支持大页内存(hugetlb),降低 TLB miss;
- 集成内存泄漏检测与堆分析工具;
- 支持多 NUMA 节点感知分配。
项目完整代码可移步 Gitee仓库:高并发内存池查看。