这是一个参考 tcmalloc 分层思想实现的并发内存池项目,核心目标是降低多线程场景下频繁申请/释放小块内存时的锁竞争与系统调用开销。
当前版本重点覆盖 256KB 以内的小对象分配,整体采用 ThreadCache -> CentralCache -> PageCache 的三级缓存架构:线程先尽量在本地拿内存,本地不够再向中心缓存批量申请,中心缓存不够再向页缓存拿页,页缓存没有合适页块时再向系统申请大块内存。
- 参考 tcmalloc 的三级缓存思路,结构清晰,职责分层明确
- 线程本地缓存减少锁竞争,适合高频小对象分配
- 中心缓存按大小分类管理
Span,支持批量搬运对象 - 页缓存按页管理大块内存,支持切分与相邻
Span合并 - 使用对象内存本身维护自由链表,额外元数据开销小
- 提供基础测试代码,便于直接运行验证
项目的核心不是“单纯存一堆空闲内存”,而是把不同粒度的内存管理拆成三层:
ThreadCache:线程私有的小对象缓存,优先命中本地自由链表,速度最快CentralCache:所有线程共享的中间层,负责把一个Span切好的对象批量发给线程,也负责接收线程批量归还的对象PageCache:页级别的大块内存管理层,负责按页分配、拆分、合并Span
flowchart TD
A["业务线程"] --> B["ConcurrentAlloc / ConcurrentFree"]
B --> C["ThreadCache<br/>线程私有自由链表"]
C -->|本地命中| D["直接返回对象"]
C -->|本地不足或本地过长| E["CentralCache<br/>按大小分类的 SpanList"]
E -->|Span 中还有对象| C
E -->|没有可用 Span| F["PageCache<br/>按页管理 Span"]
F -->|拆分现有大 Span| E
F -->|没有合适页块| G["SystemAlloc<br/>VirtualAlloc"]
这套结构的核心收益有两个:
- 大多数小对象请求都在
ThreadCache内完成,不需要加锁 - 即使需要共享层,也尽量通过“批量取、批量还”来摊薄锁成本
项目不是把每个字节大小都单独建一个链表,而是先做“对齐”,再把同一类大小映射到同一个桶中。
SizeClass::Roundup 的实现思路是按区间采用不同对齐粒度:
| 对象大小区间 | 对齐粒度 |
|---|---|
1 ~ 128 字节 |
8 字节对齐 |
129 ~ 1024 字节 |
16 字节对齐 |
1025 ~ 8KB |
128 字节对齐 |
8KB ~ 64KB |
1024 字节对齐 |
64KB ~ 256KB |
8192 字节对齐 |
这样做的好处是:
- 小对象分得更细,内部碎片更少
- 大对象分得更粗,桶数量不会爆炸
- 同一对齐后的大小一定进入同一个桶,后续管理简单稳定
SizeClass::index 则负责把“对齐后的大小”映射到具体下标。整个项目一共准备了 208 个自由链表桶,用来覆盖 256KB 以内的小对象范围。
ThreadCache 管对象,CentralCache 管批量流转,PageCache 管页和 Span。三层关注点不同,因此实现上既能保持速度,也能控制碎片。
项目里真正把三层串起来的关键结构是 Span。它表示“一段连续页组成的大块内存”,主要记录:
- 起始页号
_pageId - 页数量
_n - 当前小块自由链表
_freelist - 已分配给线程缓存的对象数
_usecount - 当前
Span是否正在被CentralCache使用的标记_isUse
有了 Span,页缓存可以按页切大块,中心缓存可以按对象粒度继续切小块,线程缓存则只关心拿到的对象链表。
- 用户调用
ConcurrentAlloc(size) - 进入当前线程的
ThreadCache - 对请求大小执行
Roundup - 用
index找到对应桶 - 如果线程本地自由链表非空,直接返回
- 如果本地为空,则从
CentralCache批量申请一段对象 - 如果中心缓存里也没有可用对象,就向
PageCache要一个合适的Span - 如果页缓存没有合适页块,则调用
VirtualAlloc申请更大的连续页,再切分后使用
sequenceDiagram
participant T as 线程
participant TC as ThreadCache
participant CC as CentralCache
participant PC as PageCache
participant OS as 系统
T->>TC: allocate(size)
TC->>TC: Roundup + index
alt 本地链表非空
TC-->>T: 直接返回对象
else 本地链表为空
TC->>CC: FetchRangeObj(batch)
alt 中心缓存有可用对象
CC-->>TC: 返回一段对象链
else 中心缓存无可用 Span
CC->>PC: newSpan(k)
alt 页缓存有合适 Span
PC-->>CC: 返回 Span
else 页缓存无可用页块
PC->>OS: VirtualAlloc
OS-->>PC: 连续页内存
PC-->>CC: 切分后的 Span
end
CC-->>TC: 返回一段对象链
end
TC-->>T: 返回首对象,剩余对象挂回本地链表
end
- 用户调用
ConcurrentFree(ptr, size) - 对应对象先回收到当前线程的
ThreadCache - 如果本地链表过长,就批量归还给
CentralCache CentralCache通过地址反查对象属于哪个Span- 当某个
Span的_usecount降为0,说明这个Span对应的小块都回来了 - 该
Span退回PageCache PageCache尝试与左右相邻空闲Span合并,减轻外部碎片
这是最底层的小对象自由链表,实现上直接把对象内存的前几个字节当作“下一个节点指针”来用,所以不需要额外节点结构。
它提供两类操作:
- 单对象操作:
push、pop - 批量操作:
PushRange、PopRange
批量接口很重要,因为线程缓存和中心缓存之间不是一个对象一个对象搬,而是一段链表整体搬运,这样能明显减少链表操作次数。
SpanList 是一个带哨兵位的双向循环链表,用来挂接同一类 Span。它的特点是:
- 头插、删除都是常数时间
- 带头结点后,边界情况少,逻辑更稳
- 非常适合做桶内
Span管理
这是页缓存里非常关键的一张映射表,用页号快速定位对象属于哪个 Span。
它承担两个任务:
- 对正在使用中的
Span,支持对象地址反查所属Span - 对空闲
Span,保留边界页映射,方便后续向左、向右做合并
这也是整个回收路径能闭环的关键。
下面不按代码逐行解释,而是按“函数在架构中的职责”来说明。
-
ConcurrentAlloc对外分配入口。先确保当前线程有自己的ThreadCache,然后把请求交给线程本地缓存处理。 -
ConcurrentFree对外释放入口。当前实现要求调用者传入正确的对象大小,释放时同样走线程本地缓存。 -
ThreadCache::allocate小对象分配主流程。先对齐,再算桶下标,本地有就直接取;没有就进入共享层拿一批回来。 -
ThreadCache::deallocate小对象释放主流程。对象先回收到本地自由链表,本地链表达到阈值后再批量吐回中心缓存,避免共享层频繁加锁。 -
ThreadCache::FetchFromCentralCache线程缓存向中心缓存取对象的关键函数。它一次不是只拿一个,而是按batchNum批量获取,自己留一个返回给用户,剩余对象挂回本地链表。这样后续相同大小请求大概率都能在本地命中。 -
ThreadCache::ListTooLong当某个桶的本地链表过长时,把一整段对象批量归还给CentralCache,防止线程私有缓存无限膨胀。
-
SizeClass::Roundup核心作用是做分段对齐。它不是统一按固定粒度对齐,而是“小对象细对齐、大对象粗对齐”,这样在碎片和桶数量之间做了平衡。 -
SizeClass::index核心作用是把“对齐后的大小”映射到唯一桶编号。这样线程缓存和中心缓存都能按同一套规则找到自己的链表位置。 -
SizeClass::NumMoveSize用来决定一次从中心缓存搬多少个对象。对象越小,单次搬运数量越大;对象越大,单次搬运数量越小。这是典型的批量摊销思路。
-
CentralCache::FetchRangeObj中心缓存给线程缓存发对象的主函数。它从某个大小类对应的SpanList中找到可用Span,摘下一段对象链表返回,并增加该Span的_usecount。 -
CentralCache::GetOneSpan当当前大小类下没有可分配对象时,这个函数负责向下层PageCache要一个新的Span,再把这个Span按固定对象大小切成自由链表,挂回对应桶。 -
CentralCache::ReleaseListToSpans线程缓存归还对象时,中心缓存通过地址找到对象所属Span,把对象重新挂回Span的小块链表。当_usecount归零时,说明这个Span已经完全空闲,可以继续交回页缓存。
-
PageCache::NumMovePage根据对象大小和单次批量搬运数量,算出向页缓存申请多少页更合适。它本质上是在“批量分配效率”和“页级浪费”之间取平衡。 -
PageCache::newSpan页缓存的主分配函数。优先看当前页数桶里有没有现成Span,没有就向更大的桶借,再切出目标页数;如果还是没有,就直接向系统申请一大块连续页。 -
PageCache::_newSpan这个函数在当前版本里声明了但没有实际使用,说明后续还能继续重构或扩展页分配逻辑。 -
PageCache::MapObjectToSpan根据对象地址快速定位所属Span。这是释放路径必须依赖的能力,否则中心缓存无法知道对象应该回到哪一段页内存。 -
PageCache::ReleaseSpanToPageCache页缓存回收Span的关键函数。它会尝试向左、向右查找相邻空闲Span并合并,减少外部碎片。这也是页级管理层最重要的价值之一。
memory_pool_improvement
├─ ConcurrentAlloc.h 对外分配与释放接口
├─ ThreadCache.h
├─ ThreadCache.cpp 线程级缓存与大小分类
├─ CentralCache.h
├─ CentralCache.cpp 中心缓存与 Span 中转
├─ PageCache.h
├─ PageCache.cpp 页缓存、页切分与合并
├─ main.cpp 基础测试代码
└─ CMakeLists.txt 构建脚本
当前实现使用了 Windows 下的 VirtualAlloc,因此更适合在 Windows 环境下直接编译运行。
cmake -S . -B build
cmake --build build --config Debug./build/Debug/memory_pool_improvement.exemain.cpp 目前包含几类基础测试:
- 基本申请与释放
1000次相同大小对象申请与释放- 多种大小混合申请与释放
- 简单压力测试
上图展示的是整个内存池的职责划分。可以把它理解成“本地快速通道 + 共享中转层 + 底层页管理层”。
- 线程访问优先走本地,追求速度
- 本地不够时走共享层,追求批量周转
- 共享层不够时走页层,追求大块管理和合并能力
第二张图展示的是一次对象申请如何逐层下探,又如何把多余对象留在本地缓存中,以提高下一次命中率。
如果把这个项目和普通 new/delete 的频繁系统调用相比,最大的区别就在于:
- 不把每次请求都下沉到系统层
- 不把每次释放都立即归还给系统
- 通过缓存和批量搬运,把频繁的小操作收敛成较少的大操作
为了让项目定位更清楚,下面这些点直接说明:
- 当前主要覆盖
256KB以内的小对象分配 ConcurrentFree需要由调用者传入正确的对象大小- 系统申请部分当前基于 Windows 的
VirtualAlloc - 目前已经有基础测试,但还没有加入更系统的多线程性能对比测试
- 代码里部分注释存在编码问题,不影响逻辑,但后续可以统一整理
这个项目最有价值的地方,不只是“实现了一个能跑的内存池”,而是把高频内存分配问题拆成了三层,并且通过对齐、桶映射、批量搬运、页切分和页合并,把性能和空间管理串成了一条完整链路。
如果继续往下扩展,这个项目还可以继续补:
- 大对象直通系统分配路径
- 更完整的多线程压测
- 统计信息与监控接口
- 更细致的回收策略与慢启动阈值调优
作为一个仿 tcmalloc 思路的练手项目,这一版已经把核心骨架搭起来了,而且主线非常完整。