空间配置器(Allocator):std::allocator 的工作原理、内存池设计、自定义分配器场景

说实话,很多C++开发者写了几年代码,都没怎么碰过allocator。我刚开始学STL时也觉得这玩意儿离我太远——不就是new和delete的包装吗?直到我在一个高并发服务器项目里,发现内存碎片导致程序运行三天后性能骤降,才意识到:分配器选不对,系统迟早要崩

今天我们就来聊聊std::allocator到底在背后做了什么,以及什么时候该自己动手写一个。

一、std::allocator 到底干了什么?

先看一个最朴素的例子:

std::vector<int> vec(100);

这行代码背后,vector会调用allocator来申请内存。默认的allocator就是std::allocator<int>。它做的事情其实很简单:

  • allocate(n):分配n个对象的连续内存,相当于 ::operator new(n * sizeof(T))
  • deallocate(p, n):释放内存,相当于 ::operator delete(p)
  • construct(p, args...):在已分配的内存上构造对象(C++17后已弃用,改用std::construct_at)
  • destroy(p):析构对象(C++17后已弃用)

说白了,默认分配器就是new/delete的薄薄一层封装。但STL容器为什么要多此一举?

核心原因:容器需要控制内存分配与对象构造的分离。比如vector扩容时,只想移动内存,不想反复构造析构对象。默认分配器给了你替换的接口。

二、内存池设计——为什么需要它?

我在一个实时交易系统里遇到过这样的场景:每秒要创建和销毁数万个短生命周期的小对象。如果用默认分配器,每次new/delete都会触发系统调用,性能惨不忍睹。更可怕的是内存碎片——系统跑着跑着,明明还有空闲内存,却分配不出一个连续的大块。

内存池的思路很简单:一次性向系统申请一大块内存,然后自己管理小块分配。就像你批发了一箱矿泉水,不用每次渴了都跑去超市买一瓶。

一个典型的内存池设计长这样:

class MemoryPool {
    struct Block {
        Block* next;
    };
    
    Block* freeList = nullptr;
    static constexpr size_t BLOCK_SIZE = 64;  // 每个小块64字节
    
    void* allocate() {
        if (!freeList) {
            // 向系统申请一大块,切成小块
            freeList = static_cast<Block*>(::operator new(BLOCK_SIZE * 1024));
            // 构建空闲链表
            Block* cur = freeList;
            for (size_t i = 0; i < 1023; ++i) {
                cur->next = reinterpret_cast<Block*>(
                    reinterpret_cast<char*>(cur) + BLOCK_SIZE);
                cur = cur->next;
            }
            cur->next = nullptr;
        }
        Block* result = freeList;
        freeList = freeList->next;
        return result;
    }
    
    void deallocate(void* p) {
        Block* block = static_cast<Block*>(p);
        block->next = freeList;
        freeList = block;
    }
};

嗯,这里要注意:内存池只适合固定大小的对象。如果你的对象大小不一,要么用多个池子,要么用更复杂的伙伴系统。

我的经验:对于大多数业务场景,64字节以内的小对象用内存池收益最大。我曾经把一个消息队列的分配延迟从微秒级降到了纳秒级——就靠一个简单的内存池。

三、自定义分配器的典型场景

什么时候该自己写分配器?我总结了几个常见场景:

场景 问题 解决方案
高频小对象分配 new/delete开销大,碎片多 内存池分配器
共享内存/进程间通信 不同进程需要访问同一块内存 基于mmap的分配器
实时系统 不允许动态分配(怕延迟抖动) 预分配固定大小的分配器
调试/统计 想追踪内存分配情况 包装分配器,记录日志

写一个自定义分配器其实不复杂,只要满足Allocator概念的要求:

template<typename T>
class MyAllocator {
public:
    using value_type = T;
    
    MyAllocator() = default;
    
    template<typename U>
    MyAllocator(const MyAllocator<U>&) noexcept {}
    
    T* allocate(std::size_t n) {
        // 这里可以换成你的内存池逻辑
        auto p = static_cast<T*>(::operator new(n * sizeof(T)));
        // 我习惯在这里加个统计
        totalAllocated += n * sizeof(T);
        return p;
    }
    
    void deallocate(T* p, std::size_t n) noexcept {
        ::operator delete(p);
        totalAllocated -= n * sizeof(T);
    }
    
    // 这个比较重要——两个分配器是否相等
    // 对于内存池分配器,通常只有同一个池子的才相等
    bool operator==(const MyAllocator& other) const {
        return true;  // 简单场景下所有实例等价
    }
    bool operator!=(const MyAllocator& other) const {
        return !(*this == other);
    }
    
private:
    static std::atomic<std::size_t> totalAllocated;
};

然后你就可以这样用:

std::vector<int, MyAllocator<int>> myVec;
myVec.push_back(42);  // 走你的分配器

避坑指南:我曾经犯过一个错——自定义分配器里用了全局状态,结果多线程下出了诡异的崩溃。记住:分配器必须支持拷贝,且拷贝后的分配器必须能释放原分配器分配的内存。这是STL容器对分配器的基本要求。

四、std::allocator 的现代演进

C++17之后,std::allocator的construct和destroy被标记为弃用。为什么?因为有了更好的工具:

  • std::allocator_traits:提供了统一的分配器接口,你只需要实现allocate和deallocate,剩下的traits帮你搞定
  • std::pmr::memory_resource:C++17引入的多态内存资源,比传统分配器更灵活
  • std::pmr::polymorphic_allocator:基于memory_resource的分配器,可以在运行时切换内存策略

我个人更推荐新项目用pmr。举个例子:

// 创建一个内存池资源
std::array<std::byte, 1024 * 1024> buffer;
std::pmr::monotonic_buffer_resource pool{buffer.data(), buffer.size()};

// 使用池资源的vector
std::pmr::vector<int> vec{&pool};
vec.push_back(1);  // 从池里分配,不会归还给系统

monotonic_buffer_resource是个好东西——它只分配不释放,直到整个资源被销毁才一次性归还。对于临时计算场景,性能极佳。

五、一张图看懂分配器体系

下面这张图展示了STL分配器的核心工作流程:

STL 分配器工作流程 STL 容器 (vector/map/...) 分配器 (Allocator) std::allocator (new/delete) 自定义分配器 (内存池/共享内存/...) 操作系统内存管理 (malloc / mmap / VirtualAlloc) 分配器是容器和系统内存之间的桥梁,你可以在这层插入自己的策略 默认行为 可定制行为

你看,分配器就是容器和系统内存之间的中间层。默认情况下它直接调用new/delete,但你可以在这层插入自己的策略——内存池、共享内存、甚至是一个统计分配次数的调试器。

六、总结一下

std::allocator不是银弹,但它给了你一个标准化的接口去干预内存分配。我个人的建议是:

  • 99%的场景用默认分配器就够了——别为了炫技而自定义
  • 遇到性能瓶颈时,先profile再动手——我曾经盲目优化,结果发现瓶颈在别处
  • 自定义分配器一定要测试多线程场景——这是最容易翻车的地方
  • 新项目优先考虑std::pmr——更灵活,更现代

最后说一句:分配器这东西,理解原理比会写更重要。你不需要每个项目都自己写一个,但知道它怎么工作,遇到问题时就能多一条思路。


公众号:蓝海资料掘金营,微信deep3321