做内存池这个项目,最开始倒不是什么宏大理想,纯粹是被性能压测报告逼的。那会儿维护的一个多线程服务,高峰期线程数一上去,分配效率就成了明显的短板,耗时占比一度接近百分之二十。后来我花了几个周末把FastAllocator的整体设计过了一遍,目标是做一个以多线程场景为核心的高性能内存池,这篇就是整个系列的第一篇文章,先把项目背景、设计目标、整体架构和几个关键的取舍讲清楚。
1. 项目由来与要解决的真实痛点
1.1 一次性能压测暴露出的分配瓶颈
先描述一下最初的场景。某个模拟服务在并发线程数达到32以上的时候,吞吐量上不去,用性能分析工具抓热点,发现很大一部分时间耗在内存分配和释放上。它的负载本身不算复杂,主要是大量小块对象反复创建销毁,比如一些短生命周期的事务结构体,每个只有几十字节。这种模式对通用分配器是最不友好的场景之一。
我当时第一反应是排查有没有内存泄漏导致分配器行为异常,确认没有之后,又怀疑是锁冲突。通用分配器为了避免线程安全问题,内部一定会有同步机制,但在高频分配场景下,锁竞争会被急剧放大。于是我做了一个很简单的验证实验:在同样的分配模式下,把malloc换成预分配的一块固定大小的内存池,直接上裸指针分配,吞吐量立刻上去了。这个实验基本证实了问题不在应用逻辑,而在通用分配器对这个负载形态不够友好。
1.2 传统malloc在高并发小对象场景下的三个瓶颈
从那个实验出发,我开始系统整理通用分配器在这个场景下的瓶颈点,大致有三个。
第一个是锁竞争。通用分配器要服务所有调用方,内部的共享元数据结构必须加锁。线程多、分配频繁的时候,锁的等待时间会直接影响分配延迟。即便用了细粒度锁,也扛不住极高频的短临界区操作。
第二个是系统调用和上下文切换。当内存不足时,通用分配器会通过系统调用向操作系统申请内存,这个操作是重量级的。在高频分配场景下,分配器的缓存策略如果没有命中,频繁触发系统调用,性能会断崖式下跌。
第三个是内存碎片。标准分配器为了通用性,会尽量复用各种大小的内存块,但碎片会导致两个后果:一是内存利用率下降,二是分配器为了找可用块遍历空闲列表,耗时变长。在小对象反复分配场景,碎片问题尤其明显。
这三个瓶颈叠加起来,就是压测报告里那个百分之二十的来历。要解决它们,不能靠调参数,得从分配器的结构设计入手。
1.3 FastAllocator的定位与项目边界
基于这个背景,我给FastAllocator定了一个清晰的定位:一个面向多线程高并发场景、以小块内存分配优化为核心的自研内存池。
同时我也给自己划了几条边界,避免陷入过度设计。
- 不做通用的malloc替代品:只针对自己定义的接口和场景优化,不追求所有负载下的最优。
- 不承诺内存占用一定低于系统分配器:内存池通常会预留一部分内存以减少系统调用,所以内存占用允许略高。
- 不追求跨平台的极致兼容性:第一版先跑通核心设计,再谈移植。
这个边界非常重要。很多人一上来就想做全能型分配器,结果被各种边界情况拖死。FastAllocator的边界是清楚的:优先解决高并发小对象分配,其他场景往后放。
2. 立项前的方案调研:不同分配器带来的设计启发
2.1 并发优化的核心思路:缓存与批量转移
动手写之前,我花了不少时间研究业界已有的高性能内存池方案。虽然不打算抄一个现成的,但从它们的设计里能提炼出很多通用原则。
最核心的思路就是分担压力,不要把所有的分配请求都打在同一个全局结构上。为每个工作线程保留一块独立的缓存区域,线程内的分配和释放直接操作这块区域,不需要任何锁。只有本地缓存不够或者回收过量时,才需要和全局结构交互。
这个思路解决了很大一部分锁竞争问题,但引入了一个新问题:线程本地缓存怎么管理?如果每个线程都预留大量内存,线程多了内存浪费会很大。所以还需要一个"按需缓存、批量获取"的机制。线程本地缓存只保留一小部分空闲块,用完了就一次性从全局结构拿一批;释放的时候也不是一次释放一个,而是攒到一定数量再批量归还。
批量转移这个思路在性能优化里很常见,但放进内存池里,效果立竿见影:分配路径上的锁操作频率会被压到一个很低的水平,因为每次拿锁都拿了一批。
2.2 碎片控制的思路:固定大小分桶与分层回收
另一个启发是关于内存碎片的管理。
通用分配器面临的是任意大小的分配请求,体积从几个字节到几兆字节都可能出现,要兼顾所有情况非常复杂。但实际业务场景里,大部分分配请求集中在某几个尺寸附近。如果对内存块做固定大小的分桶,把不同尺寸的请求映射到对应桶里,每个桶里的块大小一致,就能显著减少外部碎片。
这个思路有点像停车场的分区管理——大车停大车位,小车停小车位,互不占用,长期运行下来车位利用率依然可控。
分层回收也是从调研中收获的重要思路。线程本地缓存、全局缓存、操作系统三级结构并不只是为了并发性能,它同时也是一个天然的回收机制。某个线程释放的内存在本地缓存中积累,如果超过了阈值,就归还到全局缓存;全局缓存积压过多,再归还给操作系统。这样内存不会长期滞留在某个线程手上,全局的内存利用率会好很多。
2.3 我主动舍弃的设计
调研过程中,我也看到不少看起来很炫但实际与我的目标场景无关的特性,这些我全部舍弃了。
- 不做内存压缩迁移。像压缩式堆那样把存活对象移动到一起,虽然能彻底消除碎片,但实现复杂度太高,而且需要与对象生命周期挂钩,不适合通用内存池。
- 不做对象构造析构跟踪。内存池只负责原始内存的管理,不感知对象的构造函数和析构函数。这样接口更干净,耦合更低。
- 不做基于内存访问频率的淘汰策略。有些分配器会跟踪每个页的冷热程度来决定回收策略,这是另一个层面的复杂度的提升,第一版不做。
舍弃这些设计不是为了偷懒,而是为了控制复杂度。内存池这种基础组件,一旦出bug,排查代价极高。第一版的目标是稳定、清晰、可验证的设计主干,而不是花样繁多的功能集合。
3. 整体架构设计:三层的分工与协作
3.1 分层总览:ThreadCache、CentralCache、PageMap与PageHeap
FastAllocator的整体架构分为三层,每一层有明确的职责边界。
第一层是ThreadCache(线程缓存),每个线程一份,内部是若干条自由链表,每条链表对应一个固定大小分桶。线程内分配和释放直接操作自由链表的头节点,无锁,速度最快。
第二层是CentralCache(中心缓存),全局共享,同样维护分桶自由链表,但它的节点粒度更大。当ThreadCache缓存不足或回收过量时,会从CentralCache批量取块或归还块。这层需要同步机制,但锁的粒度控制得比较细。
第三层是PageMap与PageHeap(页管理与大块堆)。PageMap负责维护虚拟内存页到Span(一个连续的页集合)的映射关系;PageHeap管理从操作系统申请到的大块内存,按页数组织若干条空闲链表。CentralCache需要内存时,会从PageHeap中取Span并切割成小块。
核心理念是:越靠近线程,操作越轻、越快;越靠近操作系统,操作越重、越慢。ThreadCache保证大多数分配请求根本不需要走到全局结构。
3.2 分配路径与释放路径的完整旅程
设计架构时,我把一次分配的完整旅程在纸上画了出来,这里用文字描述一下。
当一个分配请求到达时,首先根据请求大小找到对应的对齐后尺寸,再找到该尺寸在分桶表中的索引。然后直接在ThreadCache对应的自由链表头部取一个节点,整个过程就是一个指针弹出的操作。这是最快路径,估计算只在几十纳秒级别。
如果ThreadCache对应的链表为空,就需要触发一次"慢路径":ThreadCache向CentralCache请求一批空闲块,比如一次取8个或16个,取回来后放到本地链表上,再从链表头部弹出一个。这个路径会有一次锁操作,但频率远低于每次分配。
如果CentralCache对应分桶的空闲块也不够,它会继续向PageHeap请求一个Span。PageHeap从它的空闲链表中找到合适的页,切割成若干个小块,再把一部分给CentralCache,CentralCache再分给ThreadCache。
释放路径大致是逆过程:线程释放内存块时,先根据期地址反查PageMap,得到它所属的Span信息和分桶索引,然后把内存块插回ThreadCache对应链表。如果ThreadCache中的缓存超过阈值,就批量归还到CentralCache;CentralCache中积压的Span达到阈值,再归还给PageHeap,PageHeap在极端情况下把内存归还给操作系统。
3.3 模块之间的数据结构契约
分层架构要工作,模块之间必须有一组清晰的数据结构契约。
首先是分桶与大小映射表(SizeClassTable)。这是一个预计算的常量表,给出所有支持的内存块尺寸、每个桶对应的块大小、每页切割块数等。这张表是链接三层模块的纽带的第一个关键点。
其次是Span结构。每条Span记录它对应的起始页地址、页数、所属分桶,以及它的状态(空闲或已分配)。ThreadCache中的单个内存块不存储任何元信息,它所属的Span信息通过PageMap反查获取。
最后是PageMap索引。理论上可以通过内存地址右移页大小偏移来定位页号,再查映射表找到Span指针。这要求页映射表本身要足够紧凑,不能成为新的性能瓶颈。
这三块数据结构贯穿了所有分配路径,它们的实现质量直接决定整个项目的性能上限。
4. 关键设计决策与背后的取舍逻辑
4.1 为什么采用多级缓存而不是单一全局池
一开始我也考虑过相对简单的方案:直接做一个全局的空闲链表池,加一把锁。这个方案编写成本低,但仔细分析后会发现,所有分配请求都会竞争同一把锁,线程一多,锁竞争必然成为瓶颈。多级缓存方案虽然数据的生命周期管理变复杂了,但它能让线程的实际分配请求绝大多数落在本地缓存上,锁竞争发生的概率大幅下降。
两者对比下来,多级缓存付出的复杂度和它在高并发场景下的收益完全成正比。所以我最终选择了三层结构。这是一个典型的"用结构换复杂度"的例子:不是让每次操作更快,而是让慢操作尽量少发生。
4.2 8字节对齐与分桶映射策略
分桶设计是整个内存池的"调音器"。
FastAllocator第一版采用8字节对齐,即每个请求大小向上取整到8的倍数。比如请求5字节,实际分配8字节;请求12字节,实际分配16字节。8字节对齐在现代处理器上很常规,缓存行友好度也不错。
对齐方式确定后,分桶数量就取决于尺寸范围的上限。我设计的分桶策略是:小尺寸区间步长为8字节,中等尺寸区间步长适当放大,超过一定阈值后直接走大块分配路径。
举个例子,64字节以下的桶可能是8字节间隔,64到256字节之间可能是16字节间隔,更大的尺寸开始进入页级分配或独立块分配。这个策略的目的是在"内碎片浪费"和"桶的数量"之间取一个平衡。如果所有尺寸都8字节对齐,桶的数量会非常多,自由链表维护成本上升;如果步长太大,小块分配会被迫分配更大的块,内存浪费严重。
4.3 锁的粒度设计:分段锁与全局锁的权衡
CentralCache作为全局共享层,锁是逃不掉的。问题是怎么让锁的代价最小化。
第一版我的方案是按分桶加锁,每个分桶自由链表有自己的锁。线程从CentralCache取块时,只锁当前分桶的锁,不同分桶的请求互不干扰。这样避免了单一全局锁把所有分桶的访问串行化的问题。
这个设计的代价是锁对象本身占内存,分桶多了会有些浪费,但在分桶数量可控的情况下,这个开销可以接受。
还有一种更高级的思路是无锁数据结构,用原子操作来实现空闲链表的入队出队。我在调研阶段详细评估过,结论是:原子操作在高竞争下依然会有缓存行乒乓效应,而且无锁链表的ABA问题处理需要额外的工作量和内存标记位,第一版不适合引入。先把锁的粒度降下来,实测如果还达不到预期,再考虑无锁化改造。这个循序渐进的路子比较务实。
4.4 内存何时归还给操作系统
一个容易被忽视的设计点是内存回收策略。内存池如果只进不出,会造成内存浪费;但如果频繁归还操作系统,又会出现反复页错误的抖动问题。
FastAllocator的归还策略是"在多个层级都设置阈值":ThreadCache中的缓存块数超过一定阈值才归还CentralCache;CentralCache中空闲Span的数量和空闲页数量超过阈值,才允许归还PageHeap;PageHeap中连续空闲的页达到某个较大值时,才通过系统调用归还给操作系统。
这个策略的效果是:内存池在平稳运行时会稳定在某个内存水位附近,不会持续增长;高负载过后,内存会缓慢回落而不是一次性断崖减少。测量"系统调用频率"和"内存峰值"这两项指标,就能知道阈值设得合不合理。
4.5 为什么自管理页而不是依赖操作系统
另一个关键决策是:FastAllocator向操作系统申请内存时,是按页批量申请的,而不是一个一个小块申请。
按页批量申请有两个好处。第一是减少系统调用次数:一次申请一兆字节和一百次申请十千字节,前者的开销低得多。第二是让元信息管理更简洁:映射表可以按页建立索引,地址对齐到页边界后,通过位运算就能快速定位页号。
自管理页的核心工作是设计好PageMap的索引结构。如果能保证每个Span都是页对齐的,那就能用地址除以页大小算出页索引,再在映射表里查到对应的Span指针。这样一整个分配路径里的元信息查询就变成几次内存读取,这比在内存块头部塞元信息的方式高效得多。
5. 对外接口与边界约定
5.1 基础API设计与malloc接口的兼容思路
FastAllocator第一版对外提供三个基础接口:分配、释放、重分配。命名直接对标常见分配器风格,方便业务代码替换。
namespace FastAllocator { void* Allocate(size_t size); void Deallocate(void* ptr); void* Reallocate(void* ptr, size_t newSize); } // namespace FastAllocator接口设计上有一个值得展开的细节:常规malloc风格会把内存块大小额外记录在块头,以便释放时知道该释放多少。FastAllocator不打算在内存块前面加头部元信息,因为这会破坏对齐性、浪费内存、并且让小块分配得不偿失。
那释放时怎么知道尺寸呢?答案是通过地址反查PageMap:指针所在页的映射记录里保存着Span信息,里面有分桶索引,自然就知道当前块属于哪个尺寸桶。这样就省掉了块头元数据的开销,让分配出去的内存块全都是干净可用的空间。
5.2 对齐参数与尺寸上限的设计约定
对齐参数是整个内存池的"全局常量",影响分桶表、页切割策略、内存对齐等方方面面。
第一版选择8字节对齐作为默认值,但在设计时就把对齐参数做成了可配置的。这样后续如果某些场景需要16字节对齐,调整起来不会伤筋动骨。
尺寸上限也是重要约定:FastAllocator只自动处理不超过某个阈值(比如256KB)的分配请求。超过阈值的分配直接转交给底层系统调用,按页对齐的大块分配处理。这样既避免了内存池对大块内存的管理开销,也让大块分配行为与操作系统的页管理保持一致。
5.3 接口使用约定与生命周期管理
设计接口时,我特别强调了一条使用约定:分配出来的内存必须由本分配器释放,不能混用。混合使用malloc/自定义分配器会产生未定义行为,因为底层元信息布局完全不同。
在实现层面,Deallocate会先做一次校验,通过PageMap查到的Span必须存在于"已分配"状态,否则会直接触发断言或日志告警。这种防御式编程在内存池里十分必要,因为内存越界和重复释放是这类组件最常见的事故来源。
我在接口层还预留了一个统计接口,用来查看每个分桶的分配次数、缓存命中率、CentralCache锁等待次数等信息。在调试性能和定位问题时,这些指标比日志有用得多。
6. 性能目标与测试方案的设计
6.1 基准测试的对象与方法
项目做得好不好,得用数据说话。在设计阶段,我就把测试方案一并规划了。
测试对象分三组:一是FastAllocator本身,二是系统的通用分配器,三是另一个自己写的极简内存池(用于对比多级缓存和简单池的性能差异)。
测试负载设计为几类典型模式:
- 高频小块分配释放,单线程:测试基础分配路径的效率。
- 多线程高频小块分配释放,线程数从1逐步增加到64:测试并发扩展性。
- 分配后保持一段持有时间再释放:模拟真实业务中对象短命但非瞬时的情况。
- 随机大小的分配请求混合:测试分桶映射的效率和碎片倾向。
每轮测试用相同的总分配次数和分配字节数,避免因为负载不均导致对比失真。
6.2 核心指标:吞吐量与延迟分布
选指标时我不只看平均值,还要看百分位分布。内存分配延迟如果偶尔飙高,对某些实时性要求高的业务来说,比平均延迟高更致命。
所以第一版重点观测这几个指标:每秒分配次数(吞吐量)、单次分配延迟的P50/P99/P999、线程数扩展后的加速比、峰值内存占用、系统调用频率。
其中系统调用频率通常是被忽视的指标:它不直接反映分配效率,但能反映缓存策略是否合理。如果吞吐量达标但系统调用频率很高,说明内存复用率差,后续可能会有性能隐患。
6.3 测试暴露出的预期风险
根据以往经验,我预判测试阶段一定会暴露以下几个问题。
- 第一批测试大概率出现ThreadCache膨胀:某些线程释放了大量对象但迟迟不再分配,内存一直滞留在线程缓存里。这个问题需要通过缓存容量阈值来约束。
- 某些分桶竞争依然激烈:比如业务负载里某个特定尺寸的分配频率特别高,按分桶加锁会导致那一个桶变成热点。应对方案可能是预热分配策略或者把该桶进一步拆分。
- 跨线程释放的场景会把性能拉低:一个线程分配、另一个线程释放,会触发频繁的跨线程缓存操作,这其实是多级缓存的通病。目前的设计无法完全规避,只能通过统计工具定位热点再优化。
这些风险在设计阶段想清楚,真正测试时就不会慌乱。每一项都有对应的排查思路和备选方案。
7. 设计阶段的风险清单与后续系列规划
7.1 我现在就能预见的几个坑
做这个设计的过程中,我陆陆续续踩到过一些思维陷阱,列在这里当风险清单。
最大的一个陷阱是过度优化假设。有些设计决策我以为是优化,实际会引入更严重的问题。比如为了省一次内存读取,把元信息塞到内存块内部,结果破坏了8字节对齐,得不偿失。内存池设计必须一步一步用数据验证,不能靠直觉。
第二个是线程局部存储的滥用。ThreadCache如果无限膨胀,每个线程都预留大量内存,线程一多内存浪费立刻失控。线程缓存必须设计积极的回收机制,不能只进不出。我在这方面已经预设了多个阈值控制点。
第三个是页映射表的膨胀。如果页大小设得太小,映射表本身会占用大量内存,而且查询路径变长;如果页大小设得太大,小块分配的粒度受限,内存浪费严重。需要根据实际场景权衡页大小。
第四个是CPU缓存命中率问题。分配器返回的内存块如果总是散布在不同缓存行,业务代码访问时的缓存命中率会受影响。理想情况下,同一线程连续分配的内存块应该在虚拟内存上足够接近,这个问题值得后续用专门的测试工具验证。
7.2 系列文章的推进节奏
既然这是系列第一篇,这里把后续规划一并梳理出来。
第二篇会讲ThreadCache和CentralCache的具体实现细节,包括分桶表的预计算、自由链表的操作、缓存与中央层的交互协议。第三篇重点在PageMap的设计与页分配策略,会展开映射表的内存布局和Span切割细节。第四篇是多线程压测和性能调优的过程记录,会用实际数据对比各种设计决策带来的影响。
整个项目的代码量不算大,但设计决策密度很高。我会把值得展开的决策点都单独写成文章,方便有同样需求的开发者参考。如果你正在做类似的内存池项目,也欢迎对照这个系列的设计思路,看看哪些点可以借鉴、哪些点在你的场景下需要调整。
写到这里,我自己的体会是:内存池设计最考验人的不是代码能力,而是做取舍的判断力。每一个看起来很小的决定——对齐多少字节、分桶怎么切分、缓存阈值设在哪——都会沿着分配路径一路放大,最终体现在性能数据上。第一版的设计就像一个骨架,后续所有实现和调优都是在为这个骨架填充血肉。下一篇我们就正式开始逐层实现,到时候见。