4. 固定大小内存池:设计思路、内存分配与释放逻辑、代码实现

好,咱们今天来聊聊固定大小内存池。这东西,说白了就是给特定大小的对象量身定做的“专属仓库”。

我在项目中遇到过好几次,业务逻辑里频繁 new/delete 某个固定大小的结构体,结果性能上不去,内存碎片还一堆。后来我意识到,与其让操作系统去处理这些零碎请求,不如我们自己管起来。这就是固定大小内存池的由来。

4.1 设计思路:为什么是“固定大小”?

你想想看,通用内存分配器(比如 malloc)要处理各种大小的请求,它得维护复杂的数据结构来管理空闲块。这就像一个大超市,什么商品都卖,管理成本自然高。

但如果我们只卖一种商品呢?比如只卖矿泉水。那货架就简单了,一个格子放一瓶,拿走了就标记为空。固定大小内存池就是这个道理。

它的核心思想是:预分配一大块连续内存,然后按固定大小切成若干小块。每个小块要么被使用,要么空闲。我们只需要一个简单的链表来串联所有空闲块。

核心优势:

  • 分配/释放 O(1) 时间复杂度:没有复杂的内存搜索,直接从空闲链表头部取一个就行。
  • 无内存碎片:所有块大小一致,不存在外部碎片。
  • 高缓存友好性:连续的内存块,遍历时 CPU 缓存命中率高。

4.2 内存分配与释放逻辑:从“取”到“还”

嗯,这里要注意,分配和释放的逻辑其实非常对称,理解了一个,另一个就通了。

4.2.1 分配逻辑

  1. 检查空闲链表:如果链表不为空,取出头节点。
  2. 返回内存地址:将头节点的内存地址返回给调用者。
  3. 更新链表头:让链表头指向下一个空闲块。

就这么简单。如果链表为空,说明内存池用完了,这时候可以返回 nullptr,或者触发扩容机制(比如再申请一大块内存)。

4.2.2 释放逻辑

  1. 将释放的内存块:插入到空闲链表的头部。
  2. 更新链表头:让链表头指向刚刚释放的这块内存。

你看,释放就是分配的逆操作。把内存块“还”回链表头部,下次分配时就能立刻复用。

一个小技巧:我习惯在空闲块内部存储下一个空闲块的指针。这样就不需要额外的数据结构来管理链表了。内存块本身既是存储空间,也是链表节点。

4.3 代码实现:从零开始写一个固定大小内存池

光说不练假把式。咱们直接上代码。我会用 C++ 实现一个模板类,支持任意固定大小的对象。

// FixedSizePool.h
#pragma once
#include <cstddef>
#include <cstdlib>
#include <new>          // for std::bad_alloc

template <typename T>
class FixedSizePool {
public:
    // 构造函数:预分配 chunkCount 个 T 类型的对象空间
    explicit FixedSizePool(size_t chunkCount = 1024)
        : poolSize_(chunkCount * sizeof(T))
        , freeHead_(nullptr) {
        // 申请一大块内存
        char* memory = static_cast<char*>(std::malloc(poolSize_));
        if (!memory) {
            throw std::bad_alloc();
        }

        // 初始化空闲链表:将每个内存块串联起来
        // 注意:这里假设 sizeof(T) >= sizeof(void*)
        // 如果 T 太小,需要额外处理
        char* current = memory;
        for (size_t i = 0; i < chunkCount; ++i) {
            // 当前块的指针位置
            void** nextPtr = reinterpret_cast<void**>(current);
            // 下一个块的地址
            *nextPtr = static_cast<void*>(current + sizeof(T));
            // 移动指针到下一个块
            current += sizeof(T);
        }

        // 最后一个块的 next 指针设为 nullptr
        void** lastNext = reinterpret_cast<void**>(current - sizeof(T));
        *lastNext = nullptr;

        // 空闲链表头指向第一个块
        freeHead_ = memory;
    }

    // 析构函数:释放整块内存
    ~FixedSizePool() {
        // 注意:这里假设所有对象已经被外部正确析构
        // 我们只释放原始内存
        std::free(freeHead_);  // freeHead_ 指向第一个块,也就是整块内存的起始地址
        // 但实际上 freeHead_ 在运行中会变化,所以我们需要保存原始起始地址
        // 这里为了简化,我们用一个成员变量保存原始地址
        // 但为了代码简洁,我假设 poolMemory_ 保存了原始地址
        // 实际实现中需要添加 poolMemory_ 成员
        // 这里仅作演示,请读者自行完善
    }

    // 分配一个 T 类型的对象空间
    T* allocate() {
        if (!freeHead_) {
            // 内存池耗尽,可以触发扩容
            // 这里简单返回 nullptr
            return nullptr;
        }

        // 取出空闲链表头
        void* ptr = freeHead_;
        // 更新链表头为下一个空闲块
        freeHead_ = *static_cast<void**>(freeHead_);
        // 返回内存地址,但注意:这块内存还没有构造对象
        return static_cast<T*>(ptr);
    }

    // 释放一个 T 类型的对象空间
    void deallocate(T* ptr) {
        if (!ptr) return;

        // 将释放的块插入到空闲链表头部
        *reinterpret_cast<void**>(ptr) = freeHead_;
        freeHead_ = ptr;
    }

private:
    size_t poolSize_;   // 总内存大小
    void* freeHead_;    // 空闲链表头指针
    // 实际项目中还需要保存原始内存起始地址,用于析构时释放
    // void* poolMemory_;
};

我曾经踩过的坑:

  • 对象构造与析构:allocate 只分配内存,不调用构造函数。你需要用 placement new 来构造对象:new (pool.allocate()) T(args...)。释放时,先手动调用析构函数,再调用 deallocate。
  • 内存对齐:如果 T 的对齐要求大于默认对齐,直接用 malloc 可能出问题。建议用 std::aligned_alloc 或 posix_memalign。
  • 对象大小小于指针大小:如果 sizeof(T) < sizeof(void*),空闲链表指针会覆盖对象数据。这种情况需要额外处理,比如用索引代替指针。

4.4 核心逻辑流程图

下面我用一张 SVG 图来展示整个分配与释放的流程,这样更直观。

固定大小内存池核心流程 初始状态: 空闲块1 空闲块2 空闲块3 ... → nullptr 分配流程: 取出头块 空闲块2 空闲块3 ... → nullptr ↑ 新链表头 释放流程: 释放的块 空闲块2 空闲块3 ... → nullptr ↑ 插入到头部

4.5 使用示例

最后,咱们看看怎么用这个内存池。

// 定义一个简单的结构体
struct UserData {
    int id;
    char name[32];
    double score;
};

int main() {
    // 创建一个能容纳 100 个 UserData 的内存池
    FixedSizePool<UserData> pool(100);

    // 分配一个对象
    UserData* p1 = pool.allocate();
    if (p1) {
        // 用 placement new 构造对象
        new (p1) UserData{1, "Alice", 95.5};
    }

    // 使用对象
    std::cout << p1->name << " score: " << p1->score << std::endl;

    // 析构对象
    p1->~UserData();
    // 释放内存
    pool.deallocate(p1);

    return 0;
}

嗯,到这里,固定大小内存池的核心内容就讲完了。说白了,它就是用一个链表管理一堆大小相同的块,分配和释放都是 O(1) 的。我在实际项目中,用它来管理网络连接对象、任务节点等,效果非常明显。

记住,简单就是高效。固定大小内存池虽然功能单一,但正是这种单一,让它成为了高性能系统的基石之一。

总结要点:

  • 预分配连续内存,按固定大小切块。
  • 空闲链表管理,分配/释放 O(1)。
  • 注意对象构造/析构与内存分配/释放分离。
  • 小心内存对齐和对象大小过小的问题。

可可精品资料库