C++中分配器allocator的实现

前言

标准容器 vector、list、map 都要申请和释放内存。这些内存从哪里来,标准库并没有写死,而是抽象成一个可替换的组件------分配器(allocator) 。容器只依赖分配器提供"申请 N 个元素的空间""释放这块空间"两个动作,具体策略(malloc、内存池、共享内存、竞技场 arena)全部交给外部决定。

这个设计的意义在于:把"内存策略"与"数据结构"解耦 。一个用自由链表(free list)做小对象缓存的分配器,能让 std::list<int> 的性能提高数倍,而容器代码一行都不用改。

本文从分配器的抽象接口讲起,讲清 allocator_traits 为什么存在、rebind 解决什么问题,再给出一份可直接编译运行的自定义分配器实现。

一、为什么需要分配器抽象

先看裸 new / delete 的三个问题:

  1. 每次都向操作系统或通用堆申请 。std::list<int> 插入 100 万个节点 = 100 万次 operator new,每次都要走堆管理器加锁、查找空闲块,开销巨大。
  2. 内存碎片。大量小对象反复申请释放,会把堆切得七零八落,最终"总空闲够但拿不出连续块"。
  3. 每个对象都带元信息 。通用堆为了 free 时需要知道大小,通常在每个块前面放一个 header,小对象的额外开销比例极高(一个 16 字节的对象可能要额外 16 字节 header)。

分配器允许我们针对具体场景做优化。经典的 SGI STL 二级分配器思路是:大于 128 字节走一级(直接 malloc),小于等于 128 字节走二级(按 8 字节对齐切成 16 个自由链表)。这就是分配器存在的第一个理由。

第二个理由是对象的构造与释放要分开 。容器扩容时需要"申请原始内存 → 在上面逐个构造元素"两步。如果只有 new,构造和分配就绑死了,vector 扩容时就没法做到"多申请一块,把旧元素 move 过去,再析构旧的",只能每个元素都重新构造一遍。

二、分配器的接口演进

2.1 从"重接口"到"最小接口"

早期 std::allocator 要求提供一整套成员:allocate / deallocate / construct / destroy / max_size / rebind。C++17 起 construct、destroy、max_size 被弃用,C++20 正式删除 。原因是这些成员本就可以由 std::allocator_traits 提供默认实现,强制要求反而增加了编写自定义分配器的负担。

2.2 C++11 之后的"最小接口"

现代分配器只需要四个要素:

成员 作用

|--------------|------------------------------|
| value_type | 元素类型,allocator_traits 据此推导 |

|---------------|----------------------------------|
| allocate(n) | 分配 n * sizeof(T) 字节,返回未构造的原始内存 |

|--------------------|----------------------|
| deallocate(p, n) | 释放从 allocate 得到的空间 |

|---------------------|-------------------|
| 拷贝构造 / operator== | 判断两个分配器实例能否互相释放内存 |

如果自定义分配器只提供 allocate / deallocate ,std::allocator_traits 会自动补上 construct、destroy、max_size、select_on_container_copy_construction、rebind 等一大堆默认行为。这就是 allocator_traits 的核心价值------向后兼容 + 减轻实现负担。

2.3 allocator_traits 与 rebind

为什么容器不直接调用 alloc.allocate(n),而要通过 std::allocator_traits<Alloc>::allocate(alloc, n)?

  • 为了在不改变分配器定义的前提下,给它补上缺失的成员;
  • 为了统一调用点,让标准库可以后续演进(例如增加 allocate_at_least)。

rebind 解决的问题 :std::list<T> 的节点类型是 ListNode<T>,不是 T。容器拿到的是 allocator<T>,却需要 allocator<ListNode<T>>。allocator_traits<Alloc>::rebind_alloc<U> 就是从 Alloc 得到"元素类型换成 U 的分配器"的标准途径。

cpp 复制代码
template<class T>

struct MyAlloc {

    using value_type = T;

    T* allocate(std::size_t n);

    void deallocate(T* p, std::size_t n);



    // 现代写法:提供模板化拷贝构造,rebind 由 traits 自动完成

    template<class U> MyAlloc(const MyAlloc<U>&) noexcept {}

};

// allocator_traits<MyAlloc<int>>::rebind_alloc<double> == MyAlloc<double>

注意那个 template<class U> MyAlloc(const MyAlloc<U>&) 转换构造函数------它是让 rebind_alloc 能工作的关键,少了它,容器会在编译期报"无法从 MyAlloc<int> 转换到 MyAlloc<Node>"。

三、有状态分配器与传播策略

分配器可以携带状态,比如指向同一个内存池的指针。要让它工作,所有 rebind 出来的版本都必须能访问到同一份状态:

cpp 复制代码
template<class T>

class PoolAlloc {

public:

    using value_type = T;

    explicit PoolAlloc(Pool* pool) noexcept : pool_(pool) {}

    template<class U>

    PoolAlloc(const PoolAlloc<U>& other) noexcept : pool_(other.pool()) {}  // 传递状态



    T* allocate(std::size_t n);

    void deallocate(T* p, std::size_t n) noexcept;

    Pool* pool() const noexcept { return pool_; }

private:

    Pool* pool_;

};



template<class T, class U>

bool operator==(const PoolAlloc<T>& a, const PoolAlloc<U>& b) noexcept {

    return a.pool() == b.pool();   // 能互相释放的前提:同一个池

}

为什么必须重载 ==? 因为 std::vector 在 move 赋值时,如果两个容器的分配器不相等,就不能把内存块直接"偷"过来,必须逐元素搬移。分配器相等是"可以偷内存"的必要条件。

几个传播 traits(决定容器拷贝/移动/交换时分配器怎么处理):

traits 含义

|------------------------------------------|-----------------|
| propagate_on_container_copy_assignment | 拷贝赋值时用源分配器替换自己的 |

|------------------------------------------|-----------------------------|
| propagate_on_container_move_assignment | move 赋值时是否可替换(true 才能偷内存) |

|-------------------|---------------------------------|
| is_always_equal | 所有实例都相等 → 容器可以省掉存储分配器的空间(空基类优化) |

std::allocator 的 is_always_equal 为 true,因此 std::vector<int> 默认不额外存储分配器对象 ,sizeof(std::vector<int>) 仍是 3 个指针。

四、代码实战

实战 1:最小的可用分配器

cpp 复制代码
// minimal_alloc.cpp  ---  g++ -std=c++17 minimal_alloc.cpp -o minimal_alloc

#include <cstddef>

#include <iostream>

#include <memory>

#include <new>

#include <vector>



template<class T>

struct LoggingAlloc {

    using value_type = T;



    LoggingAlloc() noexcept = default;

    template<class U> LoggingAlloc(const LoggingAlloc<U>&) noexcept {}  // rebind 必需



    T* allocate(std::size_t n) {

        std::cout << "allocate " << n * sizeof(T) << " bytes\n";

        return static_cast<T*>(::operator new(n * sizeof(T)));   // 只分配,不构造

    }

    void deallocate(T* p, std::size_t n) noexcept {

        std::cout << "deallocate " << n * sizeof(T) << " bytes\n";

        ::operator delete(p);                                    // 只释放,不析构

    }

};



template<class T, class U>

bool operator==(const LoggingAlloc<T>&, const LoggingAlloc<U>&) noexcept { return true; }



int main() {

    std::vector<int, LoggingAlloc<int>> v;

    for (int i = 0; i < 10; ++i) v.push_back(i);

    std::cout << "size=" << v.size() << " cap=" << v.capacity() << "\n";

    return 0;

}

运行输出能直观看到 vector 的倍增扩容 过程:容量按 1、2、4、8、16 增长,每次扩容都伴随一次 allocate 加一次 deallocate。这也顺便解释了为什么 vector 要 reserve。

实战 2:一个定长块自由链表分配器(freelist allocator)

这是 SGI STL 二级分配器的核心思想,写一个简化版本:

cpp 复制代码
// freelist_alloc.cpp  ---  g++ -std=c++17 freelist_alloc.cpp -o freelist_alloc

#include <cstddef>

#include <iostream>

#include <list>

#include <vector>



// 每个空闲块**复用自身的存储**保存 next 指针 ------ 零额外开销

// 这是自由链表分配器的精髓:不为链表节点额外分配内存

struct FreeNode {

    FreeNode* next;

};



template<std::size_t BlockSize, std::size_t ChunkCount = 64>

class FixedPool {

public:

    FixedPool() = default;

    FixedPool(const FixedPool&) = delete;

    FixedPool& operator=(const FixedPool&) = delete;



    ~FixedPool() {

        // 只释放 chunk 链表,块本身跟着 chunk 一起释放

        while (chunks_) {

            void* p = chunks_;

            chunks_ = *static_cast<void**>(chunks_);

            ::operator delete(p);

        }

    }



    void* allocate() {

        if (!free_) refill();

        FreeNode* node = free_;

        free_ = node->next;              // 摘下头节点 ------ O(1)

        return node;

    }



    void deallocate(void* p) noexcept {

        auto* node = static_cast<FreeNode*>(p);

        node->next = free_;              // 头插归还 ------ O(1)

        free_ = node;

    }



    // 槽位大小必须 >= 指针大小(否则存不下 next),

    // 并且向上取整到 max_align_t 的倍数,保证每一块都满足最大对齐

    static constexpr std::size_t slotSize() {

        constexpr std::size_t base =

            BlockSize < sizeof(FreeNode) ? sizeof(FreeNode) : BlockSize;

        constexpr std::size_t align = alignof(std::max_align_t);

        return (base + align - 1) / align * align;

    }



private:

    void refill() {

        // 一次向系统申请一大块,切成 ChunkCount 个块挂到自由链表上

        const std::size_t slot = slotSize();

        char* raw = static_cast<char*>(::operator new(slot * ChunkCount));

        // 用第一块的空间保存 chunk 链表指针

        *reinterpret_cast<void**>(raw) = chunks_;

        chunks_ = raw;



        for (std::size_t i = 1; i < ChunkCount; ++i) {

            auto* node = reinterpret_cast<FreeNode*>(raw + i * slot);

            node->next = free_;

            free_ = node;

        }

    }



    FreeNode* free_   = nullptr;

    void*     chunks_ = nullptr;

};



// 把 pool 引用注入到分配器里

template<class T, class PoolT>

class PoolAlloc {

public:

    using value_type = T;



    explicit PoolAlloc(PoolT* pool) noexcept : pool_(pool) {}

    template<class U>

    PoolAlloc(const PoolAlloc<U, PoolT>& other) noexcept : pool_(other.pool()) {}



    T* allocate(std::size_t n) {

        if (n != 1) {

            // 池只服务于单元素分配(list/map 节点);批量走通用堆

            return static_cast<T*>(::operator new(n * sizeof(T)));

        }

        return static_cast<T*>(pool_->allocate());

    }



    void deallocate(T* p, std::size_t n) noexcept {

        if (n != 1) { ::operator delete(p); return; }

        pool_->deallocate(p);

    }



    PoolT* pool() const noexcept { return pool_; }



private:

    PoolT* pool_;

};



template<class T, class U, class P>

bool operator==(const PoolAlloc<T, P>& a, const PoolAlloc<U, P>& b) noexcept {

    return a.pool() == b.pool();

}

template<class T, class U, class P>

bool operator!=(const PoolAlloc<T, P>& a, const PoolAlloc<U, P>& b) noexcept {

    return !(a == b);

}



int main() {

    // list 节点的真实大小由实现决定,这里取一个上界即可

    using NodePool = FixedPool<sizeof(void*) * 4>;

    NodePool pool;



    {

        using Alloc = PoolAlloc<int, NodePool>;

        std::list<int, Alloc> lst{Alloc(&pool)};

        for (int i = 0; i < 5; ++i) lst.push_back(i);

        std::cout << "list size = " << lst.size() << "\n";

    }   // list 析构,节点全部归还到 pool,但 pool 不向系统释放

    return 0;

}

这个分配器的意义在于:100 万次 push_back 只向系统申请 1000000 / 64 ≈ 15625 次大块,把堆操作次数降低两个数量级,而且因为块大小固定,几乎不产生碎片。

实战 3:C++17 的 pmr ------ 标准库自带的多态分配器

如果不方便给每个容器写分配器类型参数,可以用 std::pmr(polymorphic memory resource):

cpp 复制代码
alignas(std::max_align_t) unsigned char buffer[4096];              // 栈上缓冲区

std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};

std::pmr::vector<int> v{&pool};                                    // 只传运行期指针

for (int i = 0; i < 100; ++i) v.push_back(i);                      // 完全不碰全局堆

// pool 析构时整块回收,无需逐次 free

pmr 把分配器从"类型的一部分"变成"运行期对象":所有 pmr 容器共享同一个类型,运行时通过 memory_resource 指针分派。代价是每个容器多存一个指针,且分派走虚函数。

常见坑点

坑点 1:allocate 之后立刻构造对象

cpp 复制代码
template<class T>

T* allocate(std::size_t n) {

    T* p = static_cast<T*>(::operator new(n * sizeof(T)));

    for (std::size_t i = 0; i < n; ++i) new (&p[i]) T();   // ❌ 分配器不该构造

    return p;

}

分配器只负责原始内存 ,构造是容器(或 construct_at)的事。在分配器里构造会让 reserve 被迫构造全部元素,语义就错了;deallocate 时容器还会先析构再交给分配器,造成双重析构。

✅ 正确写法:只分配,不构造。

cpp 复制代码
T* allocate(std::size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }

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

坑点 2:分配器生命周期短于容器

cpp 复制代码
std::vector<int, PoolAlloc<int, PoolT>> makeVec() {

    PoolT pool;                                  // ❌ 局部对象

    std::vector<int, PoolAlloc<int, PoolT>> v{PoolAlloc<int, PoolT>(&pool)};

    return v;                                    // 返回后 pool 已析构,v 持有悬空指针

}

✅ 正确写法:分配器持有的池必须活得比容器久(静态存储期,或由调用方持有)。

坑点 3:allocate 的 n 与 deallocate 的 n 不一致

标准要求 deallocate(p, n) 的 n 与 allocate(n) 相同。有状态池分配器如果按 n 走不同代码路径(如实战 2 里 n != 1 的分支),一旦不匹配就会拿 operator delete 去释放池里的块,或者反过来------立刻崩溃。

最后提醒一句:std::allocator 直接使用 ::operator new,现代 glibc / jemalloc / tcmalloc 对小对象已有很好的缓存。除非 profile 显示分配确实是瓶颈,否则不要盲目替换。先测量,再优化。

总结

概念 要点

|------|----------------------------------------------------------------|
| 最小接口 | value_type + allocate + deallocate + 转换构造 + operator== |

|--------------------|-----------------------|
| allocator_traits | 补齐默认实现,让自定义分配器只需写两个函数 |

|----------|----------------------------------------------|
| rebind | 容器需要把 allocator<T> 变成 allocator<Node<T>> |

|--------|----------------------------|
| 有状态分配器 | 必须重载 operator==,且生命周期要够长 |

|-----------|-------------------|
| 传播 traits | 决定容器拷贝/移动时能否"偷"内存 |

|-------|---------------------|
| pmr | 类型无关的多态分配器,代价是多一个指针 |

分配器的价值不在"能编译",而在把内存策略变成可插拔的一等公民。理解了"容器只认 allocate/deallocate 与 construct/destroy",你就理解了标准库几乎所有容器的内存行为。

相关推荐
wshzd1 小时前
LLM之Agent(104)|DeepSeek-Harness(十三)ReactLoopAgent 总览:kick → turn → step
开发语言·前端·javascript
共享家95271 小时前
C语言-408
c语言·开发语言
周杰偷奶茶1 小时前
【Java】运算符指南
java·开发语言
单片机设计·······2 小时前
【dz-1162】汽车前玻璃雨刮器设计
c++·单片机·嵌入式硬件
2601_966949652 小时前
量化回测中分钟数据应该如何保存?从文件到数据仓库的工程化设计
开发语言·python·pandas·量化交易·股票数据·quantdash
Zhou1411362 小时前
SpringMVC_02_注解开发实战
开发语言·windows·python
AI智讯中枢2 小时前
高性能 C++ 实战 (五):perf+FlameGraph 火焰图生产实战,精准定位 CPU / 缓存 / 锁瓶颈,避坑 + 完整实操案例
linux·c++·性能调优·性能分析·perf·flamegraph·火焰图
繁华的地方不一定留下你的脚印2 小时前
C++ 算法与 ranges:用 find_if、transform 写清数据处理意图
开发语言·c++·算法
张小姐的猫2 小时前
【AI大模型接入SDK】 —— 前端页面 & 项目总结与拓展
前端·数据结构·数据库·c++·人工智能·chatgpt