1.C+±⾼性能内存池

1. 项⽬介绍
这个项⽬做的是什么?
当前项⽬是实现⼀个⾼并发的内存池,他的原型是google的⼀个开源项⽬tcmalloc,tcmalloc全称 Thread-Caching Malloc,即线程缓存的malloc,实现了⾼效的多线程内存管理,⽤于替代系统的内 存分配相关的函数(malloc、free)。
这个项⽬是把tcmalloc最核⼼的框架简化后拿出来,模拟实现出⼀个⾃⼰的⾼并发内存池,⽬的 就是学习tcamlloc的精华,这种⽅式有点类似我们之前学习STL容器的⽅式。但是相⽐STL容器部分, tcmalloc的代码量和复杂度上升了很多
tcmalloc源码:
2. 什么是内存池
2.1 池化技术
所谓"池化技术",就是程序先向系统申请过量的资源,然后⾃⼰管理,以备不时之需。之所以要申 请过量的资源,是因为每次申请该资源都有较⼤的开销,不如提前申请好了,这样使⽤时就会变得⾮ 常快捷,⼤⼤提⾼程序运⾏效率。
在计算机中,有很多使⽤"池"这种技术的地⽅,除了内存池,还有连接池、线程池、对象池等。以 服务器上的线程池为例,它的主要思想是:先启动若⼲数量的线程,让它们处于睡眠状态,当接收到 客⼾端的请求时,唤醒池中某个睡眠的线程,让它来处理客⼾端的请求,当处理完这个请求,线程⼜ 进⼊睡眠状态。
2.2 内存池
内存池是指程序预先从操作系统申请⼀块⾜够⼤内存,此后,当程序中需要申请内存的时候,不是直 接向操作系统申请,⽽是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回 给操作系统,⽽是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
2.3 内存池主要解决的问题
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的⻆度,还需要解决⼀下内存 碎⽚的问题。那么什么是内存碎⽚呢?
内存碎⽚分为外碎⽚和内碎⽚
普通 malloc/free 频繁申请、释放小块内存,会产生内存碎片,内存池就是用来缓解这个问题。
1、什么是外部碎片(外碎片)
举个生活化例子:
你手里有一整条长条座位:【占】【空】【占】【空】【占】【空】
每一块空位单独看都不大。
现在来了一组人,想要连续 3 个空位。
你数一数所有空位加起来总量是够 3 个位置的,但是空位互相隔开、不连在一起,凑不出一整块连续空间。
放到内存里:
系统有很多零散、不连续的空闲内存块。空闲内存总和充足,但没有一大块连续的空闲内存。当程序申请一块较大连续内存时,分配失败。
这就是外部碎片。
关键点:内存本身是空闲的,只是碎片化、不连续。
2、什么是内部碎片(内碎片)
继续举例:
电影院买票,规定售票只能按2 个座位一组售卖。
你一个人只需要 1 个座位,但规则强制分给你 2 个相连座位。
你只用 1 座,剩下那 1 个座位被分给你了、别人不能坐,一直闲置。
放到内存场景:
硬件 / 内存池有内存对齐规则,分配内存时必须分配固定粒度大小的块。
程序只需要 50 字节,但是内存分配单位最小是 64 字节。系统直接给你 64 字节。
多出 14 字节已经划给你的内存块里,别人拿不走,你自己也用不上,白白浪费。
关键点:这块内存已经被分配出去了,包裹在已占用内存内部,闲置浪费。
3、两者核心区别总结
- 外碎片:空闲内存,但是四分五裂不连续,拼不出大块;
- 内碎片:内存已经分给进程了,块内部多出一小截空间,永久闲置。
相信各位大佬,到这里可以很容易理解什么是内存池了:
普通动态分配:频繁申请释放 → 产生大量外部碎片。
内存池:提前一次性申请一大块连续内存,自己管理里面空间,减少向操作系统频繁申请释放,大幅降低外部碎片。
但内存池为了对齐、按固定块划分,很容易产生内部碎片(要一小块,强制给一整块,剩余空间浪费),这也是后续项目里能直观观察到的现象。

2.4 malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆 获取内存的, ⽽malloc就是⼀个内存池。
malloc()相当于向操作系统"批发"了⼀块较⼤的内存空间,然后"零 售"给程序⽤。当全部"售完"或程序有⼤量的内存需求时,再根据实际需求向操作系统"进货"。
malloc的实现⽅式有很多种,⼀般不同编译器平台⽤的都是不同的。⽐如windows的vs系列⽤的微软 ⾃⼰写的⼀套,linuxgcc⽤的glibc中的ptmalloc。
下⾯有⼏篇关于这块的⽂章,⼤概可以去简单看看 了解⼀下,关于ptmalloc,有兴趣⼤家可以去看看他的实现细节。
一文了解,Linux内存管理,malloc、free 实现原理 - 知乎
malloc的底层实现(pcmalloc):
https://blog.csdn.net/z_ryan/article/details/79950737

3. 设计一个定长内存池
作为程序员(C/C++)我们知道申请内存使⽤的是malloc,malloc其实就是⼀个通⽤的⼤众货,什么场景 下都可以⽤,但是什么场景下都可以⽤就意味着什么场景下都不会有很⾼的性能
下⾯我们就先来设 计⼀个定⻓内存池做个开胃菜,当然这个定⻓内存池在我们后⾯的⾼并发内存池中也是有价值的,所 以学习他⽬的有两层,先熟悉⼀下简单内存池是如何控制的,第⼆他会作为我们后⾯内存池的⼀个基础组件。

3.1 设计的基本流程图

3.1 申请空间New()的实现
申请 New ()
- 优先查自由链表
_freeList:有闲置内存直接拿(不用向系统申请,速度最快) - 没有闲置块:检查预分配大块内存剩余空间
- 空间不足:malloc 一次性申请 128KB 大内存
- 从大块内存切出一块裸地址
- 统一执行定位 new:在裸内存上调用构造函数,生成合法对象返回
代码如下:
// 功能:从内存池里申请一个T类型的对象,返回对象指针
T* New()
{
T* obj = nullptr; // 最终要返回的对象指针,先初始化为空
// ---------------- 第一步:优先复用「别人用完还回来的」内存块 ----------------
// _freeList 叫自由链表,串着所有回收回来的空闲内存块
if (_freeList)
{
// 取出链表第一个节点里存的「下一个节点的地址」
// 通俗理解:每个空闲块的前几个字节,我们都用来存下一个空闲块的地址,像链条一样串起来
void* next = *(void**)_freeList;
obj = (T*)_freeList; // 把链表第一个块拿出来,给用户当对象用
_freeList = next; // 链表头往后挪一位,指向第二个节点
}
// ---------------- 第二步:没有回收块,就从大块内存里切一块 ----------------
else
{
// 先检查:剩下的大块内存,够不够分一个对象出来
if (_remainBytes < sizeof(T))
{
// 不够的话,就向系统一次性申请一大块内存(128KB = 128*1024字节)
// 一次性申请大块,比一次次申请小块快很多,这是对象池提速的核心之一
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc(); // 申请失败就抛异常
}
_remainBytes = 128 * 1024; // 更新剩余内存大小为刚申请的整块大小
}
obj = (T*)_memory; // 从大块内存的开头,切出一块当对象
// 计算实际分配大小:如果对象比指针还小,就按指针大小分配
// 原因:回收的时候,每个块至少要能存下一个指针(用来串链表),不然放不下
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize; // 大块内存指针往后挪,相当于切掉了刚分出去的那块
_remainBytes -= objSize; // 剩余内存减去分出去的大小
}
// ---------------- 第三步:在内存上构造对象 ----------------
// 定位new语法:在已经申请好的内存地址上,主动调用T的构造函数
// 为什么要这一步?因为前面只拿到了裸内存,还没初始化对象,成员值都是乱的
new(obj)T;
return obj;
}
小编中午刚睡醒,一时忘记了定位new的语法格式,顺便复习一下吧。
定位new的语法格式:
new(已经分配好的内存地址) 类型(构造参数);
① 无参构造
new(obj) T;
等价:调用T()默认构造
② 带参数构造
如果类有带参构造函数
struct A
{
A(int x){}
};
char buf[100];
A* p = (A*)buf;
new(p) A(100); // 在p地址调用A(int)构造函数
③ C++11 及以上支持初始化列表
new(p) A{100};
顺便来回顾以下定位new和普通的new的区别:
普通
new T():
- 向操作系统堆申请内存
- 在这块内存调用构造函数
定位 new
new(ptr) T:只做第 2 件事!不申请内存! 内存你自己提前准备好。
众所周知,new和delete是一对天赐良缘的伴侣。好好好,既然new都复习了,那我顺手讲一下delete吧
- 普通new当然可以配合普通delete(成对使用)用来防止内存泄漏
- 但是定位 new分配出来的对象,绝对不能用普通 delete 释放!
普通
delete p会做两件事:
- 调用析构函数
- 释放堆内存
但内存池的内存是我们统一管理的,不能交给系统释放,所以要手动拆分:
比如我代码里的:
// 1.手动调用析构,销毁对象(对应定位new的反向操作)
obj->~T();
// 2.内存不还给系统,回收进自由链表,后续重复使用
生命周期配对:
new(ptr) T;
--obj->~T();
3.2 Delete()的使用
哈哈哈哈,上面好像把delete该讲的都讲了,没事没事,这次就当奖励了
3.2.1delete执行流程如下:
用户用完调用 Delete(obj)
obj->~T():销毁对象,对象内部资源释放- 这块内存被挂回
_freeList空闲链表
下一次调用New():优先再次取出这块内存,重复循环
3.2.2 重要细节如下:
1.obj->~T(); 手动调用析构函数
-
普通
delete obj会做两件事:① 调用析构函数 ② 调用 free 把内存还给系统堆
-
内存池不能执行②,内存我们要重复利用。
所以我们
手动只完成第一步:析构对象
。
举例子:如果
T内部有动态开辟的缓冲区,析构函数里面会释放缓冲区,防止内存泄漏;注意:只是清理对象内部资源,承载这个对象本身的那块内存,仍然保留!
2.*(void**)obj = _freeList;
重点难点:
obj原本是T*,指向一块存放 T 对象的内存。
现在对象已经析构完毕,这块内存暂时空闲,这块内存的数据已经没用了。
于是我们直接复用这块内存最开头的 8 字节(64 位系统指针大小),用来保存链表指针。(其实这里也是关键,我们用这个可以自动识别当前程序在多少位系统下运行,32位的指针大小是4字节。Google牛批)
(void**)obj:把 T 对象的地址,强制转换成「指针的指针」*(void**)obj:读写这块内存起始位置,写入旧链表头_freeList
效果:当前空闲块记住「下一个空闲块是谁」,形成单向链表。
3._freeList = obj;
更新链表头,把当前回收的内存块变成自由链表第一个节点。
也就是头插法,O (1) 时间复杂度,不需要遍历链表,速度极快。
4.为什么敢直接覆盖 obj 头部内存?
因为已经执行 obj->~T(),对象生命周期结束,对象本身的数据不再需要,这块裸内存可以随便使用,用来存储链表指针完全安全。
5.为什么 New 拿到链表中的内存后,必须再次执行 new(obj) T?
内存块只是一块空地。上次 Delete 的时候析构了对象,内存里是失效的数据。
必须依靠定位 new重新调用构造函数,初始化出新的合法对象。
6.和普通 delete 最大区别
delete obj; // 析构 + 归还内存给操作系统
TNPool.Delete(obj); // 只析构,内存缓存复用,不归还系统
7.类型强转 *(void**)obj 的前提
我们在 New 的时候做了限制:
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
保证每一块内存至少拥有存放一个指针的空间。
防止 T 很小,内存空间不足以存放链表指针,导致越界。
3.3 TestObjectPool ()
3.3.1 函数目标
模拟高频、大量小对象反复创建销毁场景 ,对比「系统原生new/delete」与「自定义定长内存池」的执行耗时,验证理论:
频繁申请释放大量小对象时,内存池性能优于系统默认堆分配,同时减少内存碎片。
代码与注释如下:
// 性能测试函数:对比 系统new/delete 和 自定义对象池 的速度差异
void TestObjectPool()
{
const size_t Rounds = 3; // 测试轮数:连续跑3轮,减少单次测试偶然性,结果更稳定
const size_t N = 100000; // 每一轮:申请、释放 10万个TreeNode节点
// ========== 第一组实验:C++原生 new / delete ==========
size_t begin1 = clock(); // clock()记录CPU开始滴答数,用来统计代码运行耗时
std::vector<TreeNode*> v1; // 存放所有new出来的对象指针,后面统一释放
v1.reserve(N); // 提前预留vector存储空间
// 重点:不加reserve,vector不断扩容、拷贝内存,会干扰计时结果!
// 循环多轮,模拟持续不断的对象创建销毁压力
for (size_t j = 0; j < Rounds; ++j)
{
// 批量申请10万个节点
for (int i = 0; i < N; ++i)
{
v1.push_back(new TreeNode);
}
// 批量释放10万个节点
for (int i = 0; i < N; ++i)
{
delete v1[i];
}
v1.clear(); // 清空容器,准备下一轮测试
}
size_t end1 = clock(); // 记录本组结束时间
// ========== 第二组实验:我们实现的ObjectPool内存池 ==========
ObjectPool<TreeNode> TNPool; // 创建TreeNode专用内存池
size_t begin2 = clock();
std::vector<TreeNode*> v2;
v2.reserve(N); // 和上面保持一致,保证两组测试环境公平
for (size_t j = 0; j < Rounds; ++j)
{
// 从内存池批量申请对象
for (int i = 0; i < N; ++i)
{
v2.push_back(TNPool.New());
}
// 将对象归还内存池(不是释放给操作系统)
for (int i = 0; i < N; ++i)
{
TNPool.Delete(v2[i]);
}
v2.clear();
}
size_t end2 = clock();
// 打印两组消耗的时钟滴答数,数值越小运行越快
cout << "new cost time:" << end1 - begin1 << endl;
cout << "object pool cost time:" << end2 - begin2 << endl;
}
3.3.2为什么系统 new/delete 通常更慢?
- 通用堆分配器开销巨大
new底层调用malloc,需要维护整张堆空闲链表、合并空闲块、处理边界、全局锁;每次分配释放都有复杂算法。
- 频繁用户态 ↔ 内核态切换
大量小块内存反复申请,容易触发操作系统堆扩容,系统调用成本很高。
- 容易产生外部内存碎片
小块内存交替分配释放,堆上出现大量不连续小孔,后续分配越来越慢。
那我们的内存池又有怎样的优越性呢?老大?
哈哈哈哈哈,我这就解释:
话说回来,而内存池:
首次少量调用malloc申请大块内存;后续分配 / 回收只操作指针(用户态)、O (1) 链表头插 / 头取,没有复杂堆算法。
3.3.3 代码细节
vector.reserve(N)的意义
vector 默认空间不足时自动扩容,扩容会分配新内存 + 拷贝所有指针 + 释放旧内存。
如果不提前reserve,扩容耗时会混入测试结果,破坏对比公平性。两组测试统一加上,消除容器带来的干扰。
- 先申请全部对象、再统一释放
模拟业务中:短时批量创建一堆对象,用完统一销毁的场景;
也可以改成「申请一个、立刻归还一个」的混合模式,两种负载都可以拓展测试。
clock()小知识点(拓展点)
clock()统计程序占用 CPU 的时间 ,不是墙上时钟;Windows 下CLOCKS_PER_SEC=1000,差值就是毫秒级滴答数。
缺点:精度一般;更高精度可以改用 C++11
std::chrono。
3.3.4测试代码存在的缺陷
老大,你的代码好像存在不足。looking my eyes, why baby why?
what can you say?曼巴out
其实这是一个开胃小菜,既然你都讲出来了,那我就给你罗列出来吧:
-
测试顺序影响公平性
先跑
new/delete,再跑内存池。操作系统堆经过第一轮大量 new/delete 后产生碎片,可能让第一组天然变慢。✅ 优化方案:多次运行、调换顺序(先测内存池,再测 new),取平均值。
-
当前内存池存在内存泄漏
New()中malloc出来的 128KB 大块内存,程序结束没有调用free。
拓展优化:增加内存池析构函数,保存所有大块内存地址,退出统一释放。
- 只测试单线程场景
原生malloc
全局锁竞争在多线程下会急剧变慢;内存池可以改造为无 锁 / 分段锁,并发性能差距会更大。
3.4 定长内存池源码
ObjectPool.h
#pragma once // 头文件保护:防止这个头文件被重复包含,导致重复定义报错
// 引入需要的标准库
#include <iostream> // 用于控制台打印输出
#include <vector> // 用数组存申请的对象指针,方便批量释放
#include <ctime> // 用于计时,对比两种分配方式的速度
#include <new> // 提供「定位new」语法和内存分配失败的异常类型
using namespace std;
// 定长内存池模板类:专门用来高效分配/释放 固定大小为T的对象
// 核心优势:比系统自带的new/delete快很多,还能减少内存碎片
template<class T>
class ObjectPool
{
public:
// 功能:从内存池里申请一个T类型的对象,返回对象指针
T* New()
{
T* obj = nullptr; // 最终要返回的对象指针,先初始化为空
// ---------------- 第一步:优先复用「别人用完还回来的」内存块 ----------------
// _freeList 叫自由链表,串着所有回收回来的空闲内存块
if (_freeList)
{
// 取出链表第一个节点里存的「下一个节点的地址」
// 通俗理解:每个空闲块的前几个字节,我们都用来存下一个空闲块的地址,像链条一样串起来
void* next = *(void**)_freeList;
obj = (T*)_freeList; // 把链表第一个块拿出来,给用户当对象用
_freeList = next; // 链表头往后挪一位,指向第二个节点
}
// ---------------- 第二步:没有回收块,就从大块内存里切一块 ----------------
else
{
// 先检查:剩下的大块内存,够不够分一个对象出来
if (_remainBytes < sizeof(T))
{
// 不够的话,就向系统一次性申请一大块内存(128KB = 128*1024字节)
// 一次性申请大块,比一次次申请小块快很多,这是对象池提速的核心之一
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc(); // 申请失败就抛异常
}
_remainBytes = 128 * 1024; // 更新剩余内存大小为刚申请的整块大小
}
obj = (T*)_memory; // 从大块内存的开头,切出一块当对象
// 计算实际分配大小:如果对象比指针还小,就按指针大小分配
// 原因:回收的时候,每个块至少要能存下一个指针(用来串链表),不然放不下
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize; // 大块内存指针往后挪,相当于切掉了刚分出去的那块
_remainBytes -= objSize; // 剩余内存减去分出去的大小
}
// ---------------- 第三步:在内存上构造对象 ----------------
// 定位new语法:在已经申请好的内存地址上,主动调用T的构造函数
// 为什么要这一步?因为前面只拿到了裸内存,还没初始化对象,成员值都是乱的
new(obj)T;
return obj;
}
// 功能:把对象还回内存池,回收以后重复用
void Delete(T* obj)
{
// 第一步:先主动调用析构函数,清理对象里的资源
// 通俗理解:对象不用了,要先结束它的生命周期,比如对象里有malloc的内存,这里要释放
obj->~T();
// 第二步:用头插法,把这块内存插到自由链表的最前面
// 头插最快,不用遍历链表,这也是对象池快的原因
*(void**)obj = _freeList; // 让当前块的前几个字节,存上原来的链表头地址
_freeList = obj; // 把链表头更新成当前块,它就成了第一个节点
}
private:
char* _memory = nullptr; // 指向大块内存的起始地址,用char*是因为1字节好切分,像尺子一样量字节
size_t _remainBytes = 0; // 大块内存里,还剩多少字节没分配出去
void* _freeList = nullptr; // 自由链表的头指针,串着所有回收回来的空闲内存块
};
// 测试用的树节点结构体,用来当内存池的测试对象
struct TreeNode
{
int _val; // 节点存的值
TreeNode* _left; // 左孩子指针
TreeNode* _right; // 右孩子指针
// 构造函数:创建节点时,给成员赋初始值
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{
}
};
// 性能测试函数:对比 系统new/delete 和 自定义对象池 的速度差异
void TestObjectPool()
{
const size_t Rounds = 3; // 测试轮数:跑3轮取平均,结果更准
const size_t N = 100000; // 每轮申请、释放的对象数量:10万个
// ---------------- 第一组:用系统原生的 new / delete ----------------
size_t begin1 = clock(); // 记录开始时间
std::vector<TreeNode*> v1; // 存所有申请的对象指针,方便批量释放
v1.reserve(N); // 提前预留空间,避免vector扩容影响计时
// 循环Rounds轮,每轮申请N个、再释放N个
for (size_t j = 0; j < Rounds; ++j)
{
// 批量申请
for (int i = 0; i < N; ++i)
{
v1.push_back(new TreeNode);
}
// 批量释放
for (int i = 0; i < N; ++i)
{
delete v1[i];
}
v1.clear(); // 清空数组,准备下一轮
}
size_t end1 = clock(); // 记录结束时间
// ---------------- 第二组:用我们自己写的对象池 ----------------
ObjectPool<TreeNode> TNPool; // 创建一个TreeNode类型的内存池
size_t begin2 = clock();
std::vector<TreeNode*> v2;
v2.reserve(N);
for (size_t j = 0; j < Rounds; ++j)
{
// 从对象池批量申请
for (int i = 0; i < N; ++i)
{
v2.push_back(TNPool.New());
}
// 批量还回对象池
for (int i = 0; i < N; ++i)
{
TNPool.Delete(v2[i]);
}
v2.clear();
}
size_t end2 = clock();
// 打印两组耗时,数值越小速度越快
cout << "new cost time:" << end1 - begin1 << endl;
cout << "object pool cost time:" << end2 - begin2 << endl;
}
Test.cpp
#include "ObjectPool.h"
int main()
{
TestObjectPool();
return 0;
}
又是美好的一天,各位观众姥爷们,若有什么不懂的地方,欢迎在评论区留言哦,我会给出相应解释的。如有什么疏忽的细节,请点出,我会及时纠正。感谢观看。喵喵