一、内存池简介
内存池,简单来说,就是一种内存管理技术。它在程序运行前预先分配一块较大的内存空间,当程序需要申请内存时,直接从这个内存池中获取,而不是每次都向操作系统申请;当程序释放内存时,也不是直接归还给操作系统,而是放回内存池,供后续使用。这种方式避免了频繁地向操作系统申请和释放内存带来的开销(用户态和内核态切换带来的性能损失),大大提高了内存分配和释放的效率。
打个比方,内存池就像是一个仓库,程序需要的内存就是仓库里的货物。以往,每次只要需要货物都要去遥远的供应商(操作系统)那里采购,采购流程繁琐且耗时;有了仓库(内存池)后,可以直接从供应商批发大量货物存在仓库,当需要货物时直接从仓库拿取,用完再放回仓库,方便又快捷。
同时,内存池在减少内存碎片方面也有着重要作用。在传统的内存分配方式中,比如频繁调用malloc和free函数,由于每次分配和释放的内存大小不一致,会导致内存空间被分割成许多不连续的小块(差生外碎片)。随着程序的运行,这些小块内存越来越多,就形成了内存碎片。当需要分配较大内存块时,这些零散的内存碎片可能无法满足需求,即使总的空闲内存足够,也会因为内存不连续而导致分配失败。
而内存池通过预先分配和集中管理内存,能有效减少这种情况的发生。内存池会按照一定的策略来分配和回收内存,比如将大的内存块按照固定大小进行划分,当程序申请内存时,直接分配合适的小块内存;释放时,再将这些小块内存有序地回收。这样一来,内存始终处于相对有序的管理状态,大大降低了内存碎片产生的概率,提高了内存的整体利用率 。
内存碎片分为外碎片和内碎片:
1.外碎片(External Fragmentation):外碎片是指在内存分配过程中,由于内存块的分配和释放操作,导致内存空间被分割成许多不连续的小块。这些小块内存虽然总体上有足够的空闲空间,但因为它们是分散的,无法满足较大内存块的分配需求,从而造成内存资源的浪费。例如,在使用动态内存分配函数(如malloc和free)时,反复地分配和释放不同大小的内存块,就容易产生外碎片。假设系统初始时有一块连续的 100KB 内存空间,先分配了 30KB、20KB 和 10KB 的内存块,然后释放了 20KB 的内存块,此时虽然有 20KB 的空闲内存,但它被夹在两个已分配的内存块中间,无法满足大于 20KB 的内存分配请求,这就是外碎片。
2.内碎片(Internal Fragmentation):内碎片是指在已分配的内存块内部,由于分配的内存块大小与实际需求不匹配,导致部分内存空间未被充分利用。例如,在固定大小内存块的分配机制中,若申请的内存块大小固定为 64 字节,而实际只需要 10 字节的数据存储,那么剩余的 54 字节就形成了内碎片。在一些操作系统的内存管理机制中,页式存储管理会产生内碎片,因为页的大小是固定的,程序使用的内存不一定刚好是页大小的整数倍,就会导致页内部分空间浪费。
内存池与碎片的关系:
内存池主要减少的是外碎片 。在传统的内存分配模式下,频繁地申请和释放内存会使内存空间逐渐碎片化,就像上述例子中,内存被分割得七零八落,难以满足大内存块的分配。而内存池通过预先分配一块较大的内存空间,并采用特定的分配和回收策略,避免了这种内存空间被过度分割的情况。
当程序从内存池中获取内存时,内存池按照自身的管理算法分配合适的内存块,释放时再将其回收,使得内存空间始终处于一种相对有序的管理状态。这就如同把内存当作一个整齐摆放物品的仓库,需要时按照规则取用,用完放回原位,避免了仓库被弄得杂乱无章,减少了外碎片的产生。不过,内存池在一定程度上也会对内碎片产生影响。如果内存池采用固定大小内存块的分配策略,就可能出现内碎片问题。比如内存池只能提供最小为 8 字节的内存块,而程序有时只需 4 字节,就会造成 4 字节的内碎片。
总体而言,内存池设计的重点和主要优势还是在于减少外碎片,提升内存的整体利用率和分配效率。
二、三级缓冲结构解析
我们即将探讨的内存池采用了三级缓冲结构,这种设计结合了不同粒度内存管理的优势,能够更灵活、高效地应对各种内存需求场景。
(一)线程私有缓冲层
这是三级缓冲结构中的第一层,每个线程都拥有自己独立的私有缓冲。当线程需要分配内存时,优先从这里获取。这一层的设计基于线程局部性原理,即一个线程在一段时间内访问的数据往往具有时间和空间上的局部性。由于每个线程独立使用自己的缓冲,避免了多线程环境下频繁的锁竞争,大大提升了内存分配的速度。
例如,在一个多线程的网络服务器程序中,每个线程负责处理一个客户端连接。每个线程都有自己的私有缓冲,在处理客户端请求时,从这个私有缓冲中快速获取所需内存,无需等待其他线程释放资源,提高了服务器的并发处理能力。
(二)线程共享缓冲层
如果线程私有缓冲中没有足够的内存来满足分配需求,就会从线程共享缓冲层获取。这一层的缓冲由多个线程共享,其容量比线程私有缓冲大。它起到了一个中间过渡的作用,减少了对全局缓冲层的直接访问,进一步降低了锁竞争的概率。
想象一下,多个线程就像不同的工人,每个工人都有自己的小工具箱(线程私有缓冲),当小工具箱里的工具不够用时,他们会先去车间的共享工具架(线程共享缓冲)寻找,如果共享工具架也没有,才会去更大的仓库(全局缓冲层)。这样的设计让大部分工具获取操作都在局部范围内完成,提高了工作效率。
(三)全局缓冲层
全局缓冲层是内存池的最后一道防线,也是最大的内存储备区域,全局缓冲层直接向操作系统申请大块内存。当线程共享缓冲层也无法满足内存分配需求时,才会从全局缓冲层分配内存。同时,当线程释放内存时,如果线程私有缓冲已满,会逐级向上将内存放回线程共享缓冲层或全局缓冲层。
全局缓冲层类似于一个大型的中央仓库,负责统筹管理所有的内存资源。虽然访问全局缓冲层可能会涉及到锁操作,带来一定的性能损耗,但由于前两级缓冲的过滤,实际访问全局缓冲层的频率较低,从而在整体上保证了内存池的高效运行。
三、手撕三级缓冲内存池
代码来自:https://github.com/youngyangyang04/memory-pool
(一)全局缓冲区
单例模式,PageCache
是页缓存,负责从系统分配和回收大块内存,以页为单位进行管理。
class PageCache
{
public:
static const size_t PAGE_SIZE = 4096;
static PageCache& getInstance()
{
static PageCache instance;
return instance;
}
void* allocateSpan(size_t numPages);
void deallocateSpan(void* ptr, size_t numPages);
private:
PageCache() = default;
void* systemAlloc(size_t numPages);
private:
struct Span
{
void* pageAddr; // 页起始地址
size_t numPages; // 页数
Span* next; // 链表指针
};
// 按页数管理空闲span,不同页数对应不同Span链表
std::map<size_t, Span*> freeSpans_;
// 页号到span的映射,用于回收
std::map<void*, Span*> spanMap_;
std::mutex mutex_;
};
对于所有空闲Span,经同页数的Span串成链表,并用freeSpans_管理
spanMap_提供指针到Span的映射,只要是被使用过的Span(正在使用或试用结束进入空闲状态的)都会保存。
allocateSpan
方法:- 使用互斥锁保护,优先从freelist中查找合适大小(页数不低于申请大小)的空闲
Span
。 - 如果找到合适的
Span
,将其从空闲链表中移除,并根据需要进行分割(如果找到的Span大于申请页数,则把Span进行分割,多余部分放回freeSpans_)。 - 如果没有合适的
Span
,则调用systemAlloc
从系统申请内存。 - 记录
Span
信息用于回收。
- 使用互斥锁保护,优先从freelist中查找合适大小(页数不低于申请大小)的空闲
deallocateSpan
方法:- 使用互斥锁保护,查找对应的
Span
。 - 尝试合并相邻的
Span(通过地址查找以带回收Span内存连续的Span,检查其是否空闲,若空闲则合并成更大页数的Span)
。 - 将合并后的
Span
通过头插法插入空闲列表。
- 使用互斥锁保护,查找对应的
systemAlloc
方法:- 使用
mmap
从系统分配内存,并清零内存
- 使用
(二)线程共享缓冲区
CentralCache
是中心缓存,用于协调各个线程的内存请求,当线程本地缓存不足时,从中心缓存获取内存。
class CentralCache
{
public:
static CentralCache& getInstance()
{
static CentralCache instance;
return instance;
}
void* fetchRange(size_t index);
void returnRange(void* start, size_t size, size_t bytes);
private:
// 相互是还所有原子指针为nullptr
CentralCache()
{
for (auto& ptr : centralFreeList_)
{
ptr.store(nullptr, std::memory_order_relaxed);
}
// 初始化所有锁
for (auto& lock : locks_)
{
lock.clear();
}
}
// 从页缓存获取内存
void* fetchFromPageCache(size_t size);
private:
// 中心缓存的自由链表
std::array<std::atomic<void*>, FREE_LIST_SIZE> centralFreeList_;
// 用于同步的自旋锁
std::array<std::atomic_flag, FREE_LIST_SIZE> locks_;
};
fetchRange
方法:- 使用自旋锁保护,避免多线程竞争。
- 尝试从中心缓存的自由链表中获取内存块。
- 如果中心缓存为空,则调用
fetchFromPageCache
从页缓存获取新的内存块。 - 将获取的内存块切分成小块,并更新中心缓存的自由链表。
returnRange
方法:- 使用自旋锁保护,将归还的内存链表连接到中心缓存的链表头部。
fetchFromPageCache
方法:- 根据请求的内存大小计算需要的页数。
- 小于等于 32KB 的请求,使用固定 8 页;大于 32KB 的请求,按实际需求分配
(三)线程私有缓冲区
ThreadCache
是线程本地缓存,每个线程都有自己的 ThreadCache
实例,用于快速分配和释放小内存块,减少锁竞争
class ThreadCache
{
public:
static ThreadCache* getInstance()
{
static thread_local ThreadCache instance;
return &instance;
}
void* allocate(size_t size);
void deallocate(void* ptr, size_t size);
private:
ThreadCache()
{
// 初始化自由链表和大小统计
freeList_.fill(nullptr);
freeListSize_.fill(0);
}
// 从中心缓存获取内存
void* fetchFromCentralCache(size_t index);
// 归还内存到中心缓存
void returnToCentralCache(void* start, size_t size);
bool shouldReturnToCentralCache(size_t index);
private:
// 每个线程的自由链表数组
std::array<void*, FREE_LIST_SIZE> freeList_;
std::array<size_t, FREE_LIST_SIZE> freeListSize_; // 自由链表大小统计
};
allocate
方法:- 处理 0 大小的分配请求,将其调整为最小对齐大小。
- 如果请求的内存大小超过
MAX_BYTES
,则直接使用malloc
从系统分配。 - 计算对应的自由链表索引,尝试从本地自由链表中分配内存。
- 如果本地自由链表为空,则调用
fetchFromCentralCache
从中心缓存获取一批内存。
deallocate
方法:- 如果释放的内存大小超过
MAX_BYTES
,则直接使用free
归还给系统。 - 计算对应的自由链表索引,将内存块插入到本地自由链表中。
- 检查是否需要将部分内存归还给中心缓存,如果需要则调用
returnToCentralCache
。
- 如果释放的内存大小超过
fetchFromCentralCache
方法:- 从中心缓存批量获取内存。
- 取一个内存块返回,其余放入本地自由链表。
returnToCentralCache
方法:- 计算要归还的内存块数量,保留一部分在本地缓存中。
- 将剩余部分归还给中心缓存
(四)详解*reinterpret_cast<void**>(ptr)
在当前的内存池源码中,*reinterpret_cast<void**>(ptr)
这类操作频繁出现,它在内存管理的实现里起着关键作用,主要用于指针类型的转换,以实现对内存块链表的操作。
假设存在一个指针 ptr
,它的值为 0x7f
,这意味着 ptr
指向内存地址为 0x7f
的位置。当对 ptr
进行解引用操作(即 *ptr
)时,修改的正是地址 0x7f
处存储的值。
而 reinterpret_cast<void**>(ptr)
是将 ptr
强制转换为二级指针类型,也就是指向指针的指针。这种类型的指针,其指向的内存空间专门用于存储指针类型的数据。需要明确的是,在整个过程中,ptr
本身存储的值始终保持为 0x7f
不变,所以 reinterpret_cast<void**>(ptr)
指向的内存块依然是地址为 0x7f
的这块区域。只不过,经过类型转换后,这块内存原本存储的数据类型从 void
型变为了 void*
型(即指针类型)。
当执行 *reinterpret_cast<void**>(ptr) = next_ptr;
这条语句时,实际上是将 next_ptr
这个指针值存储到了地址为 0x7f
的内存块中。这里的 next_ptr
是一个指向其他内存块的指针,如此一来,地址 0x7f
这块内存保存的内容就变成了 void*
类型的数据 next_ptr
。
需要注意的是,在实际的 64 位系统中,0x7f
这样小的地址值通常不会直接用于普通的用户程序内存访问,因为系统会为不同的内存区域分配特定范围的地址,这里只是为了便于理解进行的假设。