上一篇我们写了一个定长内存池,用来给小对象提速。
这篇直接进阶:把定长内存池挂到 STL 容器上,用自定义 allocator 接管 std::liststd::unordered_map 等 node 容器的内存分配。


一、为什么要把内存池跟 STL 容器绑一起?

前一篇的池子更多是“手动调用”的风格:

Node* p = pool.create(...);
pool.destroy(p);

但实际工程里,大量内存开销都藏在:

  • std::list<T>
  • std::forward_list<T>
  • std::map / std::set
  • std::unordered_map / unordered_set

这些节点容器的节点 (node) 本质就是:

struct Node {
    T value;
    Node* next;
    Node* prev;
    // 可能还有一点管理字段
};

特点:

  1. 大量小对象,生命周期复杂、频繁插入删除;
  2. 默认都是 ::operator new 分配 node,抖动和碎片都不小;
  3. C++ 标准本来就为此设计了 Allocator 接口,允许你接管分配策略

所以这篇的目标就一句话:

写一个 template<class T> class PoolAllocator,让
std::list<T, PoolAllocator<T>> 这类容器默认就走我们的定长内存池。


二、准备一个可复用的“节点级内存池”(线程安全版)

我们先给上一篇的 FixedSizePool 升级一个简单的线程安全壳子,用 mutex 就够了(先保证正确,再考虑极致性能)。

2.1 基础定长池(回顾简化版)在这里插入图片描述

#include <cstddef>
#include <new>
#include <stdexcept>

class FixedSizePool {
public:
    FixedSizePool(std::size_t blockSize, std::size_t blockCount)
        : m_blockSize(adjustBlockSize(blockSize)),
          m_blockCount(blockCount),
          m_memory(nullptr),
          m_freeList(nullptr)
    {
        if (m_blockSize == 0 || m_blockCount == 0) {
            throw std::invalid_argument("blockSize and blockCount must be > 0");
        }
        initPool();
    }

    ~FixedSizePool() {
        ::operator delete(m_memory, std::align_val_t(alignof(std::max_align_t)));
    }

    void* allocate() {
        if (!m_freeList) {
            throw std::bad_alloc{};
        }
        Node* node = m_freeList;
        m_freeList = m_freeList->next;
        return node;
    }

    void deallocate(void* ptr) {
        if (!ptr) return;
        Node* node = static_cast<Node*>(ptr);
        node->next = m_freeList;
        m_freeList = node;
    }

    std::size_t blockSize() const { return m_blockSize; }

private:
    struct Node {
        Node* next;
    };

    std::size_t m_blockSize;
    std::size_t m_blockCount;
    void*       m_memory;
    Node*       m_freeList;

    static std::size_t adjustBlockSize(std::size_t sz) {
        if (sz < sizeof(Node)) sz = sizeof(Node);
        constexpr std::size_t align = alignof(std::max_align_t);
        if (sz % align != 0) {
            sz += align - (sz % align);
        }
        return sz;
    }

    void initPool() {
        std::size_t totalSize = m_blockSize * m_blockCount;
        m_memory = ::operator new(totalSize, std::align_val_t(alignof(std::max_align_t)));

        std::byte* p = static_cast<std::byte*>(m_memory);
        m_freeList = nullptr;
        for (std::size_t i = 0; i < m_blockCount; ++i) {
            Node* node = reinterpret_cast<Node*>(p + i * m_blockSize);
            node->next = m_freeList;
            m_freeList = node;
        }
    }
};

2.2 简单加个全局线程安全封装

我们做一个“全局共享节点池管理器”,给 allocator 调用:

#include <mutex>

class GlobalNodePool {
public:
    static GlobalNodePool& instance() {
        static GlobalNodePool inst;
        return inst;
    }

    void* allocate(std::size_t sz) {
        // 为了简化:按 size 分类的那套先不做
        // 假设所有节点都差不多大小,比如 64/80 字节范围
        std::lock_guard<std::mutex> lock(m_mutex);
        if (!m_pool || m_pool->blockSize() < sz) {
            // 这里简单粗暴:重新建一个足够大的池
            // 实际工程可以做 size-class 多池,这里以演示为主
            m_pool.reset(new FixedSizePool(sz, m_defaultBlockCount));
        }
        return m_pool->allocate();
    }

    void deallocate(void* p) {
        std::lock_guard<std::mutex> lock(m_mutex);
        if (m_pool) {
            m_pool->deallocate(p);
        } else {
            // 理论上应该不会走到这里
            ::operator delete(p);
        }
    }

private:
    GlobalNodePool()
        : m_pool(nullptr),
          m_defaultBlockCount(1024 * 16)  // 默认 16K 块
    {}

    std::unique_ptr<FixedSizePool> m_pool;
    std::mutex                     m_mutex;
    std::size_t                    m_defaultBlockCount;
};

说明:

  • 这里我们为了演示,把所有节点都塞进一个池(真实项目建议按 size 分 class);
  • allocate(sz) 会在第一个请求时按 sz 造一个池,后面都复用。

三、实现最小可用版 STL AllocatorPoolAllocator<T>

C++ 标准对 allocator 的要求其实不少,但我们要“跑起来”只需要一版最小集合

核心要素是:

template <class T>
class PoolAllocator {
public:
    using value_type = T;

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

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

    // 比较:所有 PoolAllocator<T> 视为等价(共享全局池)
    bool operator==(const PoolAllocator&) const noexcept { return true; }
    bool operator!=(const PoolAllocator&) const noexcept { return false; }
};

3.1 只针对“节点容器”:假定 n == 1 为主

std::liststd::map 这类 node 容器在大多数实现里,分配节点时 allocate(1);个别场景可能会一次性 allocate(n) 构造一串节点。

为了简单起步,我们这么处理:

  • 如果 n == 1 → 用内存池;
  • 如果 n > 1 → 直接退回系统 ::operator new,不搞复杂。

代码:

#include <memory>

template <class T>
class PoolAllocator {
public:
    using value_type = T;

    PoolAllocator() noexcept = default;

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

    T* allocate(std::size_t n) {
        if (n == 1) {
            void* mem = GlobalNodePool::instance().allocate(sizeof(T));
            return static_cast<T*>(mem);
        } else {
            // 保底逻辑:大块直接走系统分配
            return static_cast<T*>(::operator new(n * sizeof(T)));
        }
    }

    void deallocate(T* p, std::size_t n) noexcept {
        if (!p) return;
        if (n == 1) {
            GlobalNodePool::instance().deallocate(p);
        } else {
            ::operator delete(p);
        }
    }

    // 所有 PoolAllocator<T> 共享同一个全局池 → 认为相等
    bool operator==(const PoolAllocator&) const noexcept { return true; }
    bool operator!=(const PoolAllocator&) const noexcept { return false; }
};

四、实战:让 std::liststd::unordered_map 吃上内存池

4.1 给 std::list 换上 PoolAllocator

#include <list>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

struct MyData {
    int    id;
    double value;
    // ... 业务...
};

using PoolList = std::list<MyData, PoolAllocator<MyData>>;

void worker_list(int threadId, int ops) {
    PoolList lst;
    for (int i = 0; i < ops; ++i) {
        lst.push_back(MyData{ i, i * 0.1 });
        if (lst.size() > 1000) {
            lst.pop_front();
        }
    }
    // scope 内析构,自动批量释放节点到内存池
}

int main() {
    const int threadCount = 8;
    const int perThreadOps = 100000;

    auto start = std::chrono::high_resolution_clock::now();

    std::vector<std::thread> threads;
    for (int i = 0; i < threadCount; ++i) {
        threads.emplace_back(worker_list, i, perThreadOps);
    }
    for (auto& t : threads) t.join();

    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> diff = end - start;

    std::cout << "list with PoolAllocator finished in " 
              << diff.count() << " s\n";

    return 0;
}

你可以写两组对比:

  • 组 A:using DefaultList = std::list<MyData>;
  • 组 B:using PoolList = std::list<MyData, PoolAllocator<MyData>>;

相同的压测逻辑跑一遍,看耗时 & 内存占用。


4.2 std::unordered_map 也可以同样接

unordered_map 的节点更复杂一些,但对 allocator 来说没区别,因为容器内部的 node 类型最后都会走你提供的 value_type 相关 allocator。

#include <unordered_map>
#include <string>

using PoolString = std::basic_string<char, std::char_traits<char>, PoolAllocator<char>>;

struct KeyHash {
    using is_transparent = void;
    std::size_t operator()(const PoolString& s) const noexcept {
        return std::hash<std::string_view>{}(std::string_view(s.data(), s.size()));
    }
};

using PoolMap = std::unordered_map<
    PoolString,
    int,
    KeyHash,
    std::equal_to<PoolString>,
    PoolAllocator<std::pair<const PoolString, int>>
>;

void test_map() {
    PoolMap mp;
    for (int i = 0; i < 100000; ++i) {
        PoolString key;
        key.reserve(16);
        key += 'k';
        key += 'e';
        key += 'y';
        // 简化起见,这里直接构字符
        mp.emplace(std::move(key), i);
    }
}

这一块主要是演示:allocator 不只给容器节点用,还可以给 string / pair 用
真实项目中可以根据需要决定:只给 node 容器换 allocator,string 还是用默认的也完全 OK。


五、要注意的细节 & 坑

5.1 这是“node 容器专用 allocator”

这一版设计是基于“单个节点分配”的前提来的:

  • n == 1 → 内存池;
  • n > 1 → 系统 new

所以更适合:

  • std::list
  • std::forward_list
  • std::map / std::set
  • std::unordered_map / unordered_set

不适合直接拿去给 std::vector / std::string 做大块连续内存池(可以,但需要额外设计一个“变长块池”)。

5.2 线程安全 vs 性能

上面的 GlobalNodePool 用的是一个 mutex 包住 FixedSizePool

  • 优点:逻辑简单、安全;
  • 缺点:极高并发场景下会有锁竞争。

后面如果你有兴趣,可以继续演进:

  1. 把池做到 thread-local 小池 + 中央池 的模式(类似 TCMalloc);
  2. 或者改用无锁栈(std::atomic<Node*> + CAS)做 free list。

但这已经是第三篇、第四篇的内容了 😄,这一篇先把**“怎么挂到 STL”**讲清楚。

5.3 池的生命周期和状态

我们这里用的是:

static GlobalNodePool& instance();

在普通业务进程里,这种“懒汉单例 + 静态局部变量”足够安全:

  • 首次调用构造;
  • 程序退出时由 runtime 统一析构。

如果你在 动态库 / 插件 中用 allocator,要注意:

  • 确保 allocator / pool 的生命周期长于用它的容器;
  • 可以考虑让池“永不析构”(泄一点内存换简单性,对常驻进程问题不大)。
Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐