1. 为什么需要内存池

内存池是一类在程序启动或运行阶段预先申请大块内存,再按需切分、复用、回收的内存管理技术。它并不是要完全替代操作系统提供的 mallocfreenewdelete,而是针对特定场景减少系统调用、降低分配延迟、缓解碎片化、提升缓存局部性。

在通用内存分配器里,一次 malloc 调用背后往往包含以下工作:查找合适大小的空闲块、必要时向操作系统申请更多虚拟内存、更新空闲链表或红黑树、记录分配元数据、处理多线程竞争等。对于高频小对象分配场景,例如每秒上百万次的对象创建与销毁,这些开销会被无限放大。内存池通过一次大量申请、按固定或分级尺寸切分、对象回收后直接放回空闲链表的思路,将单次分配退化为常数时间的链表摘取操作。

理解内存池不能只停留在“快”的直觉上,还需要从三个维度观察它的收益来源:

  • 减少系统调用malloc 在本地缓存耗尽时可能需要通过 brkmmap 向内核申请内存,而内存池大多数分配都发生在用户态,不触发内核态切换。
  • 降低碎片:固定大小内存池中所有块尺寸一致,不存在外部碎片;可变长内存池通过分级和伙伴合并策略控制内部与外部碎片。
  • 改善局部性:内存池中的对象集中存放在一块连续地址空间内,CPU 缓存命中率更高,尤其对遍历链表、图结构等场景提升明显。

内存池并非万能。它适用于对象生命周期可预测、分配尺寸相对固定、高频分配释放的服务端程序,例如网络服务器中的连接对象、数据库中的记录缓冲、游戏引擎中的粒子与子弹对象、消息队列中的消息结构等。如果程序偶尔分配一次大对象且不希望长期占住内存,直接使用系统分配器反而更合适。

2. 通用内存分配器的开销来源

要设计内存池,需要先理解 malloc 为什么要付出那些成本。以常见的 glibc ptmalloc 为例,它维护了 fast binssmall binslarge binsunsorted bin 等多层结构。释放内存时并不一定立刻归还操作系统,而是放入缓存区等待下次复用。这样的设计在通用场景下已经相当优秀,但通用性本身也带来了代价。

2.1 元数据空间开销

每次分配通常会在用户可见内存的前方或侧方保存一个头部信息,用来记录该块的大小、是否空闲、前后邻接关系等。对于 8 字节的小对象,头部可能占据同样甚至更多的空间,造成实际内存利用率下降。内存池如果只为一种尺寸的对象服务,可以完全不保存每块头部,因为尺寸信息隐含在池的类型中。

2.2 查找与合并开销

当空闲块大小不能满足请求时,分配器需要遍历空闲表或查找树;释放时还可能触发与相邻空闲块的合并、拆分等复杂操作。这些操作保证了灵活性和碎片控制,但在高频小对象场景下是纯开销。内存池的定长块释放物归原处,不需要合并拆分,复杂度为 O(1)。

2.3 线程竞争开销

多线程环境下,全局分配器必须通过互斥锁、自旋锁或线程缓存等机制保护共享数据结构。锁竞争、缓存行伪共享、线程局部缓存迁移都会拖慢性能。高质量内存池通常为每个线程分配独立的本地缓存,减少全局同步。

2.4 系统调用开销

用户态分配器最终要通过 brkmmapmadvise 等系统调用获取或归还内存。系统调用涉及用户态与内核态切换、页表操作、缺页处理等。内存池真正的价值之一就是把这些昂贵的系统调用集中到批量阶段,把高频路径留在用户态。

3. 内存池的分类

内存池的实现方式很多,根据分配粒度、线程模型、回收策略可以划分为不同类别。不同类别解决不同问题,工程实践中经常组合使用。

3.1 定长内存池与变长内存池

定长内存池只管理同一种尺寸的内存块,结构最简单,性能最高。每个池对应一个空闲链表,分配时弹出头节点,释放时挂回头节点。变长内存池需要支持不同尺寸的请求,通常按 8、16、32、64 等尺寸分档,每一档内部仍然采用定长池的链表结构,或者采用伙伴系统、slab 机制分配不同大小的对象。

3.2 单线程内存池与多线程内存池

单线程内存池不需要考虑同步,设计最直观。多线程内存池必须处理多个线程同时分配与释放带来的数据竞争。常见策略包括:全局加锁、每线程私有池、分层线程缓存、无锁队列等。工业级分配器如 tcmalloc 和 jemalloc 都采用线程本地缓存加分页中枢的结构。

3.3 可增长内存池与固定容量内存池

有些嵌入式系统或游戏引擎会预先划定一块固定大小内存区域,所有分配都从该区域中完成,容量耗尽直接报错。这种方案没有运行时扩容成本,内存使用完全可控,但要求开发者事先估算峰值。另一些内存池则支持动态向系统申请新的内存块,灵活性更好。

3.4 对象池与通用内存池

对象池是为某一类对象设计的内存池,例如数据库连接池、线程池、网络缓冲区池。它通常不直接暴露内存地址给用户,而是借出和归还完整对象,因此还能承担资源初始化、复用、生命期管理等工作。通用内存池只负责内存的分配与回收,不关心对象语义。

4. 设计目标与核心指标

设计内存池前,必须定义清楚要优化什么、能接受什么代价。常见的设计目标包括分配速度、内存利用率、碎片控制、线程扩展性、实现复杂度、可调试性等。

4.1 分配与释放的复杂度

理想情况下,单次分配与释放都应当是常数时间 O(1)。这个目标可以通过固定大小的空闲链表轻松实现。一旦加入按尺寸分档、合并拆分、跨块复用等能力,复杂度可能上升到 O(log n),但换来的是更低的内存开销。

4.2 内存利用率

内存利用率等于实际被应用程序使用的字节数除以内存池从系统申请到的总字节数。定长池如果对象尺寸与实际使用差异很大时会浪费严重;按 2 的幂次分档在尺寸不匹配时最多浪费接近一倍,而更细粒度分档可以减少浪费却增加链表数量和管理开销。需要在两者之间权衡。

4.3 碎片控制

外部碎片是指空闲内存散布在多个不相邻的小块中,虽然总量足够但无法满足一个大块请求。内部碎片是指分配给请求的内存大于实际需要的部分。内存池既要通过合并机制降低外部碎片,又要通过尺寸分级降低内部碎片。

4.4 线程扩展性

多核环境下,内存分配器是否具备良好的扩展性,取决于全局锁粒度、线程缓存容量、跨线程归还策略。理想情况是大多数分配都在线程本地完成,只有缓存耗尽或溢出时才访问全局中枢。

4.5 可观测性与安全性

内存池还应当为调试提供支持,例如记录统计信息、检测越界访问、检测重复释放、检测内存泄漏。这些能力往往通过编译期开关控制,发布版本关闭以换取性能。

5. 定长 FreeList 内存池设计

定长 FreeList 内存池是最基础也最常用的设计。它用单向链表管理所有空闲块,每个空闲块的前若干字节被复用来存放下一个空闲块的指针,不额外占用空间。其核心思想是:既然这块内存正在空闲,就可以把它的起止地址当成指针存储区域。

5.1 数据结构

struct FreeNode {
    FreeNode* next;
};
class FixedAllocator {
public:
explicit FixedAllocator(size_t blockSize, size_t blockCount);
~FixedAllocator();
void* allocate();
void deallocate(void* ptr);
size_t blockSize() const { return blockSize_; }
size_t freeCount() const { return freeCount_; }
private:
void grow();
size_t blockSize_;
size_t freeCount_;
FreeNode* freeList_;
std::vector<char*> chunks_;
};

这里的关键点是 blockSize_ 定义每个块的有效大小,freeList_ 指向空闲链表头节点,chunks_ 保存每一次从系统申请的大块内存基址,便于析构时统一释放。为了避免 freeList_ 头指针频繁被更新,还可以使用指向指针的指针技巧,但基础版本直接使用即可。

5.2 初始化与块切分

FixedAllocator::FixedAllocator(size_t blockSize, size_t blockCount)
    : blockSize_(std::max(blockSize, sizeof(FreeNode))),
      freeCount_(0),
      freeList_(nullptr) {
    if (blockCount > 0) {
        grow(blockCount);
    }
}
FixedAllocator::~FixedAllocator() {
for (char* chunk : chunks_) {
::operator delete(chunk);
}
}

构造时如果指定了预分配块数量,可以直接调用扩展函数切分块。块尺寸必须不小于一个指针的大小,否则空闲块连“下一个指针”都放不下。C++ 中使用 ::operator delete::operator new 配对,保证整块大内存的申请与释放成对出现。

5.3 分配与释放

void FixedAllocator::grow(size_t blockCount) {
    const size_t metaSize = sizeof(FreeNode);
    // 块的实际存储需要同时容纳用户数据与空闲链表指针。
    const size_t total = blockSize_;
    char* chunk = static_cast<char*>(::operator new(total * blockCount));
    chunks_.push_back(chunk);
for (size_t i = 0; i &lt; blockCount; ++i) {
    char* block = chunk + i * total;
    deallocate(block);
}
freeCount_ += blockCount;
}
void* FixedAllocator::allocate() {
if (freeList_ == nullptr) {
grow(kDefaultGrowCount);
}
FreeNode* node = freeList_;
freeList_ = node->next;
--freeCount_;
return node;
}
void FixedAllocator::deallocate(void* ptr) {
if (ptr == nullptr) {
return;
}
FreeNode* node = static_cast<FreeNode*>(ptr);
node->next = freeList_;
freeList_ = node;
++freeCount_;
}

这段代码完整展示了 FreeList 的分配逻辑。分配时直接摘取头节点,时间复杂度 O(1);释放时把块作为新头节点插入链表,同样是 O(1)。grow 一次性申请 total * blockCount 字节,并把所有块串成空闲链表。这里为了保证对象地址满足对齐要求,实际实现中通常会把块尺寸向上取整到 8 或 16 的倍数。

5.4 嵌入指针技术

空闲链表复用空闲块自身的前 8 字节存放 next 指针,这种技巧叫嵌入指针。它避免了为每个块额外分配一个指针节点,内存零开销。但要注意,程序在空闲块被取出后就会写入自己的数据,这会覆盖原来的 next 指针;释放时又把它当作指针重新写入,因此不会产生数据安全问题,因为空闲块中的数据本来就不再有意义。

嵌入指针要求块尺寸大于等于指针尺寸。对于极小的定长内存,例如 4 字节对象,在 64 位系统上无法使用 8 字节指针,这类对象要么交给通用分配器,要么合并成更大的块再统一管理。

6. 内存对齐的深入讨论

内存对齐是内存池实现中最容易被忽略却影响正确性和性能的细节。CPU 访问内存时,不同架构对地址有对齐要求。x86-64 上未对齐访问通常也能工作,但会带来性能惩罚;而某些 ARM 架构上未对齐访问可能直接触发异常。此外,多线程安全、SIMD 指令、原子操作都要求更严格的对齐。

6.1 基本对齐

malloc 返回的内存保证适合任何基础类型,典型对齐为 16 字节。自定义内存池也必须保证返回地址满足对象类型的对齐要求。alignofalignas 是 C++11 提供的工具,可以在实现中对齐检测做静态断言。

static_assert(alignof(std::max_align_t) == 16, "unexpected platform alignment");
constexpr size_t alignUp(size_t n, size_t alignment) {
    return (n + alignment - 1) & ~(alignment - 1);
}

6.2 缓存行对齐

现代 CPU 的缓存行通常是 64 字节。两个被不同线程频繁修改的变量如果落在同一个缓存行内,会产生伪共享,导致每次修改都互相失效缓存。内存池中的全局计数器、线程本地缓存头结构都应尽量按缓存行对齐并做填充。

struct alignas(64) ThreadCache {
    FreeNode* freeList = nullptr;
    size_t allocated = 0;
    size_t deallocated = 0;
    char padding[64];
};

6.3 对齐分配实现

如果内存池需要支持任意对齐,可以在返回前把地址向上调整,并把原始地址保存在返回指针前方,释放时再恢复。

void* alignedAllocate(size_t size, size_t alignment) {
    const size_t total = size + alignment + sizeof(void*);
    void* raw = ::operator new(total);
    uintptr_t aligned =
        (reinterpret_cast<uintptr_t>(raw) + sizeof(void*) + alignment - 1)
        & ~(alignment - 1);
    void** header = reinterpret_cast<void**>(aligned) - 1;
    *header = raw;
    return reinterpret_cast<void*>(aligned);
}
void alignedDeallocate(void* ptr) {
void** header = reinterpret_cast<void**>(ptr) - 1;
::operator delete(*header);
}

该实现额外申请 alignment 字节,以便在最坏情况下仍能向上对齐;同时在对齐地址前保存原始地址,释放时找出真实起始位置。它的缺点是每个对象多消耗一个指针的空间。

7. 按尺寸分档的变长内存池

定长内存池只能服务于一种尺寸。为了处理任意大小的分配请求,一个直接思路是维护一系定长池,每个池负责一个尺寸档位。请求到来时,向上取整到最近的档位,再从对应池中分配。

7.1 分档策略

最简单的分档是按 8 字节递增,8、16、24、32 一直到某个上限。更节省管理结构的方式是按 2 的幂次分档:8、16、32、64、128……前者内部碎片小,但档位多;后者链表数量少,但最大可能浪费接近一半。实际实现可以在小尺寸段细粒度分档,在大尺寸段粗粒度分档。

class BucketAllocator {
public:
    static constexpr size_t kMaxSmallSize = 256;
    static constexpr size_t kNumBuckets = kMaxSmallSize / 8;
void* allocate(size_t size);
void deallocate(void* ptr, size_t size);
private:
size_t bucketIndex(size_t size) const {
return (std::max(size, size_t(8)) - 1) / 8;
}
std::array&lt;FixedAllocator, kNumBuckets&gt; buckets_;
};

7.2 分配与释放路径

void* BucketAllocator::allocate(size_t size) {
    if (size == 0) {
        size = 1;
    }
    if (size <= kMaxSmallSize) {
        const size_t idx = bucketIndex(size);
        return buckets_[idx].allocate();
    }
    // 大对象直接交给系统分配器,内存池只加速小对象。
    return ::operator new(size);
}
void BucketAllocator::deallocate(void* ptr, size_t size) {
if (ptr == nullptr) {
return;
}
if (size <= kMaxSmallSize) {
const size_t idx = bucketIndex(size);
buckets_[idx].deallocate(ptr);
} else {
::operator delete(ptr);
}
}

这种方案的释放接口需要传入原先的分配尺寸,因为释放时必须知道把内存归还到哪个档位的池中。C++ 的 operator delete 支持带销毁尺寸的重载,可以把尺寸传递给释放函数,因此该限制在封装成 C++ 分配器接口时并不难处理。

7.3 基于 2 的幂次分档

使用最高位计算等技巧可以快速定位尺寸档位。但 2 的幂次分档浪费较大,尤其是 33 字节会被归入 64 字节档,浪费接近一半。为此,工业实现往往混合分档,例如 8 到 128 之间按 8 字节递增,128 到 1KB 之间按 64 字节递增,更大尺寸采用伙伴系统或直接走通用分配器。

8. 伙伴系统详解

伙伴系统是一种经典的内存分配算法,以 2 的幂次作为块尺寸。它把空闲块组织为多个链表,每个链表对应一个尺寸等级。分配时如果该等级没有空闲块,就向上拆分更大的块;释放时如果伙伴块也空闲,就向上合并。伙伴系统的核心是“伙伴地址”的计算,它让合并动作只需要 O(1) 定位伙伴,而无需扫描邻接信息。

8.1 伙伴关系

两个块互为伙伴,当且仅当它们从同一个父块中拆分出来,并且地址连续。在二进制地址视角下,伙伴地址就是当前块地址与块尺寸异或。假设块尺寸为 2^k,块起始地址 addr 的伙伴地址为 addr ^ (1 << k)

size_t buddyOf(size_t addr, size_t order) {
    return addr ^ (size_t(1) << order);
}

8.2 数据结构

class BuddyAllocator {
public:
    explicit BuddyAllocator(size_t totalSize);
    ~BuddyAllocator();
void* allocate(size_t size);
void deallocate(void* ptr);
private:
struct BlockHeader {
size_t order;
bool free;
};
size_t orderFor(size_t size) const;
size_t split(size_t order);
void merge(size_t index, size_t order);
char* memory_;
size_t totalSize_;
size_t maxOrder_;
std::vector&lt;BlockHeader&gt; headers_;
std::vector&lt;std::list&lt;size_t&gt;&gt; freeLists_;
};

8.3 分配与释放流程

size_t BuddyAllocator::orderFor(size_t size) const {
    size_t order = 0;
    size_t blockSize = 1;
    while (blockSize < size) {
        blockSize <<= 1;
        ++order;
    }
    return order;
}
size_t BuddyAllocator::split(size_t order) {
if (order > maxOrder_ || freeLists_[order].empty()) {
return static_cast<size_t>(-1);
}
size_t index = freeLists_[order].front();
freeLists_[order].pop_front();
headers_[index].free = false;
// 逐级向下拆分,直到满足目标等级。
while (order &gt; 0 &amp;&amp; headers_[index].order &gt; 0) {
    --order;
    size_t buddyIndex = buddyOf(index, order);
    headers_[buddyIndex].order = order;
    headers_[buddyIndex].free = true;
    freeLists_[order].push_back(buddyIndex);
}
headers_[index].order = order;
return index;
}

伙伴系统申请较大块时,从更高等级拆出一个空闲块,并将其余一半放入较小等级链表中。释放时检查伙伴是否空闲且等级相同,如果满足则合并并继续向上检查。

void BuddyAllocator::merge(size_t index, size_t order) {
    while (order < maxOrder_) {
        size_t buddy = buddyOf(index, order);
        BlockHeader& bh = headers_[buddy];
        if (!bh.free || bh.order != order) {
            break;
        }
        // 从当前等级链表中移除伙伴。
        freeLists_[order].remove(index);
        freeLists_[order].remove(buddy);
        if (buddy < index) {
            index = buddy;
        }
        ++order;
    }
    headers_[index].order = order;
    headers_[index].free = true;
    freeLists_[order].push_back(index);
}

伙伴系统的优点是分配与释放都能较快完成,块尺寸规整,外部碎片较少;缺点是内部碎片较大,并且元数据维护需要额外的位图或头部结构。它常见于操作系统内核的页分配器和部分游戏引擎的资源池。

9. Slab 分配器设计

Slab 分配器最早由 Sun 公司用于 Solaris 内核,后来被 Linux 内核广泛采用。它的核心思路与按尺寸分档的内存池类似,但引入了“着色”技术改善 CPU 缓存映射,并通过对象构造缓存减少重复初始化开销。用户态内存池可以从 slab 中借鉴分层思想:一个 slab 就是一块连续内存,被切分为多个相同尺寸的对象槽位。

9.1 三层结构

Slab 系统通常分为三层:缓存、slab、对象。缓存管理某一种尺寸的所有 slab;每个 slab 内部是一个空闲对象位图和若干对象槽;对象则从 slab 的位图中查找空闲槽位。对比 FreeList,slab 的空闲信息可以用位图而不是链表保存,便于顺序扫描、减少指针破坏风险。

9.2 一个简化版 Slab 实现

class Slab {
public:
    Slab(size_t objectSize, size_t objectCount)
        : objectSize_(objectSize), objectCount_(objectCount),
          freeCount_(objectCount) {
        data_ = static_cast<char*>(>
            ::operator new(objectSize * objectCount + (objectCount + 7) / 8));
        bitmap_ = data_ + objectSize * objectCount;
        std::memset(bitmap_, 0, (objectCount + 7) / 8);
    }
void* allocate() {
    if (freeCount_ == 0) {
        return nullptr;
    }
    for (size_t i = 0; i &lt; objectCount_; ++i) {
        if (!testBit(i)) {
            setBit(i);
            --freeCount_;
            return data_ + i * objectSize_;
        }
    }
    return nullptr;
}
void deallocate(void* ptr) {
const size_t index = (static_cast&lt;char*&gt;(ptr) - data_) / objectSize_;
clearBit(index);
++freeCount_;
}
private:
void setBit(size_t i) { bitmap_[i / 8] |= (1 << (i % 8)); }
void clearBit(size_t i) { bitmap_[i / 8] &= ~(1 << (i % 8)); }
bool testBit(size_t i) const { return bitmap_[i / 8] & (1 << (i % 8)); }
char* data_;
char* bitmap_;
size_t objectSize_;
size_t objectCount_;
size_t freeCount_;
};

上面的例子为了可读性省略了对象构造缓存、着色、对齐等细节,但已经展示了 slab 的核心:一块大内存、按对象尺寸切分、用位图记录空闲状态。位图分配需要遍历查找空闲位,最坏 O(n)。若要 O(1),可以保留一个空闲头索引,或者混合 FreeList 思想,把空闲对象用链表串起来。

10. 多线程与并发内存池

单线程内存池无法直接服务多线程程序。工业级分配器通常采用“线程缓存 + 中央堆 + 页中枢”的分层结构,让绝大部分分配与释放在线程本地完成,减少全局锁竞争。

10.1 线程本地缓存

每个线程拥有一个 ThreadCache,按尺寸档位维护多个小空闲链表。分配时优先从线程缓存取,释放时优先还到线程缓存。只有缓存为空或超过阈值时,才与中央全局结构交互。

class ThreadCache {
public:
    void* allocate(size_t size) {
        const size_t idx = bucket(size);
        FreeNode* node = lists_[idx];
        if (node) {
            lists_[idx] = node->next;
            --cached_[idx];
            return node;
        }
        return centralAlloc(idx);
    }
void deallocate(void* ptr, size_t size) {
    const size_t idx = bucket(size);
    FreeNode* node = static_cast&lt;FreeNode*&gt;(ptr);
    node-&gt;next = lists_[idx];
    lists_[idx] = node;
    if (++cached_[idx] &gt;= kThreshold) {
        centralFree(idx);
    }
}
private:
static constexpr size_t kNumBuckets = 64;
static constexpr size_t kThreshold = 128;
std::array<FreeNode*, kNumBuckets> lists_{};
std::array<size_t, kNumBuckets> cached_{};
};

10.2 线程缓存与中央堆的交换

线程缓存为空时,需要从中央堆批量拉取一批对象,而不是只取一个。这样可以把中央堆的锁竞争摊销到多次分配中。缓存超过阈值时,再把一部分对象批量归还。批量粒度通常取决于对象大小,小对象每次搬运较多,大对象每次搬运较少。

void* ThreadCache::centralAlloc(size_t idx) {
    std::lock_guard<std::mutex> lock(centralMutex_);
    auto& freelist = central_[idx];
    FreeNode* node = freelist;
    size_t count = 0;
    while (node && count < kBatchSize) {
        node = node->next;
        ++count;
    }
    if (node) {
        FreeNode* next = node->next;
        node->next = nullptr;
        lists_[idx] = freelist->next;
        freelist = next;
        cached_[idx] = count - 1;
        return freelist;
    }
    return nullptr;
}

这里的同步开销集中在批量搬运时刻,普通分配路径完全无锁,因此多核扩展性良好。跨线程归还会产生一个线程把内存释放到另一个线程缓存的问题,工业实现通过在中央堆层面做归属标记并游荡处理,或者允许小比例对象暂时挂在非所属线程缓存中。

10.3 锁的粒度与无锁化

多线程内存池的全局结构应当尽可能缩小锁粒度。每个尺寸档位一把锁优于一把全局锁;每核一个中央结构优于争抢同一个结构。极端情况下可以使用无锁栈和 CAS 实现空闲链表,但需要处理 ABA 问题,通常使用带版本号的指针或消除策略。相比无锁实现的复杂度,分层线程缓存在多数工程场景下性价比更高。

11. 完整实现:线程安全分级内存池

下面给出一个相对完整的 C++ 线程安全内存池,它结合了按尺寸分档、线程本地缓存、批量分配与释放。代码以教学为目标,追求清晰而非绝对极限性能,但结构已经贴近工业实现。

11.1 公共接口

class MemoryPool {
public:
    static MemoryPool& instance();
void* allocate(size_t size);
void deallocate(void* ptr, size_t size);
private:
MemoryPool();
~MemoryPool();
MemoryPool(const MemoryPool&) = delete;
MemoryPool& operator=(const MemoryPool&) = delete;
static constexpr size_t kMaxSmallSize = 4 * 1024;
static constexpr size_t kClassCount = 64;
static constexpr size_t kBatchSize = 64;
size_t sizeClass(size_t size) const;
void refill(size_t idx);
void releaseBatch(size_t idx, FreeNode* head, size_t count);
struct alignas(64) PerThreadData {
std::array&lt;FreeNode*, kClassCount&gt; freeLists{};
std::array&lt;size_t, kClassCount&gt; cached{};
};
static thread_local PerThreadData tls_;
std::array&lt;FreeNode*, kClassCount&gt; central_;
std::array&lt;std::mutex, kClassCount&gt; mutexes_;
};

11.2 尺寸档位计算

size_t MemoryPool::sizeClass(size_t size) const {
    if (size <= 8) return 0;
    if (size <= 16) return 1;
    if (size <= 32) return 2;
    if (size <= 64) return 3;
    // 大于 64 字节后按 64 字节为一档。
    return ((size - 1) / 64) + 4;
}

该分档方式在 64 字节以下用 2 的幂次档,64 字节以上按 64 字节递增,兼顾了小型对象的粒度和大型对象的管理结构数量。真实 tcmalloc 使用的 size class 更复杂,会配合实测缓存行做校准,但原理一致。

11.3 分配路径

void* MemoryPool::allocate(size_t size) {
    if (size == 0) {
        size = 1;
    }
    if (size > kMaxSmallSize) {
        return ::operator new(size);
    }
    const size_t idx = sizeClass(size);
    FreeNode* node = tls_.freeLists[idx];
    if (node) {
        tls_.freeLists[idx] = node->next;
        --tls_.cached[idx];
        return node;
    }
    refill(idx);
    node = tls_.freeLists[idx];
    if (node) {
        tls_.freeLists[idx] = node->next;
        return node;
    }
    return ::operator new(size);
}

11.4 释放路径

void MemoryPool::deallocate(void* ptr, size_t size) {
    if (ptr == nullptr) {
        return;
    }
    if (size > kMaxSmallSize) {
        ::operator delete(ptr);
        return;
    }
    const size_t idx = sizeClass(size);
    FreeNode* node = static_cast<FreeNode*>(ptr);
    node->next = tls_.freeLists[idx];
    tls_.freeLists[idx] = node;
    if (++tls_.cached[idx] >= kBatchSize) {
        releaseBatch(idx, tls_.freeLists[idx], tls_.cached[idx]);
        tls_.freeLists[idx] = nullptr;
        tls_.cached[idx] = 0;
    }
}

11.5 批量补充与批量归还

void MemoryPool::refill(size_t idx) {
    std::lock_guard<std::mutex> lock(mutexes_[idx]);
    FreeNode* head = central_[idx];
    if (head == nullptr) {
        // 向系统批量申请,并切分成对象挂到中央链表。
        const size_t blockBytes = (kMaxSmallSize + 8) * kBatchSize;
        char* block = static_cast<char*>(::operator new(blockBytes));
        // 实际应当保存块地址供最终释放,这里为示例简化。
        FreeNode* prev = nullptr;
        for (size_t i = 0; i < kBatchSize; ++i) {
            FreeNode* node =
                reinterpret_cast<FreeNode*>(block + i * (kMaxSmallSize + 8));
            if (prev) prev->next = node;
            else head = node;
            prev = node;
        }
        if (prev) prev->next = nullptr;
        central_[idx] = head;
    }
// 从中央链表摘取一批。
FreeNode* batch = head;
size_t count = 0;
FreeNode* prev = nullptr;
while (head &amp;&amp; count &lt; kBatchSize) {
    prev = head;
    head = head-&gt;next;
    ++count;
}
if (prev) {
    prev-&gt;next = nullptr;
}
central_[idx] = head;
tls_.freeLists[idx] = batch;
tls_.cached[idx] = count;
}
void MemoryPool::releaseBatch(size_t idx, FreeNode* head, size_t count) {
std::lock_guard<std::mutex> lock(mutexes_[idx]);
FreeNode* tail = head;
while (tail && tail->next) {
tail = tail->next;
}
if (tail) {
tail->next = central_[idx];
}
central_[idx] = head;
}

这里为演示省略了整块内存的追踪和最终释放,生产实现需要维护一个块链表,在析构或进程退出时统一释放。批量补充时从中央链表一次性摘下 kBatchSize 个对象给线程缓存,后续这些分配全部无锁,直到缓存耗尽。

12. 性能测试与基准方法

内存池的性能不能只看一次分配耗时,必须结合多线程、对象尺寸分布、分配与释放比例、缓存冷热等因素综合评估。常见的基准包括单线程顺序分配、单线程随机释放、多线程并发分配、生产者消费者模式等。

12.1 基准测试样例

#include <chrono>
#include <vector>
#include <cstdio>
template <typename Alloc>
void benchmark(Alloc alloc, size_t count, const char* name) {
using namespace std::chrono;
std::vector<void*> ptrs;
ptrs.reserve(count);
auto start = high_resolution_clock::now();
for (size_t i = 0; i &lt; count; ++i) {
    ptrs.push_back(alloc.allocate(64));
}
for (size_t i = 0; i &lt; count; ++i) {
    alloc.deallocate(ptrs[i], 64);
}
auto end = high_resolution_clock::now();
double ms = duration_cast&lt;microseconds&gt;(end - start).count() / 1000.0;
printf("%s: %.2f ms for %zu iterations (%.1f ns/op)\n",
name, ms, count, ms * 1e6 / count);
}

12.2 典型对比对象

分配器单线程小对象多线程扩展性内存利用率实现复杂度
malloc/free低(直接使用)
定长 FreeList很高需额外同步低(尺寸固定)很低
分档内存池取决于实现
伙伴系统中(内部碎片大)较高
线程缓存内存池中高

12.3 性能陷阱

  • 过早优化:在确认分配是瓶颈之前不要引入复杂内存池,普通程序 malloc 可能已经足够。
  • 伪共享:全局计数器和锁变量应关注缓存行布局。
  • 大对象误入小对象池:超过阈值必须直接走系统分配,否则浪费严重。
  • 批量粒度失衡:批量太大造成内存滞留,太小则频繁竞争全局锁。
  • 测试不真实:用完全顺序的分配释放衡量内存池会高估性能,真实负载往往有随机释放和跨线程模式。

13. 内存池的调试与安全增强

内存池把大量内存管理逻辑收归己有,既是性能优化手段,也可能成为内存错误放大器。一旦出现越界、野指针、重复释放,排查难度可能比系统分配器更高。因此调试版内存池通常内置边界保护、统计和检测。

13.1 红区检测越界

在每个分配块前后放置固定模式字节,释放或检查时验证模式是否被破坏。若被破坏,说明发生越界写。红区会增加固定内存开销,应当只在调试版开启。

#ifdef MEMPOOL_DEBUG
constexpr size_t kRedZone = 8;
constexpr uint8_t kRedByte = 0xA5;
#endif

13.2 填充模式检测未初始化

分配时用 0xCD 填充内存,释放时用 0xDD 填充,可以帮助观察程序是否读取未初始化或已释放内存。这些值不保证一定暴露错误,但能提高可观察性。

13.3 统计信息

内存池可以记录每种档位当前分配数、峰值分配数、累计分配次数、缓存命中率等。这些数据既是调优依据,也是线上排查内存泄漏的线索。

struct PoolStats {
    size_t allocated;
    size_t peakAllocated;
    size_t totalAllocs;
    size_t totalFrees;
    size_t cacheHits;
    size_t cacheMisses;
};

14. 实际工程实践与注意事项

在真实项目中引入内存池,不仅要写对核心算法,还要处理好对象生命期、线程退出时机、与标准库容器集成、动态扩容与归还策略等问题。

14.1 与 STL 容器集成

STL 容器模板接受自定义分配器参数。通过实现 value_typeallocatedeallocate 等接口,可以让 std::vectorstd::liststd::unordered_map 背后的内存都来自内存池。

template <typename T>
class PoolAllocator {
public:
    using value_type = T;
PoolAllocator(MemoryPool* pool) : pool_(pool) {}
template &lt;typename U&gt;
PoolAllocator(const PoolAllocator&lt;U&gt;&amp; other) : pool_(other.pool_) {}
T* allocate(size_t n) {
return static_cast&lt;T*&gt;(pool_-&gt;allocate(n * sizeof(T)));
}
void deallocate(T* ptr, size_t n) {
pool_-&gt;deallocate(ptr, n * sizeof(T));
}
bool operator==(const PoolAllocator&amp; other) const {
return pool_ == other.pool_;
}
bool operator!=(const PoolAllocator&amp; other) const {
return !(this == other);
}
private:
MemoryPool pool_;
};

14.2 动态扩容与内存归还

内存池从系统申请的大块内存通常不会轻易归还,因为归还意味着下一次分配又要重新系统调用,并且碎片化处理复杂。可以在空闲块比例长期过高时通过 madvise 建议内核回收物理页,或者维护块级引用计数,当整块完全空闲时主动归还。

#include <sys/mman.h>
void releasePhysicalPages(void* ptr, size_t len) {
    madvise(ptr, len, MADV_DONTNEED);
}

14.3 线程退出处理

线程本地缓存在线程退出时如果直接丢弃,会把大量对象滞留在终止线程中。正确的做法是注册线程退出回调,在回调中把线程缓存的对象全部归还到中央堆。C++ 中可以使用 thread_local 对象的析构函数来执行。

struct ThreadExitCleanup {
    ~ThreadExitCleanup() {
        MemoryPool::instance().releaseThreadCache();
    }
};
thread_local ThreadExitCleanup cleanupGuard;

14.4 内存池对象生命期陷阱

如果内存池作为全局对象析构过早,而其他模块仍持有其中分配的内存,进程退出时可能访问已释放区域。应让内存池尽量延后析构,例如使用“泄漏式”单例,进程退出时由操作系统统一回收,或者使用 atexit 注册的清理函数反向初始化顺序。

15. 与工业级分配器的对比

理解 tcmalloc、jemalloc、glibc ptmalloc 的取舍,有助于评估自研内存池的必要性与边界。

15.1 tcmalloc 的关键思路

tcmalloc 按对象大小分为小对象、中对象和大对象。小对象分配在每线程缓存中完成,缓存不足时从中央空闲列表批量获取;中对象使用页堆和跨度信息;大对象直接通过页或 mmap。它的目标是小对象零竞争、大对象高效且内存占用合理。

15.2 jemalloc 的关键思路

jemalloc 使用多个 arena 降低线程竞争,线程按哈希分配到 arena,arena 内部维护分档的 bin 结构和 run。它非常注重查找速度、缓存行友好以及碎片控制,还提供丰富的运行时统计与调优接口。

15.3 ptmalloc 的关键思路

ptmalloc 是 glibc 默认分配器,它在 dlmalloc 基础上增加多线程支持,采用多个 arena 避免单一锁。快速路径 fast bins 处理小对象释放;常规路径通过 unsorted bin 中转,再归入 small binslarge bins。它通用性强,但极端高频小对象场景不如专用线程缓存分配器。

特性ptmalloctcmallocjemalloc
线程缓存有(arena)有(TLS)有(TLS + arena)
小对象性能
碎片控制中高
可统计性较弱较强很强
适用场景通用服务端高频分配通用且可调优

16. 典型应用场景

16.1 网络服务器

高并发服务器每处理一个连接会分配连接对象、读写缓冲区、请求响应结构。使用内存池可以把这些高频对象的分配耗时降到常数级,并减少全局分配器锁竞争,提升每秒请求处理能力。例如事件循环框架中的 BufferRequest 都适合池化。

16.2 游戏引擎

游戏在每帧都会创建大量粒子、子弹、物理碰撞体、临时变换等对象。若直接使用 new/delete,频繁的系统分配会造成帧率抖动。按帧或按对象类型划分内存池,可以保证每帧内存操作平滑可控。

16.3 消息队列与数据库

消息处理链路中的消息头、序列化缓冲、行记录等对象具有较高生命周期一致性,非常适合内存池。数据库缓冲池则更接近页式伙伴系统与 slab 的组合,既管理页也管理页内槽位。

16.4 嵌入式与实时系统

嵌入式环境往往没有虚拟内存和复杂分配器,动态分配不可控可能导致内存耗尽。固定容量内存池让资源使用上限在编译期或启动期明确,便于满足实时性和可靠性要求。

17. 常见问题排查清单

  • 分配死循环:检查 grow 逻辑,确保新块确实被加入空闲链表,避免链表始终为空。
  • 越界覆盖链表指针:块尺寸小于对象实际写入尺寸时会覆盖相邻空闲块的头指针,必须保证块尺寸正确。
  • 重复释放导致链表环:释放同一指针两次会把节点同时挂在链表多处,形成环形结构,后续遍历无限循环。
  • 内存只增不减:线程缓存没有归还机制、中央堆没有块级追踪会导致长期内存占用过高。
  • 对齐崩溃:把块递交给需要 16 字节对齐的对象,实际地址只有 8 字节对齐时会触发异常,需统一对齐到 alignof(std::max_align_t)
  • 误用释放尺寸:分档池释放时传入错误尺寸会把对象还错池,引起跨尺寸污染。
  • 多线程数据竞争:忘记加锁或线程缓存结构未缓存行对齐,会造成数据竞争和性能骤降。

18. 完整测试用例建议

内存池上线前应当覆盖以下至少几类验证:

  1. 基本功能:分配 N 对象,全部释放后再次分配数量一致;分配返回地址互不重叠。
  2. 边界尺寸:分配 1 字节、指针大小、档位交界尺寸、大对象阈值附近尺寸。
  3. 压力测试:分配释放循环千万次,检查计数与统计一致性。
  4. 多线程测试:多个线程同时随机分配释放,使用 ThreadSanitizer 检测数据竞争。
  5. 对比基准:与 malloc 对比单线程和多线程吞吐量,记录缓存命中率。
  6. 调试版本:开启红区与填充模式,通过构造越界和重复释放用例验证检测能力。

19. 总结与设计建议

内存池的本质是把“每次分配都面对复杂通用逻辑”转变为“一次性准备 + 常数时间复用”。其核心权衡是性能、内存利用率与实现复杂度三者之间的平衡。不同场景下的最优设计差异很大,盲目移植工业级算法未必划算。

在实际设计时,建议遵循以下顺序:

  • 先量化分配瓶颈,确认是否存在优化的必要。
  • 若对象尺寸固定,优先选择定长 FreeList,简单而且极快。
  • 若尺寸多样,先用分档内存池覆盖小对象,大对象直接交给系统。
  • 若多线程是主要瓶颈,引入线程本地缓存与批量搬移。
  • 若需要控制物理内存和外部碎片,再考虑伙伴系统或页级管理。
  • 始终为调试版本保留统计、边界检测和生命周期追踪能力。

内存池不是灵丹妙药,但在高频分配、延迟敏感、资源受限的系统中,它往往是把性能曲线拉平的关键工具。理解从 FreeList、分档池、伙伴系统到多线程线程缓存的设计演进,能帮助开发者在面对具体业务时做出更准确的技术决策。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐