4. 固定大小内存池:设计思路、内存分配与释放逻辑、代码实现
好,咱们今天来聊聊固定大小内存池。这东西,说白了就是给特定大小的对象量身定做的“专属仓库”。
我在项目中遇到过好几次,业务逻辑里频繁 new/delete 某个固定大小的结构体,结果性能上不去,内存碎片还一堆。后来我意识到,与其让操作系统去处理这些零碎请求,不如我们自己管起来。这就是固定大小内存池的由来。
4.1 设计思路:为什么是“固定大小”?
你想想看,通用内存分配器(比如 malloc)要处理各种大小的请求,它得维护复杂的数据结构来管理空闲块。这就像一个大超市,什么商品都卖,管理成本自然高。
但如果我们只卖一种商品呢?比如只卖矿泉水。那货架就简单了,一个格子放一瓶,拿走了就标记为空。固定大小内存池就是这个道理。
它的核心思想是:预分配一大块连续内存,然后按固定大小切成若干小块。每个小块要么被使用,要么空闲。我们只需要一个简单的链表来串联所有空闲块。
核心优势:
- 分配/释放 O(1) 时间复杂度:没有复杂的内存搜索,直接从空闲链表头部取一个就行。
- 无内存碎片:所有块大小一致,不存在外部碎片。
- 高缓存友好性:连续的内存块,遍历时 CPU 缓存命中率高。
4.2 内存分配与释放逻辑:从“取”到“还”
嗯,这里要注意,分配和释放的逻辑其实非常对称,理解了一个,另一个就通了。
4.2.1 分配逻辑
- 检查空闲链表:如果链表不为空,取出头节点。
- 返回内存地址:将头节点的内存地址返回给调用者。
- 更新链表头:让链表头指向下一个空闲块。
就这么简单。如果链表为空,说明内存池用完了,这时候可以返回 nullptr,或者触发扩容机制(比如再申请一大块内存)。
4.2.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 图来展示整个分配与释放的流程,这样更直观。
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)。
- 注意对象构造/析构与内存分配/释放分离。
- 小心内存对齐和对象大小过小的问题。
可可精品资料库