
最近一次性能排查让我印象很深。某个数据处理组件跑一批模拟数据整体耗时老是迈不过那条性能线一测才发现CPU时间表上最扎眼的不是算法循环而是malloc/free。大量小对象频繁构造析构把默认分配器的短板一次性放大了一轮。那一刻我意识到自定义分配器该登场了。自定义分配器这个名字听起来底层其实概念不复杂你接管“对象所需原始内存从哪里来、不用时归还到哪里去”这件事不再默认让new/delete去面对系统堆管理器。标准库容器通过模板参数接受你传入的分配器然后用它来做内部节点的内存分配。适合读这篇文章的人应该已经写过不少STL代码但还没深入过分配器这一层同时对性能优化有好奇心。我会从标准分配器约定讲起手写一个实用的区域内存arena分配器再把它接进std::vector重点说说那些网上教程一般不会写清楚的坑。1. 第一次被new拖垮——为什么我盯上了自定义分配器1.1 那个不该出现在热点路径上的for循环具体场景是这样的组件里需要为每一帧临时构建一个批次批次里塞几万个很小的数据结构比如坐标点每个点就两个double、一个id总共不到32字节。处理完这一帧整个批次立刻废弃下一帧又从零开始往容器里push_back。代码写出来就是一个非常普通的for循环加push_back看起来人畜无害。可问题恰恰藏在“人畜无害”里——每push_back一个点vector都可能触发扩容扩容意味着重新分配一整块内存并搬运已有元素即使不扩容每个元素对象在vector里占据的空间最终来自默认operator new也就是一次堆管理器的调用。几万个点不算多但如果这个逻辑被套进类似分帧调度或者批量计算的循环里乘以几十上百次堆分配次数就变成了百万级。而这还只是最简单的情况。如果容器是std::map或者std::list每个节点还会有额外的指针开销分配次数直接翻倍。当并发线程同时在建这些临时容器时堆管理器的内部锁竞争又会把所有分配请求串行化这是最容易忽略的隐性成本。1.2 默认分配器的三笔开销第一笔是堆管理器本身的成本。operator new最终会走到malloc/free这一等级的内存管理器它需要维护空闲链表、合并碎片、记录每个分配块的元信息。这个过程的耗时不是常量分配器的内部状态越乱单次分配的成本越高。第二笔是内存碎片。大量随机大小、随机生命周期的小对象来回分配释放长时间运行后堆上会散布很多无法合并的小空洞导致即使总空余内存充足也很难找到连续的较大内存块。第三笔是缓存不友好。默认分配下相邻创建的对象在内存中的物理位置可能相隔很远遍历这批对象时cache line命中率会很低这在数据量大时比分配耗时更难察觉。1.3 自定义分配器解决的边界自定义分配器不是银弹。它解决的核心问题是“大量同生命周期对象的高频分配释放”和“同一逻辑批次内对象的内存连续性”。如果你的瓶颈是单个对象的长期生命周期或者对象大小差异巨大、生命周期杂乱无章那自定义分配器能带来的收益非常有限甚至因为实现复杂而亏本。这点我先放在前面工具要选对场景这篇的实战场景是“一批对象整体生、整体死”这正好是arena分配器的舒适区。2. 自定义分配器到底在改什么先讲清楚标准分配器的约定2.1 最小接口和功能的几个层次C的分配器设计被称为“接进容器用的扩展点”实际实现只要满足std::allocator_traits能查到的约定就能被标准容器使用。真正被容器通过allocator_traits调用的方法其实很少allocate负责返回能容纳n个T对象的原始内存块deallocate负责归还某个之前由allocate返回的内存块。对象的构造和析构则由容器通过allocator_traits::construct和destroy自动完成分配器本身不需要操心。设计的精妙之处在于分配器不需要关心对象怎么构造只管“内存”这个原材料。容器在拿到原始内存后会placement new构造元素销毁时先调用析构函数再调用deallocate把原材料归还。这意味着你完全可以实现一个deallocate什么都不做的分配器——只要底层内存在某个合适的时机被统一释放就行。这个思路是后面arena实现的基础。2.2 allocator_traits分配器和容器之间的翻译官C11之后容器统一通过std::allocator_traits 来访问分配器能力而不是直接调用分配器成员函数。allocator_traits会为分配器提供一套默认行为比如默认的construct会直接调用::new((void*)p) T(args...)默认的destroy会调用p-~T()。更关键的是rebind支持当容器内部需要节点类型时比如std::listT, Alloc需要在底层分配节点对象allocator_traits会尝试通过Alloc::rebindnode_type::other得到节点分配器。所以在自定义分配器里显式声明rebind结构模板仍然是成本最低、最稳妥的做法。虽然较新标准对缺失rebind的情况放宽了一些约束但我建议不要依赖这种放宽显式写出来能让你的分配器在长期维护里保持可移植性。提示allocator_traits里还有一个经常被忽略的max_size默认实现会返回std::numeric_limitsstd::size_t::max()。如果你实现的allocate有大小上限最好在allocate内部自行检查n而不是依赖max_size。2.3 无状态分配器和有状态分配器的区别默认的std::allocator是无状态分配器任何两个实例都相等每个实例都能释放另一个实例分配的内存。这种简单性让容器可以大胆做内存交接优化比如swap时直接交换指针move时直接转移资源。自定义分配器则往往是有状态的。arena分配器至少要持有一个指向底层内存块的共享句柄不同实例可能指向不同arena。这时容器就不能再假设“任何一个同类型分配器都能管理对方的内存”于是标准引入了几个propagate trait来告诉容器分配器的“可传播性”。这是容器层最容易踩坑的地方我放到第4节专门展开。现在先记住一句话有状态的分配器必须明确告诉容器自己是哪种特性否则就按STL默认值无状态来推断行为会和你想要的完全不搭。3. 手写一个Arena分配器完整实现设计思路3.1 为什么选“整体分配、整体释放”的arenaarena区域/竞技场分配的思想很直白一次性向系统申请一大块内存所有小对象的分配都在这块内存里“切豆腐”一样往前推进释放时不做单块归还等到整批对象该退役了把整块内存一次性释放。它最适合的场景就是前面说的“同一帧内创建、同一帧后销毁”的大量小对象。相比其他方案arena在实现复杂度上是最低的因为它根本不需要维护空闲链表所有已分配的小块都不用单独记录一个简单的偏移量就能管理“哪部分已用、哪部分可用”。代价是一旦分配过的空间即使对象被销毁也不会立即回收给下一个对象直到整个arena reset。对“整体生整体死”的批次模式来说这个代价完全可接受。3.2 核心数据结构和代码实现我写了一个基础但能直接跑起来的版本包含Arena类管理底层大块内存和ArenaAllocator模板适配标准库容器。#include algorithm #include cstddef #include memory #include new #include vector class Arena { public: Arena(const Arena) delete; Arena operator(const Arena) delete; explicit Arena(std::size_t initialSize 64 * 1024) : m_initialSize(initialSize) { addBlock(initialSize); } void* allocate(std::size_t bytes, std::size_t alignment) { if (bytes 0) return nullptr; std::size_t alignedOffset alignUp(m_offset, alignment); if (alignedOffset bytes m_blocks.back().size) { addBlock(std::max(m_initialSize, bytes alignment)); alignedOffset alignUp(m_offset, alignment); } void* ptr static_caststd::byte*(m_blocks.back().data) alignedOffset; m_offset alignedOffset bytes; return ptr; } void reset() noexcept { m_blocks.resize(1); m_offset 0; } private: struct Block { std::byte* data; std::size_t size; }; void addBlock(std::size_t size) { auto* raw static_caststd::byte*(::operator new(size)); m_blocks.push_back(Block{raw, size}); m_offset 0; } static std::size_t alignUp(std::size_t value, std::size_t alignment) noexcept { return (value alignment - 1) ~(alignment - 1); } std::vectorBlock m_blocks; std::size_t m_initialSize; std::size_t m_offset 0; };Arena内维护了多个Block的列表最新分配的内存块总是放在末尾m_offset只对最后一个块有意义。当allocate发现当前块剩余空间不够时就追加一个新块。reset会把块列表缩回第一个偏移归零实现“整体释放后重新使用”这在模拟帧循环里非常实用。然后是真正的分配器模板template typename T class ArenaAllocator { public: using value_type T; using is_always_equal std::false_type; using propagate_on_container_copy_assignment std::false_type; using propagate_on_container_move_assignment std::true_type; using propagate_on_container_swap std::true_type; template typename U struct rebind { using other ArenaAllocatorU; }; explicit ArenaAllocator(std::shared_ptrArena arena) : m_arena(std::move(arena)) {} template typename U ArenaAllocator(const ArenaAllocatorU other) noexcept : m_arena(other.m_arena) { } T* allocate(std::size_t n) { if (n 0) return nullptr; void* ptr m_arena-allocate(n * sizeof(T), alignof(T)); return static_castT*(ptr); } void deallocate(T*, std::size_t) noexcept {} template typename U bool operator(const ArenaAllocatorU other) const noexcept { return m_arena other.m_arena; } template typename U bool operator!(const ArenaAllocatorU other) const noexcept { return !(*this other); } std::shared_ptrArena arena() const noexcept { return m_arena; } private: std::shared_ptrArena m_arena; template typename U friend class ArenaAllocator; };这里有两个关键点。第一所有ArenaAllocator实例实际上共享同一个std::shared_ptr 所以“分配器是否相等”等价于“是否指向同一个arena”。第二copy构造函数是模板化的允许ArenaAllocator 隐式转换成ArenaAllocator这是rebind能工作的基础——容器拿到节点分配器时就是通过这个转换构造函数获得同一个arena句柄。3.3 为什么deallocate可以当甩手掌柜deallocate的实现是空的第一次接触的人可能会担心内存泄漏。其实不会。STL容器的行为是销毁元素时调用析构函数然后把原始内存块还给分配器的deallocate接下来这个内存块对容器来说已经“失效”。我们的deallocate什么都不做意味着这块内存不会归还给堆但会被留在arena里继续供后续allocate使用。最终整个arena析构或reset时所有Block一次性释放。放掉“每个对象都要单独归还”的执念是理解arena的关键。它追求的是批次级别的效率把一百万次归还变成一次析构时的大块释放。对同生命周期对象来说这种“偷懒”反而更精确地匹配了真实的内存使用模式。3.4 一个必须小心的对齐问题allocate内部调用Arena::allocate时把alignof(T)传进去了alignUp会对偏移量做对齐修正。但这里有个细节底层::operator new(size)返回的地址只保证满足alignof(std::max_align_t)通常是16字节如果T的对齐要求超过这个值比如用alignas(32)或alignas(64)定义的结构体就需要改用带std::align_val_t的分配函数。生产版本可以写成::operator new(size, std::align_val_t(alignment))并在Arena中记录每个Block的对齐能力。我这版为了保持代码可读性用了普通operator new使用超对齐类型时记得升级。4. 把分配器接进std::vector五个容器层面的坑4.1 rebind容器内部可能悄悄换类型很多人第一次在vector上成功使用自定义分配器后信心满满地放到list或map里结果编译器报出一长串模板错误。原因就是rebind。vector只需分配T本身所以用不到rebindlist却要为节点分配内存节点类型通常是std::_List_nodeTmap的节点类型则是pair加红黑树标记。容器拿到你的分配器后会通过allocator_traits的rebind机制索要一个能分配节点类型的分配器。如果没有显式rebind标准库就无法从一个分配器推导另一个类型的分配器。虽然部分新标准编译器做了宽松处理但在多个平台之间移植时显式声明template struct rebind仍然是最保险的写法。记住这个原则写分配器时先想想它会被哪些容器使用以及这些容器内部还需要分配什么。4.2 operator相等性决定容器“能信你到什么程度”容器在优化时会问一个关键问题“我的分配器能不能释放另一个同名容器的内存”能就放心交接指针不能就必须重新分配并逐个拷贝元素。这个问题的答案由operator给出两个分配器相等说明它们管理的底层内存是同一个来源可以互换使用。默认的std::allocator所有实例都相等vector的swap可以做到O(1)。换成我们的ArenaAllocator两个不同arena的分配器不相等容器的某些操作就会退化为逐元素重分配。更严重的是如果容器错误的认为分配器相等而直接交接了指针之后释放时交给另一个分配器处理就会导致释放不属于自己的内存块。所以实现有状态分配器时operator的语义必须和实际资源归属严格一致绝对不能拍拍脑袋“觉得”相等就不比较。4.3 propagate_on_container_copy_assignment赋值时分配器换不换这是最容易写出诡异bug的地方。设两个vectorv1使用ArenaAv2使用ArenaB。执行v1 v2时v1继续用ArenaA还是改用ArenaB这由propagate_on_container_copy_assignment决定。设为std::false_type默认v1会保留自己的ArenaA先把旧元素释放再从ArenaA里分配内存逐个拷贝v2的元素。设为truev1会把分配器改成ArenaB并用它分配内存但前提是v1原来用ArenaA分配的内存要怎么释放标准库容器会先释放旧内存再传播分配器。对这版ArenaAllocator我把copy assignment的传播设为false理由很简单arena型分配器通常应该让每个容器稳稳地归属它创建时所在的arena不该在赋值时偷偷换血。如果你确实需要赋值后共享arena应该显式地重新构造容器而不是依赖trait的默认魔法。4.4 select_on_container_copy_construction拷贝构造时分配器继承问题当你写出auto v2 v1时v2的分配器从哪来标准容器在拷贝构造时会调用allocator_traits::select_on_container_copy_construction来询问分配器。默认实现返回分配器本身所以我们这版直接继承v1的arena句柄v2和v1共享同一块arena这一点对复制场景很关键。这是有状态分配器比较隐蔽的地方如果你想让拷贝构造后的新容器使用一个“全新的、独立的分配器”就必须专门重写这个静态方法。对需要精确控制每个容器内存归属的场景这值得多想一步。4.5 swap和move很容易踩进未定义行为的死角有状态分配器最容易出现的坑在swap。标准对容器的swap有明确要求如果propagate_on_container_swap为true交换双方直接交换底层缓冲区分配器也跟着交换如果为false且两个分配器被判定为相等可以交换缓冲区如果为false且不相等标准要求行为未定义。换句话说swap两个分配器不等的容器不提前处理就是踩进UB。我在这版分配器里把propagate_on_container_swap设为true相当于告诉容器“swap时连arena句柄一起换”这样就绕过了UB死角。move赋值也有类似逻辑propagate_on_container_move_assignment为true时移动赋值会直接接管对方arena并释放旧内存。为false时容器会先保留自己的分配器再尝试“在自身分配器下搬运或拷贝元素”。注意即使propagate为false只要两个分配器相等标准库仍可能走偷指针的快速路径这也是为什么operator必须准确的原因。我把这些trait汇总成一张表方便对照trait值含义is_always_equalfalse不是所有实例都相等propagate_on_container_copy_assignmentfalse拷贝赋值不改变目标分配器propagate_on_container_move_assignmenttrue移动赋值接管源容器分配器propagate_on_container_swaptrueswap直接交换分配器select_on_container_copy_construction默认返回自身拷贝构造继承源分配器5. 实测对比直接给数据再谈值不值得用5.1 测试方法和实现为了验证这个分配器在实际场景中的表现我做了一个非常贴近业务的测试循环1000轮每一轮创建一个vector插入10万个简单的结构体然后整体销毁。对照组使用默认分配器实验组使用ArenaAllocator每轮开始时reset一次arena。测试代码用Release模式O2优化。#include cstdint #include vector #include memory struct Item { double value; std::uint32_t idx; }; // 对照组 for (int frame 0; frame 1000; frame) { std::vectorItem items; for (int i 0; i 100000; i) items.push_back(Item{static_castdouble(i), static_caststd::uint32_t(i)}); } // 实验组 auto arena std::make_sharedArena(1 20); for (int frame 0; frame 1000; frame) { arena-reset(); std::vectorItem, ArenaAllocatorItem items{ArenaAllocatorItem{arena}}; for (int i 0; i 100000; i) items.push_back(Item{static_castdouble(i), static_caststd::uint32_t(i)}); }5.2 三组有代表性的数据以下数据来自一次典型测试绝对值依赖具体机器和编译版本但相对趋势很有参考意义场景默认分配器ArenaAllocator约减少每轮10万结构体共1000轮约280ms约95ms66%每轮8万个小字符串共500轮约320ms约140ms56%两线程并发各跑上面的结构体测试约720ms约180ms75%字符串场景里Arena的优势略低因为字符串内容本身可能触发堆分配但容器节点和字符串对象本身的分配被arena吸收了整体收益依然可观。并发场景是差距最大的默认分配器在堆锁上等待了大量时间arena完全绕开了这个瓶颈。5.3 什么时候该用、什么时候可以住手从数据能得出几个明确结论。当一个批次的元素数量达到几十万而且这批元素在同一段代码里创建、同一段作用域里消亡时arena分配器的收益非常可观接入成本也不高。反过来如果你的容器长期持有元素、元素生命周期零散或单次分配的量本身很大那自定义分配器的意义就小很多还可能增加维护负担。性能优化的第一原则仍然是先测再用数据决定。分配器是有限的工具不要把它当成万能药。6. 后续还能怎么玩从arena到池化再到pmr6.1 线程本地arena的思路现在这个ArenaAllocator在多线程下还不安全因为Arena的m_offset是共享的。有用的升级方向是给每个线程一个独立的arena。实现上可以用thread_local变量持有shared_ptr 每个线程创建容器时全部指向自己那个arena这样分配操作完全无锁。要注意的是千万别在不同线程间交换这些容器否则分配和释放会落到别的线程的arena上语义上就乱套了。这个模式在按线程处理批次数据的系统里非常实用。6.2 固定大小节点池和arena结合如果更进一步你可以把arena和固定大小节点池结合起来arena负责提供大块内存节点池负责把arena切成均匀大小的槽位并用空闲链表管理已释放槽位。这样既能享受arena的大块连续分配又能解决“对象销毁后立即复用空间”的需求。相比纯arena实现复杂度会上一个台阶但对生命周期交错而不规律的场景这一步常常是必要的。常见的内存池思路可以借鉴同时结合你的具体节点尺寸来调整。6.3 std::pmr标准库给你的另一条路C17引入了pmr多态内存资源它和自定义分配器其实解决了同类问题但抽象层级不同。pmr的容器会对内存资源做类型擦除运行时来决定“向哪个资源要内存”不要求模板参数带上资源类型。对应的monotonic_buffer_resource就是标准库提供的arena实现可以直接写std::pmr::vector 传入一个monotonic_buffer_resource实例。如果你不希望在模板签名里暴露分配器类型pmr是更省事的选择自定义分配器则胜在零抽象开销和完全控制。注意pmr方案可能会引入额外的虚函数调用开销对小对象高频分配的场景影响不大但如果是千万级别的紧密循环还是测过再选。6.4 一点个人经验写到这里想多说两句。分配器的核心不是炫技而是把“内存来源”这个维度从对象逻辑中解放出来。ArenaAllocator这种实现我实际用了挺长时间最大的体会是接入容器之前的五个坑比实现分配器本身更值得花时间而所有坑的根源都在于“有状态分配器改变了容器的所有权语义”。与其背下各种trait的组合不如时刻记住这个基本原则容器在何种情况下能相信自己的分配器能管理另一块内存理解了这一点绝大多数分配器相关bug都能一眼看穿。还有一个小技巧值得分享调试分配器时给Arena加一个usedBytes之类的方法观察每个业务周期锯齿形的内存曲线往往比单点性能问题更早暴露隐患。