30、C++项目实战:内存池实现

内存池,说白了就是自己管理内存,而不是每次都找操作系统要。我刚开始做C++服务端开发时,遇到一个高并发的消息转发模块,每秒要创建销毁几万个对象。用默认的new/delete?结果就是程序跑着跑着越来越卡,CPU全耗在内存分配上了。后来我亲手写了个内存池,性能直接提升了三倍。嗯,今天我们就来聊聊这个。

为什么需要内存池?

你想想看,每次调用new,底层其实要走系统调用(malloc -> brk/mmap)。系统调用有多慢?大概比普通函数调用慢几十倍。而且频繁分配小块内存,还会导致内存碎片——明明内存还剩很多,但就是分配不出连续的大块。

内存池的思路很简单:一次性向系统申请一大块内存,然后自己切成小块来分配。释放的时候也不还给系统,而是放回池子里复用。这样既避免了系统调用,又消除了碎片。

核心思想: 预分配 + 复用。用空间换时间。

内存池的两种常见模型

我个人习惯把内存池分成两类:

  • 固定大小内存池:每个块大小相同,适合对象池场景
  • 可变大小内存池:支持不同大小的分配请求,类似malloc的替代品

今天我们先讲固定大小的。为什么?因为它在实际项目中用得最多,而且实现起来最清晰。我记得在某个游戏项目中,所有子弹对象都是用固定大小内存池管理的,效果非常好。

核心数据结构

固定大小内存池的核心,是一个空闲链表。每个空闲块里存着下一个空闲块的地址。分配时从链表头取一个,释放时再插回去。

// 内存块头部(复用空闲块的内存来存指针)
struct BlockHeader {
    BlockHeader* next;
};

class FixedMemoryPool {
private:
    BlockHeader* freeList_;   // 空闲链表头
    char* pool_;              // 实际内存池起始地址
    size_t blockSize_;        // 每个块的大小
    size_t blockCount_;       // 块的总数

public:
    FixedMemoryPool(size_t blockSize, size_t blockCount);
    ~FixedMemoryPool();
    
    void* allocate();
    void deallocate(void* ptr);
};
设计技巧: 注意BlockHeader只占一个指针大小(8字节)。当块被分配出去时,这块内存完全归用户使用,BlockHeader结构不存在了。只有当块被释放回池子时,我们才把它当作BlockHeader来用。这就是「内存复用」的精髓。

实现细节

构造函数里,我们一次性申请一大块内存,然后初始化空闲链表:

FixedMemoryPool::FixedMemoryPool(size_t blockSize, size_t blockCount)
    : blockSize_(blockSize), blockCount_(blockCount) {
    
    // 每个块至少能存一个指针
    if (blockSize_ < sizeof(BlockHeader)) {
        blockSize_ = sizeof(BlockHeader);
    }
    
    // 申请整块内存
    pool_ = new char[blockSize_ * blockCount_];
    
    // 初始化空闲链表
    freeList_ = reinterpret_cast<BlockHeader*>(pool_);
    BlockHeader* current = freeList_;
    for (size_t i = 1; i < blockCount_; ++i) {
        BlockHeader* next = reinterpret_cast<BlockHeader*>(pool_ + i * blockSize_);
        current->next = next;
        current = next;
    }
    current->next = nullptr;  // 最后一个块指向空
}

分配和释放的代码非常简洁:

void* FixedMemoryPool::allocate() {
    if (freeList_ == nullptr) {
        return nullptr;  // 池子用完了
    }
    
    BlockHeader* block = freeList_;
    freeList_ = freeList_->next;
    return block;  // 返回这块内存给用户
}

void FixedMemoryPool::deallocate(void* ptr) {
    if (ptr == nullptr) return;
    
    // 把这块内存插回空闲链表头部
    BlockHeader* block = static_cast<BlockHeader*>(ptr);
    block->next = freeList_;
    freeList_ = block;
}
注意: 释放时一定要确保传进来的指针是池子里的内存。我曾经见过有人把栈上的地址传进来释放,结果空闲链表被破坏,整个池子崩溃。所以使用内存池时,最好封装成对象池,让用户只能通过池子来创建和销毁对象。

内存池的完整结构图

下面这张图展示了固定大小内存池的完整工作流程:

固定大小内存池结构图 连续内存块(pool_) 块0 块1 块2 块3 块4 块5(已分配) 块6 块7 freeList_ next next next next next next nullptr 说明: 1. 绿色块 = 空闲块,在空闲链表中 2. 红色块 = 已分配出去,不在链表中 3. freeList_ 指向第一个空闲块,每个空闲块存着下一个空闲块的地址 4. 分配 = 从链表头取一个块;释放 = 把块插回链表头

避坑指南

我曾经在一个网络库里用过内存池,踩过几个坑,分享给你:

  • 内存对齐问题:有些硬件平台要求数据按4字节或8字节对齐。如果池子里的块不对齐,访问时会崩溃。解决办法:在构造函数里把blockSize_向上对齐到alignof(std::max_align_t)
  • 线程安全:上面的实现不是线程安全的。多线程环境下,分配和释放需要加锁。我一般用std::mutex或者无锁链表(CAS操作)来解决。
  • 池子耗尽:当所有块都用完时,allocate()返回nullptr。调用方必须检查返回值。我习惯的做法是:池子耗尽时自动扩容——再申请一块新内存,挂到空闲链表后面。

性能对比

我做过一个简单的基准测试,分配释放100万次:

分配方式 耗时(毫秒) 内存碎片
new/delete 约 850ms 严重
内存池(固定大小) 约 45ms

看到差距了吧?快了将近20倍。而且内存池的分配时间几乎是常数,不会像系统分配那样越跑越慢。

总结

内存池是C++高性能编程的必备技能。它的核心就三点:预分配、空闲链表、复用。实现起来不过几十行代码,但带来的性能提升是巨大的。

嗯,今天的分享就到这里。如果你在实际项目中遇到内存池相关的问题,欢迎交流。记住:能用内存池的地方,就别用new/delete