内存分配器:std::allocator、自定义分配器、STL容器分配器适配、pmr分配器

聊到内存管理,很多人第一反应就是 newdelete。但说实话,在真正的工程实践中,尤其是高性能场景下,直接裸用 new 往往不是最优解。我早年做游戏引擎时就被坑过一次——频繁的 new/delete 导致内存碎片化严重,帧率直接掉了一半。从那以后,我开始认真研究分配器。

分配器是什么?说白了,它就是 STL 容器用来申请和释放内存的「幕后管家」。默认情况下,std::vector 用的是 std::allocator,但你可以换掉它,换成自己定制的分配器,甚至用 C++17 引入的 pmr 来统一管理内存池。

std::allocator:默认的分配器

std::allocator 是 STL 容器的默认分配器。它的实现很简单——内部调用 ::operator new::operator delete。嗯,说白了就是封装了一下全局的 new/delete

它的接口长这样:

template<typename T>
struct std::allocator {
    using value_type = T;

    T* allocate(std::size_t n) {
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }

    void deallocate(T* p, std::size_t n) noexcept {
        ::operator delete(p);
    }

    // 构造和析构(C++20 后已弃用)
    template<typename... Args>
    void construct(T* p, Args&&... args) {
        ::new ((void*)p) T(std::forward<Args>(args)...);
    }

    void destroy(T* p) noexcept {
        p->~T();
    }
};

注意,allocate 只分配内存,不构造对象。construct 才负责在已分配的内存上调用构造函数。这种分离设计,是 STL 分配器的核心思想——内存分配和对象构造是两回事。

我的习惯:在大多数业务代码中,直接用默认分配器就够了。但如果你在做高频交易、游戏引擎、嵌入式系统,那默认分配器往往不够用。

自定义分配器:为什么需要?

默认分配器的问题在于:它每次分配都走全局堆,没有缓存,没有池化。我曾在项目中遇到一个场景——需要频繁创建和销毁大量小对象(比如粒子系统中的粒子)。用默认分配器,性能惨不忍睹。

自定义分配器可以解决这个问题。比如,我们可以实现一个简单的「线性分配器」:

template<typename T>
class LinearAllocator {
    char* buffer;
    std::size_t offset;
    std::size_t capacity;
public:
    using value_type = T;

    LinearAllocator(std::size_t cap)
        : buffer(new char[cap]), offset(0), capacity(cap) {}

    ~LinearAllocator() { delete[] buffer; }

    T* allocate(std::size_t n) {
        std::size_t bytes = n * sizeof(T);
        if (offset + bytes > capacity)
            throw std::bad_alloc();
        T* ptr = reinterpret_cast<T*>(buffer + offset);
        offset += bytes;
        return ptr;
    }

    void deallocate(T*, std::size_t) noexcept {
        // 线性分配器不支持单次释放
    }

    // 重置整个缓冲区
    void reset() { offset = 0; }
};

这个分配器只分配,不释放。用完后一次性重置整个缓冲区。适合帧级内存管理——每帧开始重置,帧内所有临时对象都从这块内存取。我在做实时渲染引擎时就用过这种方案,内存分配几乎零开销。

注意:自定义分配器必须满足 STL 的分配器要求(Allocator requirements)。特别是,分配器必须是可拷贝的,且拷贝后的分配器必须能释放对方分配的内存。这一点很容易踩坑。

STL容器分配器适配

STL 容器默认使用 std::allocator,但你可以通过模板参数替换它:

// 使用自定义分配器的 vector
std::vector<int, LinearAllocator<int>> myVec(LinearAllocator<int>(1024));

// 使用 pmr 分配器的 vector
std::vector<int, std::pmr::polymorphic_allocator<int>> pmrVec;

这里有个细节:分配器类型是容器类型的一部分。std::vector<int>std::vector<int, MyAlloc> 是两种不同的类型,不能互相赋值。嗯,这一点在设计接口时要特别注意。

另外,容器在拷贝或移动时,分配器也会被拷贝或移动。C++11 引入了「分配器感知的容器」概念,容器会通过 std::allocator_traits 来统一操作分配器,而不是直接调用分配器的成员函数。这样做的好处是:即使分配器没有提供某些成员(比如 construct),allocator_traits 也会提供默认实现。

pmr分配器:C++17 的现代方案

C++17 引入了 std::pmr(Polymorphic Memory Resources),它提供了一套基于多态的内存分配机制。核心思想是:分配器不再是一个模板参数,而是一个运行时多态对象。

pmr 的核心组件:

  • memory_resource:抽象基类,定义 allocatedeallocate 接口
  • polymorphic_allocator:包装了 memory_resource* 的分配器适配器
  • synchronized_pool_resource:线程安全的内存池
  • unsynchronized_pool_resource:非线程安全的内存池
  • monotonic_buffer_resource:线性分配器,类似我们上面写的 LinearAllocator

使用示例:

#include <memory_resource>
#include <vector>

int main() {
    // 创建一个 1MB 的缓冲区
    char buffer[1024 * 1024];
    std::pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));

    // 使用 pmr 分配器的 vector
    std::pmr::vector<int> vec(&pool);

    for (int i = 0; i < 100000; ++i) {
        vec.push_back(i);
    }
    // 所有内存都来自 buffer,不会走全局堆
}

pmr 的好处是:你可以在运行时切换分配策略。比如,调试时用 new_delete_resource,发布时换成 pool_resource。代码不需要重新编译。

避坑指南:我曾经在项目中混用了 std::pmr::vector 和普通 std::vector,结果在传递数据时发生了意外的拷贝。原因是 pmr 容器的分配器是运行时绑定的,而普通容器的分配器是编译期绑定的。两者不兼容,赋值时会触发深拷贝。所以,要么全用 pmr,要么全不用,别混着来。

知识体系结构图

下面这张图展示了本章涉及的核心概念和它们之间的关系:

C++ 内存分配器体系 std::allocator 自定义分配器 pmr 分配器 线性分配器 / 池分配器 monotonic_buffer_resource pool_resource (同步/非同步) STL 容器(vector, map, string...) 默认 自定义 pmr

如何选择?

我个人的经验是:

  • 默认场景:用 std::allocator,别折腾。90% 的情况它够用。
  • 性能敏感:考虑 pmr::monotonic_buffer_resource 或自定义线性分配器。比如每帧重置的临时数据。
  • 多线程环境:用 pmr::synchronized_pool_resource,避免自己实现锁。
  • 需要运行时切换策略:pmr 是唯一选择。它的多态特性让分配策略可配置。

最后提醒一句:分配器是工具,不是银弹。滥用自定义分配器反而会让代码变得难以维护。我见过有人为了「优化」给每个容器都配了专属分配器,结果内存碎片更严重了。嗯,适度使用,才是正道。


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