欢迎光临
我们一直在努力

【内存管理与高并发内存池系列】从 malloc 到 ObjectPool:定长内存池的原理、对齐处理与空闲链表复用

🔥 本文专栏:内存管理与高并发内存池 🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录:你现在走得慢没关系,怕的是你明明还有火,却因为一时看不到结果就把自己熄灭了。

★★★ 本文前置知识: glibc内存分配机制


思维导图

在这里插入图片描述

引入

在此前的学习中,我们已经认识了 mmap 系统调用,以及 malloc 函数背后的内存分配与管理机制。

当程序需要在堆上申请内存时,通常会调用 malloc。malloc 本身是线程安全的,多个线程可以并发调用它申请和释放内存。但是,线程安全并不意味着没有额外成本。

结合前面对 malloc 执行路径的分析可以知道,如果线程申请的内存能够命中当前线程的 tcache,那么分配过程可以直接在线程局部缓存中完成,效率相对较高。但是,一旦 tcache 没有命中,例如对应大小的空闲链表为空,或者申请的内存大小不在 tcache 的管理范围内,那么 malloc 就可能需要进入对应 arena 的分配路径。

而 arena 是可能被多个线程共享的结构,因此访问 arena 时就可能涉及加锁。对于频繁调用 malloc 申请内存、又频繁调用 free 释放内存的程序来说,这部分锁开销和分配路径上的管理开销就可能被不断放大。

因此,内存池要解决的核心问题,并不是完全替代 malloc,而是针对特定场景减少频繁调用 malloc/free 带来的开销。

以定长内存池为例,它会预先向系统申请一片连续的内存空间,然后将这片内存按照固定大小切分成一个个内存块。后续程序需要申请内存时,不再频繁调用 malloc,而是直接从已经切分好的空闲内存块中取出一个;当内存释放时,也不会立即归还给系统,而是重新挂回内存池的空闲链表中,等待下一次复用。

这就是定长内存池的核心思想:提前申请一批内存,并通过空闲链表管理已经申请好的内存块,从而将后续频繁的申请和释放操作转化为对空闲链表的取出与归还,减少对 malloc/free 的依赖。

定长内存池原理图解:从 malloc 对比到空闲链表复用

定长内存池的设计思想:固定大小内存块与空闲链表复用

根据上文的分析,我们已经知道,定长内存池的核心思想是:先预先向操作系统申请一片连续的内存空间,然后由程序自己管理这片内存。这样一来,后续在高频申请和释放内存时,就不需要反复调用 malloc 和 free,从而减少通用内存分配路径中的锁竞争以及管理开销。

不过,在具体设计定长内存池之前,我们还需要先明确一点:定长内存池虽然可以借鉴 malloc 中“切块管理”和“空闲链表复用”的思想,但是它和 malloc 的设计目标并不相同。

malloc 是一个通用内存分配器,它需要处理各种不同大小的内存申请。也就是说,程序可能申请 16 字节,也可能申请 64 字节、128 字节,甚至更大的内存块。因此,malloc 内部需要按照不同规格来组织和管理内存块,并且每个 chunk 还需要维护对应的元数据,例如当前 chunk 的大小、状态信息等。之所以必须这样,是因为 free 只接收一个指针,分配器要能反查出这块的大小、以及前后块是否空闲可合并,这些信息没有别处可放,只能记录在 chunk 头部。

而定长内存池面向的场景更加具体。它通常并不是为了处理任意大小的内存申请,而是针对某一种固定大小对象的高频创建和销毁。例如,在我之前实现的 Epoll Server 项目中,主线程会不断接收客户端连接。每当一个 TCP 连接完成三次握手之后,程序就需要创建一个连接对象,用来保存该连接对应的上下文信息;当连接关闭之后,又需要释放对应的连接对象。

在这种高并发场景下,连接对象的申请和释放会非常频繁。但是这些对象的类型是固定的,大小也是固定的。也就是说,在连接建立和关闭这类高频执行的逻辑中,程序并不是反复申请各种不同大小的对象,而是反复申请和释放同一种连接对象。

因此,对于定长内存池来说,我们就不需要像 malloc 那样设计成一个通用的内存分配器,也不需要维护多种不同大小规格的内存块。我们只需要将预先申请到的连续内存,切分成多个大小相同的内存块即可。每一个内存块都可以用来存放一个固定类型的对象。

也正是因为每个内存块的大小都是固定的,所以定长内存池不需要像 malloc chunk 那样,在每个内存块前面额外维护 chunk 大小等元数据信息。内存池只需要在整体结构中记录单个内存块的大小、当前剩余空间的位置、空闲链表的头指针等管理信息即可。对于每一个已经分配出去的内存块来说,它本身就可以完全作为对象的存储空间使用。

接下来还需要考虑释放之后的内存块如何复用。由于对象的释放顺序和申请顺序并不一定一致,所以内存池中被释放的空闲块可能分布在不同位置,并不是连续排列的。针对这些不连续的空闲块,我们可以使用一个空闲链表将它们组织起来。

这里的空闲链表不需要额外创建链表节点,而是可以直接复用空闲块本身。因为一个内存块被释放之后,它原本保存的对象数据已经失效,这部分空间就可以被重新利用。于是,我们可以把空闲块头部的几个字节当作 next 指针,用来保存下一个空闲块的地址,从而将多个空闲块串联成一条链表。

这样一来,当程序再次申请内存块时,内存池会优先检查空闲链表。如果空闲链表不为空,就直接从链表头部取出一个空闲块进行复用;如果空闲链表为空,再从尚未切分的连续内存区域中划分出一个新的内存块。

所以,定长内存池的核心可以概括为:针对固定大小对象,提前申请一片连续内存,将其切分成多个相同大小的内存块,并通过空闲链表管理已经释放的内存块,从而实现内存块的快速复用。

示意图

在这里插入图片描述

定长对象池的代码实现:从内存布局到对象申请与释放

ObjectPool 代码骨架设计:内存块大小计算与对齐处理

根据上文的分析,我们已经认识了定长内存池的基本原理。接下来,就可以开始编写对应的代码。

在正式实现接口之前,我们先明确整体代码骨架。对于定长内存池来说,它的核心职责就是:提前申请一片连续内存,并将这片内存按照固定大小切分成多个内存块,后续再通过空闲链表对这些内存块进行管理和复用。

因此,这里我们可以设计一个 ObjectPool 模板类。之所以将其设计成模板类,是因为对象池本身应该具备一定的通用性。不同场景下,我们可能需要为不同类型的对象创建定长内存池,例如连接对象、任务对象、定时器节点等。通过模板参数 T,就可以指定当前对象池负责管理哪一种类型的对象。

接下来,需要在 ObjectPool 类中维护定长内存池的管理信息。

首先,需要有一个指针指向空闲链表的头节点,用来管理已经释放、可以再次复用的内存块。其次,还需要记录整片内存池的起始位置、当前尚未划分区域的起始位置,以及整片内存池的结束位置。这样一来,当空闲链表中没有可复用内存块时,我们就可以继续从尚未划分的连续内存区域中切出新的内存块。

除此之外,还需要记录当前对象池最多能够容纳多少个内存块。这里需要注意,构造函数接收的 poolSize 表示的是内存池中预先准备的内存块数量,而不是字节数。真正申请的总字节数应该是:

poolSize * slotSize

其中,slotSize 表示每一个内存块的实际大小。

对于构造函数来说,它会接收一个参数,用来表示当前对象池预先创建多少个固定大小的内存块。获取到这个参数之后,首先需要判断其合法性。如果传入的块数量为 0,那么这个对象池就没有实际意义,因此直接抛出异常。其次,为了避免一次性申请过大的内存空间,这里也可以设置一个上限,如果超过该上限,同样抛出异常。

完成参数检查之后,就需要向系统申请一片连续的虚拟内存。这里既可以调用 mmap 申请,也可以调用 malloc 申请。为了让实现更加简单,这里选择使用 malloc。malloc 返回的是这片连续内存的起始地址,拿到这个地址之后,就可以初始化 start、cur 和 end 等指针。

不过,在真正申请内存之前,还有一个非常关键的细节需要处理,那就是每一个内存块的大小,也就是 slotSize。

这里的块大小不能简单地等于 sizeof(T)。原因在于,一个内存块存在两种状态:当它被分配出去时,它用来存放 T 类型对象;当它被释放之后,它会被复用为空闲链表中的一个节点。此时,空闲块头部的几个字节会被用来保存下一个空闲块的地址。

也就是说,一个内存块既要能够容纳一个 T 类型对象,也要能够容纳一个 FreeNode 节点。

例如,在 64 位平台下,指针大小通常是 8 字节。如果某个对象本身只有 4 字节,那么如果直接将块大小设置为 sizeof(T),释放该对象之后,再把这个 4 字节空间复用成一个保存指针的空闲链表节点,就会发生越界访问。

因此,这里需要先取 sizeof(T) 和 sizeof(FreeNode) 中的较大值:

size_t rawSize = std::max(sizeof(T), sizeof(FreeNode));

这里除了要保证每个内存块的空间大小足够之外,还需要额外考虑内存对齐问题。

在 C++ 中,一个对象的起始地址需要满足该类型的对齐要求。也就是说,如果某个类型的对齐数是 alignof(T),那么该类型对象的起始地址就应该是 alignof(T) 的整数倍。这里需要注意,对齐要求并不等同于对象大小。对象大小由 sizeof(T) 表示,而对象的对齐要求由 alignof(T) 表示。

这里补充说明一下 alignof 运算符。alignof 是 C++ 中用来获取类型对齐要求的运算符,其语法形式为 alignof(T),表示 T 类型对象在内存中存放时,起始地址需要满足多少字节对齐。

需要注意的是,alignof(T) 和 sizeof(T) 表示的是两个不同概念。sizeof(T) 表示一个 T 类型对象本身占用多少字节,而 alignof(T) 表示这个对象的起始地址需要按照多少字节进行对齐。

例如,对于一个 int 类型来说,它的大小通常是 4 字节,对齐要求通常也是 4 字节。这意味着一个 int 对象最好放在 4 的整数倍地址上。如果我们只是从字节数组的角度看,某个地址后面确实有足够的空间可以存放一个 int,但是这个地址本身不满足 int 的对齐要求,那么在这个地址上构造或访问 int 对象就是不安全的。

从硬件角度来看,CPU 访问内存时通常更倾向于按照特定边界读取数据。比如对于一个 4 字节的数据来说,如果它的起始地址刚好是 4 的整数倍,那么这个数据就完整地落在一个符合对齐要求的地址范围内。CPU 在访问这个数据时,通常可以更加自然地一次性取出完整内容。

但是,如果一个 4 字节数据的起始地址不是 4 的整数倍,例如从某个偏移 1 字节的位置开始存放,那么这个数据就可能跨越原本的访问边界。此时,CPU 可能需要进行多次内存读取,再将读取到的结果重新拼接起来,才能得到完整的数据。这样不仅会增加额外的访问开销,也会降低整体访问效率。

因此,满足对齐要求的数据通常能够被 CPU 更高效地访问;而不满足对齐要求的数据,轻则导致额外的读取与拼接开销,重则在某些对齐要求严格的硬件平台上,可能直接触发硬件异常。

从 C++ 语言规则来看,如果在一块不满足 T 对齐要求的地址上构造 T 对象,那么这个行为本身就是未定义行为。因此,内存池在切分内存块时,不能只考虑每个块是否能够容纳一个对象,还必须保证每个块的起始地址满足对象的对齐要求。

所以,在计算每个内存块大小时,首先需要取 sizeof(T) 和 sizeof(FreeNode) 中的较大值,保证该内存块既能存放对象,也能在空闲状态下复用成空闲链表节点。接着,还需要取 alignof(T) 和 alignof(FreeNode) 中更严格的对齐要求,并将块大小向上调整到该对齐数的整数倍。

这样一来,只要整片内存池的起始地址满足对齐要求,并且每个内存块的大小也是对齐数的整数倍,那么后续通过 start + n * slotSize 切分出来的每一个内存块,其起始地址也都能够满足对齐要求。

所以,这里还需要取 alignof(T) 和 alignof(FreeNode) 中更严格的对齐要求:

size_t alignment = std::max(alignof(T), alignof(FreeNode));

最后,再将 rawSize 按照该对齐数进行向上对齐,得到最终的 slotSize:

slotSize = align_up(rawSize, alignment);

这里之所以需要将 rawSize 按照 alignment 向上对齐,是因为 rawSize 只能保证单个内存块的空间大小足够,却不能保证后续每一个内存块的起始地址都满足对齐要求。

在定长内存池中,整片连续内存会被按照固定大小切分成多个内存块。假设整片内存的起始地址为 start,每个内存块的大小为 slotSize,那么每个内存块的起始地址大致如下:

start
start + slotSize
start + 2 * slotSize
start + 3 * slotSize

也就是说,后续每一个内存块的起始地址,都是在 start 的基础上不断加上 slotSize 得到的。因此,即使 start 本身满足对齐要求,如果 slotSize 不是 alignment 的整数倍,那么后续切分出来的内存块起始地址就可能逐渐偏离对齐边界。

举个例子,假设某个类型对象本身占 12 字节,而空闲链表节点 FreeNode 的对齐要求是 8 字节,那么此时 rawSize 可能是 12,alignment 可能是 8。如果直接让 slotSize = rawSize,也就是每个块大小为 12 字节,那么即使第一个块的起始地址满足 8 字节对齐,第二个块的起始地址也会变成 start + 12,这个地址就不一定再是 8 的整数倍。

这样一来,后续某些内存块在被释放之后,如果要复用成 FreeNode 节点,就可能出现地址不满足 FreeNode 对齐要求的问题。同理,当这些块再次被用来构造 T 类型对象时,也可能不满足 T 类型的对齐要求。

因此,slotSize 不能直接等于 rawSize,而是需要将 rawSize 向上调整到 alignment 的整数倍:

slotSize = align_up(rawSize, alignment);

这样做的目的就是保证每个内存块的大小本身是对齐数的整数倍。只要整片内存的起始地址满足对齐要求,那么后续通过 start + n * slotSize 切分出来的每一个内存块,其起始地址也都能够继续满足对齐要求。

所以,rawSize 解决的是“当前内存块是否放得下”的问题,而 align_up 解决的是“连续切分之后,每一个内存块起始地址是否仍然对齐”的问题。两者结合起来,才能保证对象池中的每一个内存块既有足够空间,又能安全地存放 T 对象或复用成 FreeNode 节点。

为此,我们可以自己实现一个向上对齐函数 align_up,专门用于把某个大小调整到指定对齐数的整数倍:

static size_t align_up(size_t size, size_t alignment)
{
return (size + alignment 1) / alignment * alignment;
}

这个函数的含义是:如果 size 本身已经是 alignment 的整数倍,那么结果仍然是 size;如果不是,就将其向上调整到下一个 alignment 的整数倍。例如,align_up(12, 8) 的结果就是 16。

对应的代码骨架如下:

template<typename T>
class ObjectPool
{
private:
struct FreeNode
{
FreeNode* next;
};

public:
ObjectPool(size_t _poolSize)
: slotSize(0)
, start(nullptr)
, cur(nullptr)
, end(nullptr)
, poolsize(_poolSize)
, freeListHead(nullptr)
{
if (poolsize == 0)
{
throw std::invalid_argument("Pool size must be greater than 0");
}

if (poolsize > 1000000)
{
throw std::invalid_argument("Pool size is too large");
}

size_t rawSize = std::max(sizeof(T), sizeof(FreeNode));
size_t alignment = std::max(alignof(T), alignof(FreeNode));
slotSize = align_up(rawSize, alignment);

start = static_cast<char*>(std::malloc(poolsize * slotSize));
if (start == nullptr)
{
throw std::bad_alloc();
}

cur = start;
end = start + poolsize * slotSize;
}

// …
private:
static size_t align_up(size_t size, size_t alignment)
{
return (size + alignment 1) / alignment * alignment;
}

size_t slotSize;

char* start;
char* cur;
char* end;

size_t poolsize;
FreeNode* freeListHead;
};

在这段代码中,start 指向整片内存池的起始位置,end 指向整片内存池的结束位置,cur 指向当前尚未被划分区域的起始位置。后续当空闲链表为空时,就可以通过 cur 从剩余空间中继续切分出新的内存块。

而 freeListHead 则指向空闲链表的头节点。当有内存块被释放时,该内存块会被挂到空闲链表中;当再次申请内存块时,优先从空闲链表中取出一个已经释放的内存块进行复用。

因此,这个类的骨架实际上已经包含了定长内存池最核心的几个组成部分:固定大小的内存块、连续内存区域、当前切分位置以及空闲链表。

New 接口实现:空闲块复用与 placement new 构造对象

有了 ObjectPool 类的基本骨架之后,接下来就可以继续实现对象池对外提供的接口。

对于一个简单的定长对象池来说,核心接口其实并不复杂,主要就是两个:一个用于申请对象的 New 接口,另一个用于释放对象的 Delete 接口。这里我们先来看 New 接口的实现。

New 接口的职责并不是简单地返回一块原始内存,而是从对象池中取出一块固定大小的内存,并在这块内存上构造一个 T 类型对象,最终返回构造完成后的对象指针。

它的整体分配思路可以分为两步。

首先,优先检查空闲链表是否为空。如果空闲链表不为空,说明此前已经有对象被释放,对应的内存块已经被挂回到了空闲链表中。此时就可以直接取出空闲链表的头节点进行复用,并让 freeListHead 指向下一个空闲块。

如果空闲链表为空,说明当前没有已经释放的内存块可以复用。此时就需要从尚未切分的连续内存区域中继续划分出一个新的内存块。划分之前,需要先判断剩余空间是否足够。如果 cur + slotSize > end,说明当前对象池中既没有可以复用的空闲块,也没有足够的未划分空间,此时对象池已经无法继续分配,只能返回空指针。

如果剩余空间足够,就将当前 cur 指向的位置作为本次分配的内存块起始地址,然后让 cur 向后移动 slotSize,指向下一块尚未划分区域的起始位置。

拿到可用内存之后,还需要调用 placement new 在这块已有内存上构造 T 类型对象:

return new (memory) T();

这里需要注意,placement new 并不会重新向系统申请内存,它的作用是在指定的内存地址上调用对象的构造函数。也就是说,memory 指向的内存块已经由对象池提前准备好了,placement new 只是负责在这块内存上完成对象的构造。

对应代码如下:

T* New()
{
void* memory = nullptr;

if (freeListHead != nullptr)
{
memory = freeListHead;
freeListHead = freeListHead->next;
}
else
{
if (cur + slotSize > end)
{
return nullptr;
}

memory = cur;
cur += slotSize;
}

return new (memory) T();
}

当前这个版本的 New 接口调用的是 T 的默认构造函数,因此它要求 T 类型必须支持默认构造。如果某个对象需要通过带参构造函数完成初始化,那么可以进一步将 New 接口改造成可变模板参数版本,从而支持在对象池中直接构造带参数的对象。

不过对于当前这个入门版本来说,先实现默认构造版本即可。它已经能够清楚体现定长对象池的核心分配流程:优先复用空闲链表中的内存块;如果没有可复用块,再从尚未切分的连续内存区域中划分新块;最后通过 placement new 在该内存块上构造对象。

Delete 接口实现:显式析构与空闲链表回收

最后,我们再来看 Delete 接口的实现。

Delete 接口的作用并不是将内存真正归还给操作系统,而是将已经使用完的对象归还给对象池,等待后续再次复用。因为在 New 接口中,我们是通过 placement new 在指定内存块上构造了一个 T 类型对象,所以在归还这块内存之前,首先需要显式调用对象的析构函数,结束该对象的生命周期。

对应代码如下:

void Delete(T* obj)
{
if (obj == nullptr)
{
return;
}

obj->~T();

FreeNode* node = reinterpret_cast<FreeNode*>(obj);
node->next = freeListHead;
freeListHead = node;
}

首先,函数会判断传入的对象指针是否为空。如果 obj == nullptr,说明当前没有有效对象需要释放,直接返回即可。

如果对象指针有效,那么第一步就是调用对象的析构函数:

obj->~T();

这里需要注意,调用析构函数只是销毁 obj 指向的这个 T 类型对象,释放对象内部可能持有的资源,例如堆内存、文件描述符、缓冲区等。但是,obj 指向的这块原始内存本身并没有被释放,它仍然属于当前对象池管理。

当析构函数调用完成之后,这块内存中原本保存的对象数据就已经失效。此时,这块内存就可以重新被复用。由于空闲链表的节点结构非常简单,只需要保存下一个空闲块的地址,所以我们可以直接将这块内存重新解释成一个 FreeNode 节点:

FreeNode* node = reinterpret_cast<FreeNode*>(obj);

这里再补充一个细节:当我们说“复用空闲块头部的几个字节来保存下一个空闲块的地址”时,在代码层面通常有两种写法。

第一种写法,就是定义一个简单的结构体节点,例如:

struct FreeNode
{
FreeNode* next;
};

当某个对象被释放之后,这块内存中原本保存的对象数据已经失效,此时就可以将这块内存的起始地址强制转换为 FreeNode*:

FreeNode* node = reinterpret_cast<FreeNode*>(obj);
node->next = freeListHead;
freeListHead = node;

这样做的含义是:把这块空闲内存的起始位置,重新解释成一个 FreeNode 节点。由于 FreeNode 内部只有一个 next 指针,所以编译器会按照结构体成员的布局规则,将这块内存开头的若干字节解释为 next 成员。也就是说,空闲块头部的几个字节就被用来保存下一个空闲块的地址。

第二种写法则更加底层,可以直接使用 void** 来操作空闲块头部的指针空间。例如:

*(void**)obj = freeListHead;
freeListHead = reinterpret_cast<FreeNode*>(obj);

这里的核心是:我们希望在 obj 指向的这块内存开头写入一个地址,而地址本身就是一个指针值。因此,可以先把 obj 转换成 void**,也就是“指向指针的指针”即二级指针,然后再通过解引用操作 *(void**)obj,把空闲块头部的 sizeof(void*) 个字节当作一个指针变量来使用。

换句话说:

(void**)obj

表示将 obj 这个地址强制转换为 void** 类型,也就是把它看作一个二级指针。其含义是:把 obj 指向的这块内存的起始位置,当成一个“可以存放指针值的位置”。

*(void**)obj = freeListHead;

表示向这块内存的头部写入下一个空闲块的地址。

这两种写法的本质是一样的,都是复用空闲块自身的前几个字节来保存 next 指针,从而把多个空闲块串联成一条空闲链表。相比之下,使用 FreeNode 结构体的写法语义更加清晰,也更适合放在博客和代码示例中;而 void** 写法更加底层,能更直观地体现“直接把空闲块头部当作指针槽位使用”的思想。

无论采用哪一种写法,都必须保证每个内存块至少能够容纳一个指针,并且内存块的起始地址满足指针类型的对齐要求。否则,当空闲块被复用成链表节点时,就可能出现越界访问或未对齐访问的问题。

接着,通过头插法将该节点挂入空闲链表中:

node->next = freeListHead;
freeListHead = node;

这样一来,这个已经释放的内存块就重新回到了对象池的空闲链表中。后续再次调用 New 接口申请对象时,就可以优先从空闲链表中取出该内存块进行复用,而不需要重新向系统申请内存。

因此,Delete 接口的核心流程可以概括为:先显式调用析构函数销毁对象,再将对象占用的内存块复用成空闲链表节点,并通过头插法归还给对象池。

需要注意的是,当前实现默认传入的 obj 一定来自当前对象池。如果传入一个不是由该对象池分配的指针,或者对同一个对象重复调用 Delete,都可能破坏空闲链表结构,导致未定义行为。

源码

ObjectPool.hpp:

#pragma once

#include <cstdlib>
#include <stdexcept>
#include <new>
#include <algorithm>

template<typename T>
class ObjectPool
{
private:
struct FreeNode
{
FreeNode* next;
};

public:
/**
* 对象池的构造函数,用于初始化对象池
* _poolSize 指定对象池的大小(对象数量)
*/

ObjectPool(size_t _poolSize)
: slotSize(0) // 初始化每个槽位的大小为0
, start(nullptr) // 初始化内存起始指针为nullptr
, cur(nullptr) // 初始化当前指针为nullptr
, end(nullptr) // 初始化结束指针为nullptr
, poolsize(_poolSize) // 初始化对象池大小
, freeListHead(nullptr) // 初始化空闲链表头为nullptr
{
// 检查对象池大小是否合法
if (poolsize == 0)
{
throw std::invalid_argument("Pool size must be greater than 0");
}

// 检查对象池大小是否过大
if (poolsize > 1000000)
{
throw std::invalid_argument("Pool size is too large");
}

// 计算每个槽位的大小和对齐要求
size_t rawSize = std::max(sizeof(T), sizeof(FreeNode)); // 获取T类型和FreeNode类型中较大的大小
size_t alignment = std::max(alignof(T), alignof(FreeNode)); // 获取T类型和FreeNode类型中较大的对齐要求
slotSize = align_up(rawSize, alignment); // 对齐后的槽位大小

// 分配内存
start = static_cast<char*>(std::malloc(poolsize * slotSize)); // 分配连续内存块
if (start == nullptr)
{
throw std::bad_alloc(); // 内存分配失败时抛出异常
}

// 初始化指针
cur = start; // 当前指针指向内存起始位置
end = start + poolsize * slotSize; // 结束指针指向内存末尾
}

// 析构函数,用于释放对象池分配的内存
~ObjectPool()
{
// 释放通过start指针分配的内存块
std::free(start);
}

ObjectPool(const ObjectPool&) = delete;
ObjectPool& operator=(const ObjectPool&) = delete;

/**
* 分配内存并构造新对象的函数
*
* return T* 返回指向新构造对象的指针,如果内存不足则返回nullptr
*/

T* New()
{
void* memory = nullptr; // 用于存储分配的内存地址

// 检查空闲列表是否有可用节点
if (freeListHead != nullptr)
{
// 从空闲列表中获取内存块
memory = freeListHead;
freeListHead = freeListHead->next; // 更新空闲列表头指针
}
else
{
// 检查当前内存池是否有足够空间
if (cur + slotSize > end)
{
return nullptr; // 内存不足,返回nullptr
}

// 从内存池中分配新内存块
memory = cur;
cur += slotSize; // 更新当前指针
}

// 在分配的内存上构造新对象
return new (memory) T();
}

/**
* 回收对象并将其内存返回给内存池的空闲链表
* obj 指向待删除对象的指针
*
* 注意:此函数仅调用析构函数清理对象资源,并将内存交还给内存池,
* 并未真正将内存归还给操作系统(即没有调用系统的 free/delete)。
*/

void Delete(T* obj)
{
// 安全检查:如果传入的指针为空,直接返回,避免对空指针解引用引发崩溃
if (obj == nullptr)
{
return;
}

// 1. 显式调用对象的析构函数
// 这样做可以正确清理对象内部管理的资源(如动态内存、文件句柄等),
// 但不会释放对象自身占用的内存块(即不调用 operator delete)。
obj->~T();

// 2. 将对象占用的内存块重新纳入内存池的空闲链表
// 使用 reinterpret_cast 将对象指针强转为 FreeNode*。
// 这里利用了“复用内存块”的技巧:对象被析构后,其占用的内存空间
// 在逻辑上已经变成了一块裸内存,直接将这块内存的前几个字节
// 当作 FreeNode 的 next 指针来使用,从而避免了额外的内存分配开销。
FreeNode* node = reinterpret_cast<FreeNode*>(obj);

// 3. 头插法:将当前空闲节点插入到空闲链表的头部
// 将新回收的节点的 next 指向原来的链表头
node->next = freeListHead;

// 更新链表头指针,使其指向刚刚回收的节点
freeListHead = node;
}

private:
static size_t align_up(size_t size, size_t alignment)
{
return (size + alignment 1) / alignment * alignment;
}

private:
size_t slotSize;

char* start;
char* cur;
char* end;

size_t poolsize;
FreeNode* freeListHead;
};

在这里插入图片描述

结语

那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力! 在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » 【内存管理与高并发内存池系列】从 malloc 到 ObjectPool:定长内存池的原理、对齐处理与空闲链表复用
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址