续啃《代码随想录》的《内存池项目》 —— 上一篇是关于分离写法衍生的思考,这篇开始逐行代码死磕追问豆包 + 无尽思考实践串联
看代码随想录的 V1
前言:
这个代码有ABA问题
无ABA问题的代码塞了一堆修复缺陷的额外逻辑版本号啥的,核心 CAS / 锁的主干流程会被掩盖,新手看不出最基础的运行逻辑。必须先看简化裸版看懂主干,再补防护代码,才能明白每一段防护代码是为了解决什么坑。裸 CAS 链表调用 CAS 的代码本身没错,只是没加版本标记这个防护字段;不是 CAS 写错,是结构设计缺防护。先看懂CAS咋跑,这是无锁基础,然后至于ABA只是修复多线程并发的漏洞工具,会理论就行,大厂有现成的ABA修复工具
举锁的例子:
锁的代码写得规规矩矩,只是加锁顺序乱了卡死,不是锁本身坏了;
CAS 调用语法完全没错,只是节点只存地址,内存回收后地址重复用,就出 ABA。
代码咋上手看,先说宏观框架:
第一层:HashBucket(分级调度层,对应你全局单池的上层分发器)
你只有 1 个固定尺寸池;这套代码按对象大小分 64 档独立内存池,核心逻辑:
宏定义档位:
SLOT_BASE_SIZE=8,每一档槽尺寸 =(下标+1)*8,最大一档 64*8=512B,超过 512 直接走原生operator new/delete
getMemoryPool:静态数组MemoryPool memoryPool[64],64 个独立内存池,每个池子固定单一槽大小(类似把你的单池复制 64 份,每份尺寸不同)
useMemory分配路由:路由指的是按内存尺寸分发请求到对应尺寸独立内存池的分发逻辑,这里先计算对象需要的档位,向上取整匹配对应尺寸池,调用对应池allocate();超大内存直接原生分配
freeMemory回收路由:匹配尺寸归还对应池,超大内存原生释放工具函数
newElement/deleteElement:封装内存分配 + placement new 构造、析构 + 内存回收,对外屏蔽底层内存池细节这里名字叫 HashBucket,但没有哈希映射逻辑,只是大小分级桶,和哈希无关,仅做尺寸分类调度。
哈希逻辑目标:数据均匀分散到所有桶,避免大量数据挤在少数桶,减少单桶锁竞争、链表遍历开销。若不打乱,连续 key 会落在同一个桶,造成热点桶,查询、插入、删除性能退化。本场景需求相反:相同内存尺寸必须进同一个池,不能打散,因此只用线性分段,不用哈希。
比如相近的 3 和 4:
3 二进制:00000011,4 二进制:00000100,
3^14=13 (00001101),4^14=10 (00001010),
异或会翻转二进制位,相邻数字二进制仅末尾少量 bit 不同,异或后高位突变,取模后下标跳变,下标不连续。
代码逻辑:SLOT_BASE_SIZE=8,下标 =(40+7)/8 -1 =5-1=4,size 与下标严格线性对应:8→0、16→1、24→2、32→3、40→4。
第二层:单个 MemoryPool(对标你的 GlobalMemoryPool,单尺寸内存池,核心差异)
大块切割逻辑:
目标池:新大块只标记起止范围,不提前拆分链表;只有释放的内存才进空闲链表
你的池:新大块一次性全部切完,所有小块直接挂载空闲链表
池子数量
目标池:64 个独立池子,每个池子尺寸不同
你的池:全局仅单个固定尺寸池子
锁范围
目标池:仅新开大块时上锁,空闲链表无锁
你的池:分配、释放全程加锁
内存适配
目标池:自动匹配尺寸,超 512 字节走原生 new
你的池:只能分配固定大小内存
开始说细节:
一些语法直接备注到了代码里,省着看一眼代码看一眼博客费劲
代码思想直接就写博客里,
但如果博客只写重要的思路和疑难,不提那些【注释只在代码里的函数】就会比较乱,因为对于这种陌生代码只有清楚知道看代码块的流程,先看哪个函数后看哪个函数,才能懂,所以只注释在代码里的这里直接提一句。
关于.h里的全局宏定义,看代码里注释即可。
关于.h里的static void* useMemory(size_t size){:
代码里说了语法,但为啥要用内存池?为啥要用operator而不用
malloc或者new int?
operator new是 C++ 接口分配失败抛异常且支持重载,malloc是 C 的,分配失败返回空指针。一、内存池可以降低锁冲突
glibc是Linux 系统自带的基础底层工具库,程序申请内存都靠它实现
arena:系统提前批量从内核拿来、存放在进程里的大块备用内存区域。
malloc(size):先走glibc里的调用 brk/mmap 系统调用从OS内核进货到arena 堆管理方便下次不用再进货operator new(size):默认operator new底层调用malloc
new int:先调用 operator new (4) 分配内存(这里一次性进货不止4字节到arena,提前预加载,多次申请会扩容arena,但new int速度慢主要大头在于全局堆锁竞争、长期全局碎片查找开销大),再调用定位new来用 int 构造函数初始化内存
int* p = new int;
void* raw = operator new(sizeof(int));分配裸内存并用raw获取
p = new(raw) int;编译器自动生成new(内存地址) int(定位 new)执行构造。
new int无传参时,内置 int 会默认初始化,就是只分配内存,不写值,随机脏。
new int(10),构造就会把 10 写入这块内存。
new int()是值初始化,直接搞 0
回头看
int* p = new int;这个是随机值#include <iostream> int main() { int* p = new int; *p = 1475; // 手动写入脏数据 delete p; // 释放,内存保留原有数字不会清空 // 再次分配同大小内存,大概率复用刚才那块内存 int* p1 = new int; std::cout << "new int 未初始化值:" << *p1 << std::endl; int* p2 = new int(); std::cout << "new int() 值初始化:" << *p2 << std::endl; delete p1; delete p2; }
delete p调用operator delete,把这块内存还给进程堆管理器,不会立刻清零内存里的数据,内存上旧数字还留在原地,只是标记这块内存空闲,后续再new int分配时堆管理器可能把这块旧内存重新分给你,OS 只在程序结束后,统一回收全部内存;运行时 delete 只是程序内部归还,不归还给 OS。这个代码刚释放马上申请同等大小小块堆内存,堆管理器空闲链表会优先取出刚释放的这块内存(但实际不是)
那第二步的构造有啥用?
new(raw) int只做默认初始化,不会写 0,内存还是脏值,看着好像没效果,但这套流程是统一规范:不管是 int、自定义结构体,语法都固定拆成「分配内存 + 定位 new 构造」两步,自定义类有构造函数时,定位 new 必须执行构造逻辑(初始化成员、开资源),这一步必不可少;内置基础类型无用户构造逻辑,只是语法上保留统一流程,看起来像多余。想要清除脏值要写成new(raw) int(),触发值初始化置 0。
malloc、operator new、new int均由线程调用,同一进程所有线程共用全局堆 arena,分配时竞争同一把堆锁;内存池预分配大块裸内存后内部分配,不再抢占全局堆锁,降低锁冲突:
fork搞出进程后,里面比如总共想干3个事,就叫做3个业务,就malloc出3个内存池,每个业务的线程就在这个自己内存池里做事,但依旧有锁,只不过:
全局堆:全进程所有线程抢同一把锁,并发高时大量线程排队阻塞,性能差。
业务独立内存池:仅A业务内部线程抢A池专属锁,竞争线程数量大幅变少,排队阻塞概率大幅降低,性能更好。
二、内存池降低碎片污染问题
free的时候:
malloc等 glibc 库,
调用 malloc 分配超大块(mmap 分配),free 时直接还给系统;
中间的地址咋释放都不还给OS,只有堆顶释放累积到足够阈值才还给 OS
剩下的都不还给 OS 只有整体释放池子才给 OS
手写内存池内部释放不调用系统接口,销毁池子才释放整块内存
注意:堆地址从低往高扩张,堆的边界(堆顶)是当前占用最高地址。
如果
new int,[A 占用内存][B 占用内存][C 占用内存],然后freeB 标记空等复用,变为[A 占用内存]|小空闲缝|[B 占用内存][C 占用内存],如果直接一次malloc/operator new搞大块,分配后收回都是会出现剩余 1000 个 1KB 小块,想分配 10KB 都分不了的情况
全局堆、内存池都会产生碎片(零散闲置小块内存),全局堆所有线程共用同一片公共大堆内存,互相抢占空间,碎片布满整个进程,持续破坏连续内存
内存池是一次
malloc开辟了大块内存,不需要去公共大堆里申请,碎片仅局限在池子内部,不影响外部,尾部完整连续空间始终保留而不会被其他线程抢走。三、有了内存池,库函数选能自定义字节数的
单纯
new int只能分配单个 int 大小,内存池可自定义任意字节长度四、有了内存池,库函数选性能好的
先看代码:
查看代码
#include<iostream> using namespace std; struct Test{ int* num; Test(){ num = new int(1); cout << "执行构造函数,num值:" << *num << endl; } ~Test(){ delete num; cout << "执行析构函数" << endl; } }; int main(){ // int *a = new int; // int *b = new int(); // void* qq = operator new(4); // int* c = static_cast<int*>(qq); // cout<<*a<<" "<<*b<<" "<<" "<<*c<<endl; // 有对象,自动执行构造、析构 Test* t1 = new Test; delete t1; cout<<"———————:"<<endl; // 只有内存,无对象,不跑构造析构 void* m = operator new(sizeof(Test));//分配对应大小空白内存,只统计结构体成员内存:仅int* num指针大小,和构造、析构、cout 代码无关,函数代码不占对象内存,完全不执行构造函数,num 是随机脏值 Test* t2 = (Test*)m; operator delete(m); /* 同样大小内存: new 类型 = 内存分配 + 构造;delete = 析构 + 释放内存 operator new/malloc = 只拿空白内存,无对象、无构造析构;释放只回收内存,不清理对象内部资源 */ }
new int会创建带完整生命周期的对象,只能逐个delete,无法整块回收复用;这是豆包的结论,估计也是全网的结论,但我仔细思考太歧义了!妈逼的都可以整块复用,
单次小块循环分配场景:
malloc(4)100 次 /new int100 次,都要 100 次释放,无任何区别。唯一硬性差异只在单次释放内部流程:
free只回收内存;delete先走析构清理资源,再回收内存。豆包说大块一次性分配场景:
malloc(400)整块内部逻辑分割后依旧可以一次性free;100 个独立new int语法不允许整块回收,必须逐个delete。 我的评价是纯傻逼的很误导人!!“100 个独立new int语法不允许整块回收” 是因为他是 100 次的new int,换到malloc(4)搞 100 次依旧要 100 次free!你malloc所谓的可以一整块释放也只是释放一次malloc的字节啊。
malloc(3):只划出 3 字节裸内存,无任何自动执行的对象初始化、对象析构逻辑。CPU 仅调用系统分配接口,拿到裸内存,1 层逻辑。对应free
operator new(3):同上
new T(size)(普通对象 new):构造:底层先调用
operator new拿内存,再自动执行构造函数生成有效对象;释放:用
delete,底层先执行析构再释放内存。相比于上面两个,多了一次函数调用 CPU 级别的指令开销,即在分配多构造调用、释放多析构调用。
关于.h里的static void freeMemory(void* ptr, size_t size){只看注释就行。
关于.h里的template<typename T, typename... Args> T* newElement(Args&&... args)里涉及到的new(p) T(std::forward<Args>(args)...);:
这里涉及到的前设基础知识太多太多了~~~~(>_<)~~~~
先说
move:左值
string s = "hello";被string s2 = move(s);强转为右值,cout s为空
std::move唯一作用:把左值强制转换成无名右值引用,仅此一步,不移动资源。如果本身就是右值,没必要用move。转换后编译器匹配重载优先级:先匹配
T&&移动函数,不存在则降级匹配const T&拷贝函数。
手写移动专属函数
类名(类名&& 源)移动构造函数
类名& operator=(类名&& 源)移动赋值运算符二者是自定义移动语义,接管资源,避免深拷贝,必须手动写才会生效
无
std::move:左值传入 → 调用拷贝构造 / 拷贝赋值右值直接匹配移动构造/移动赋值,无需std::move。
std::string是标准库提前写好了移动构造,你直接用才会掏空原对象;自定义类不手写移动函数,move 不会产生资源转移效果。
语法规则:
非
const左值引用Test&仅能绑定非const左值,不能绑定右值、const左值。const 左值引用
const Test&万能绑定:普通对象、const 对象、临时对象、亡值全能接住。右值引用(
Test&&)只接收临时(非const纯右值) / 被 std::move 转换后的亡值(经过move转来的)- const Test&&不用,因为要移动你写个屁的const
实参传给形参,形参带引用,比如
T&/const T&/T&&,这个传递过程就叫绑定,而没引用的实参传递给形参,不叫绑定,只发生拷贝,不存在绑定。
T&&是右值引用,是实现移动语义的基础,仅负责绑定右值,但不等同于移动语义,移动语义依靠T&&形参的移动构造 / 移动赋值,转移资源而非拷贝。
临时对象不能用非 const 左值引用接收,比如
Test x = Test{"123"};中Test{"123"};是无名的临时对象,如果Test写成Test& Test(Test& t){ t.buf = "改了内容"; }就错了因为不可以传递给无
const拷贝构造,只能Test(const Test& t){ std::string temp = t.buf; }别问东问西,临时对象马上销毁,尽管传递给构造的时候没销毁,但就是死命禁止任何修改。
Q:绑定是啥意思?我咋感觉像赋值呢?
A:
Test b = a:b 是刚造出来的新东西,这一步叫造新对象(初始化),不是赋值。赋值是东西本来就有,后来改内容。
绑定(不可换绑):调用拷贝构造函数时,拿 a 去填函数括号里的参数 Test& t,把 a 和这个参数拴在一起,这个拴住的动作就叫绑定,跟复制内容两码事。
复制内容:拴好之后,函数内部再把 a 里面的数据抄给 b,这一步看着像赋值,但只是复制数据。
看个代码:
查看代码
#include <utility> #include <string> struct Test { std::string buf; Test(std::string s) : buf(std::move(s)) {} // 手写移动构造 Test(Test&& t) noexcept : buf(std::move(t.buf)) {} // 手写移动赋值 Test& operator=(Test&& t) noexcept { buf = std::move(t.buf); return *this; } // 拷贝构造 Test(const Test& t) : buf(t.buf) {} }; int main() { Test a{"123"}; Test b = a; // 左值,调用拷贝构造 Test c = std::move(a); // move转右值,调用手写移动构造 }
Test(std::string s) : buf(std::move(s)) {}接收字符串参数来构造对象,字面量匹配值参数为
std::string时,先隐式转换生成临时右值std::string,再拷贝一份值传递给s,这就是局部的左值s,std::move(s)将s转为右值,即
std::move(s)强制转换成std::string&&(纯右值)类型,然后由于写了重载的移动构造,又写了move,即
buf(move(s)),buf调用std::string移动构造接管字符内存(无深拷贝),刚刚的局部形参s生命周期结束自动析构,此时s为空字符串,无资源释放冲突。练手:
查看代码
#include <iostream> #include <string> #include <utility> using namespace std; struct MyStr { string data; // 新增普通构造 MyStr(const string& s) : data(s) {} // 拷贝构造:初始化列表直接拿other.data构造成员 MyStr(const MyStr& other) : data(other.data) {} }; int main() { MyStr a{"test"}; MyStr b = move(a); cout << a.data << endl; // 仍输出test,走拷贝无移动 }1、
MyStr a{"test"};:
"test"是const char[5],隐式转换为const char*,结构体没有入参为const char*的构造函数,仅存在MyStr(const string& s),编译器利用const char*隐式生成临时string("test"),临时string绑定到const string& s,初始化列表data(s)调用string的拷贝构造,复制字符串内容2、
MyStr b = move(a);:
std::move(a)将左值a转换成类型为MyStr&&的亡值,结构体只定义了拷贝构造MyStr(const MyStr& other),不存在移动构造MyStr(MyStr&& other),const MyStr&可以绑定亡值,重载匹配拷贝构造函数,初始化列表data(other.data)复制other.data的字符串数据,不转移内存资源3、
cout << a.data << endl;:
a内部string的堆内存没有被转移,对象原有数据保留,打印结果为test。进阶完善追问:
查看代码
// 无数次实践发现,学错的思考错的为什么错(指针部分) // 学仿佛超纲钻牛角尖一句一句问的下面这个,真的把所有需要掌握的东西灵活的起来了,真会了 #include <utility> #include <string> #include<iostream> using namespace std; struct Test { std::string buf; Test(std::string s) : buf(std::move(s)) {cout<<"string"<<endl;} // 接收 string 参数的转换构造,移动传入字符串 Test(Test& t) : buf(t.buf) {cout<<"无const左值拷贝构造"<<endl;} // 无 const 的左值拷贝构造,仅能拷贝非 const 对象 //buf(t.buf)会新建独立字符内存,属于深拷贝 //所有值传递:内置类型:int、char、double、bool等基础原生类型,无堆内存,所以无深浅拷贝区分,仅复制变量本身数值,不存在额外堆空间,谈不上深拷贝 //但自定义类对象值传递会触发深拷贝 //深拷贝各自独立堆内存,不会双重释放;浅拷贝共用同一块堆内存才会双重释放 //编译器默认生成的拷贝构造 / 赋值运算符,直接逐成员复制指针,共用堆内存,属于浅拷贝,所以只要涉及到堆,用了默认的浅拷贝共享堆内存,析构时重复释放直接崩溃 Test(Test&& t) noexcept : buf(std::move(t.buf)) {cout<<"移动构造"<<endl;} // 移动构造,接管源对象字符串资源 Test(const Test& t) : buf(t.buf) {cout<<"新增"<<endl;}//拷贝构造 }; int main() { cout<<endl; // 场景1 正常 Test a{"123"}; // 先拿字面量构造std::string临时,再传入构造函数调用 Test(std::string s) : buf(std::move(s)) {} 。等价含义写法:Test a = std::string("123");,但属于拷贝初始化,Test a{"123"};属于直接初始化 Test b = a; // a 是非 const 左值,优先匹配 Test(Test& t) 拷贝构造,若删掉该重载,才会选用 Test(const Test&),引用绑定规则允许非const绑定到const,但 const 不可以绑定到非const cout<<endl; // 场景2 // Test t4="ss"; // "123"、"ss" 字面量类型:const char[N],属于字符数组, // const char[3](字面量数组)→ const char*:数组自动退化,无代价、内置转换,随时能转,不占用自定义转换的名额次数(数组名作为右值使用时,自动退化为首元素指针) // const char* → const char[3]:不能隐式转换,指针只是存地址,没有数组长度信息,编译器做不到自动转回数组。 // 编译器尝试转换链: // 第一层:const char[3] 内置退化 const char* → 临时std::string,即内部偷偷 string __temp("ss") , 使得 "ss" 变为 std::string("ss") // 第二层:临时std::string → Test(你写的构造函数,用户自定义隐式转换),使得 std::string("ss") 变为 Test (std::string ("ss")) // 最后用Test (std::string ("ss")) 去初始化 t4 : Test t4 (std::string ("ss")) ,但 C++ 硬性规则:一次初始化表达式,最多允许 1 次自定义隐式转换 // 想过编译必须加 const char* 构造函数 Test(const char* s) : buf(s) {},这样"ss"构造成 Test ,属于1次隐式,合法、 // 场景3, const Test t1{"abc"};// 调用 Test(std::string s) 构造函数,临时 std::string 右值匹配该构造,生成 const Test 对象,类型为const Test Test t2 = t1; // 编译器会在 Test 类中匹配参数能接收 t1(const Test左值) 的构造函数 // Test(std::string s)参数是std::string // 拷贝构造 Test(Test& t),形参类型Test&(非 const 左值引用,Test&&才叫右值引用),C++ 语法规则:const 左值不能绑定到非 const 左值引用 // 移动构造 Test(Test&& t),形参是右值引用Test&&,仅能绑定纯右值 / 亡值。t1 是左值,匹配失败 // 想编译过必须加个新增的 Test(const Test& t) 拷贝构造 cout<<endl; // 场景4, Test t3 = Test{"xyz"}; // 未命名临时对象,属于纯右值 // 构造 1,Test(std::string s) 类型不匹配,因为等号右边是Test临时类型 // 拷贝构造Test(Test& t):形参非 const 左值引用,不能绑定右值,匹配失败 // 移动构造Test(Test&& t):右值引用可绑定纯右值,匹配成功, // 但 C++11/14 要么生成临时再移动,要么直接原地构造。 // C++17直接原地构造,直接走 t3 里搞 string,没临时 // 复制省略(拷贝消除):编译器跳过拷贝/移动构造,直接把临时对象建在目标变量内存,省去复制资源开销。 cout<<endl; }
关于
string:
std::string内部写了好多重载,一、赋值运算符(已有对象覆盖内容时调用)
1、C 字符串字面量赋值
string& operator=(const char*);str = "abc"这是人类简写语法,对应string& operator=(const char* cstr),编译器会自动翻译成成员函数调用:str.operator=("abc")
左边
str.= 调用对象
operator== 函数名括号
("abc")里的"abc"= 传入函数的实参2、拷贝赋值
string& operator=(const string&); //string& 是整体返回值类型,&都写在右侧示例:
b = a;
string&:返回当前对象引用,支持连续赋值x=y=z
operator=:赋值函数名
const string&:参数,接收另一个字符串左值
b=a等价b.operator=(a),a 匹配形参const string&,调用该拷贝赋值重载,函数内部把 a 的数据复制给 b,返回*this(b 自身引用),实现x=y=z连续赋值二、构造函数(创建新对象时调用)1、拷贝构造
string(const string&);示例:string b(a);2、移动构造
string(string&&) noexcept;示例:
string b(move(a));
懂了
move继续说为啥要引入forward,先看个代码:查看代码
#include <utility> #include <iostream> using namespace std; struct Data { int a; Data() { std::cout << "默认构造\n"; } Data(const Data&) { std::cout << "拷贝构造\n"; } Data(Data&&) { std::cout << "移动构造\n"; } }; template<typename T> void badTransfer(T val) { cout<<"@"<<endl; Data d(std::move(val));// 调用移动构造, val是函数内局部左值, std::move库函数内部固定是转为Data&& 匹配移动构造函数参数Data&& cout<<"——结束——"<<endl; } int main() { Data a; cout<<"#"<<a.a<<endl; badTransfer(a); // 调用上面的模板,函数实例化后等价于普通函数: // void badTransfer(Data val) { // Data d(std::move(val)); // } // 执行逻辑:把变量 a 拷贝一份给形参 val(触发 Data 拷贝构造),函数内部再 move val cout<<"——开始下面——"<<endl; badTransfer(Data{}); // Data{}是临时右值,类型还是Data,推导T = Data // 实例化后和上面完全一样的函数,先拷贝临时对象到 val,再 move val }这里
Data{}是列表初始化(值初始化),构造一个无名临时Data对象(纯右值)。无任何用户自定义构造,编译器自动生默认构造
Data a;命名左值对象,生命周期到作用域结束;无初始化,内置成员脏值
Data a{}值初始化,内置成员置 0不用
Data a()因为会被编译器解析成函数声明:名叫 a、无参数、返回 Data 的函数,不是定义局部对象。Data{}:创建临时对象,内置成员置为 0
Data():同上,但 C++11 后推荐{}形式,存在自定义无参构造时,全部只执行你的构造,成员是否清零完全看你构造内赋值(前提你有成员)
这个看似没问题,但失去的原生左右值属性, 如果改成
template<typename T> void badTransfer2(T&& val) { Data d(std::move(val)); }此时问题彻底暴露:Data a; badTransfer2(a);a 是外部左值,
std::move(val)直接把外部 a 转为右值,触发移动构造,外部 a 资源被掏空,后续使用 a 会产生未定义行为。这就是单纯 move 最大的坑:不分场景强制右值,会误移动外部左值对象。所以要引入完整正确转发模板:
template<typename T> void goodTransfer(T&& val) { //T&& val 是万能引用,不是单纯右值引用 //非模板固定类型(如 Data&&、int&&):单纯右值引用,只能接收右值,不能接左值。 Data d(std::forward<T>(val)); } //注意运算符都写在右侧
std::forward<T>(val)依赖万能引用推导规则:
传入左值
Data a,推导T=Data&,forward 输出左值引用,调用拷贝构造;传入右值
Data{},推导T=Data,forward 输出右值引用,调用移动构造;模板形参是
T&& val(万能引用)
实参是左值:编译器强制给 T 推成
类型&实参是纯右值 / 亡值:编译器直接推 T 为原生裸类型(不带任何 &)
逻辑:传右值临时
Data{},它本身没有左值属性,不需要给 T 附加左值引用标记,所以 T=Data由于模板是
T&& val:
传左值,把推导的 T =
Data&代入得到Data& && val,符号全部贴在类型Data右侧,再引用折叠 →Data& val传右值,把推导的 T =
Data代入得到:Data&& val只要里面出现左值引用符号 &,结果一定是左值引用
&;对比两套模板,一眼看出 move 的局限性方案 1:只 move(值参 badTransfer)
左值 a:拷贝副本→move 副本,外部 a 安全,但右值传参多一次拷贝;
临时 Data {}:先拷贝临时生成 val,再 move,冗余拷贝。
方案 2:万能引用 + move(badTransfer2)
template<typename T> void badTransfer2(T&& data) { Data tmp = std::move(data); // 致命问题在这里 }
临时 Data {}:直接移动,无冗余;
左值 a:传左值
a,T推导成Data&,展开后T&& = Data& &&,折叠为Data&,所以是左值引用,直接 move 外部对象,掏空外部变量,严重逻辑 bug。方案 3:万能引用 + forward(标准完美转发)
左值 a:仅拷贝,不改动外部;
临时右值:直接移动,无多余拷贝;
同时兼顾安全与性能。
懂了语法开始说涉及到
forward的代码:
if ((p = reinterpret_cast<T*>(HashBucket::useMemory(sizeof(T)))) != nullptr)
sizeof(T):计算你要创建的对象 T 自身占用多少字节;
只拿裸内存(不用创建对象,仅一片原始内存),调用
HashBucket::useMemory/HashBucket::freeMemory,手动传入要分配的字节大小 size,// 分配16字节裸内存 void* buf = Kama_memoryPool::HashBucket::useMemory(16); // 回收,必须传入当初分配的size Kama_memoryPool::HashBucket::freeMemory(buf,16);分配并创建 C++ 对象(自动构造 + 自动析构)
调用模板
newElement<T>/deleteElement<T>,填类型 T,不用手动算 sizeof,参数直接传给类构造函数struct Test{ int a; Test(int x):a(x){} }; // 分配内存+执行Test构造函数 Test* t = Kama_memoryPool::newElement<Test>(10); // 先调用~Test析构,再回收内存 Kama_memoryPool::deleteElement(t);
newElement/deleteElement是对useMemory/freeMemory的上层封装,专门给有构造析构的类对象使用;
useMemory/freeMemory是底层裸内存接口,面向无类型缓冲区
HashBucket::useMemory(入参):就是你前面看懂的静态分配接口,内部逻辑:判断大小选内存池 / 原生 operator new,返回一块无类型裸内存地址;
reinterpret_cast<T*>(裸内存地址):把接口返回的无类型内存指针,强制转换成 T 类型指针,方便后续在这块内存创建对象;
HashBucket::useMemory返回值类型是void*(无类型裸内存指针),void* 特点:编译器不知道这块内存存的是什么东西,不能直接调用构造函数、不能当 T 对象使用。
reinterpret_cast<T*>(地址):强制类型转换,单纯修改编译器对这块内存的解读类型,不会修改内存本身、不会拷贝数据。只能把 void * 裸内存转成
T*,让编译器认可这块内存是用来存放 T 类型对象的,后面才能执行new(p) T(...)定位 new 构造对象,比如:
void* rawMem = HashBucket::useMemory(sizeof(TestObj));
TestObj* p = reinterpret_cast<TestObj*>(rawMem);强转成TestObj*,告诉编译器这块内存存TestObj,现在p是T类型指针,可以执行构造
new(p) TestObj(1,2);,在已经分配好内存的p指向的那块空间上,原地创建TestObj对象并调用构造函数传入参数1、2,不重新分配堆内存。reinterpret_cast转换后的TestObj *指针作为地址执行定位new,如果不做这个转换直接拿void *去定位new,编译器会报语法错误,不允许操作。
p = 转换后的指针:把转换完的地址赋值给 p;
!= nullptr:判断内存分配是否成功,分配成功才执行内部构造逻辑。
new(p) T(std::forward<Args>(args)...);
new(p):定位 new 语法,不会向系统申请新内存,只会在 p 指向的已有内存上执行构造;
std::forward<Args>(args)...:完美转发,把调用 newElement 时传入的所有构造参数,原封不动传递给 T 的构造函数,支持左值、右值参数;
T(...):调用类型 T 对应的构造函数,在分配好的内存上初始化对象。
return p;:把已经完成内存分配 + 对象构造的 T 指针返回给调用者,外部就能正常使用这个对象。
省略号:
typename... Args、typename ... Args、typename...Args都等价,...修饰Args,中间空格仅排版分隔,代表 Args 是一组可变类型集合包,意思是可以存任意多个不同类型参数(模板尖括号里填的int、float这类种类叫类型参数,叫参数是因为它是传给模板的输入值,和函数括号传数字参数逻辑一样,只是传的是类型而非数据)
Args&&... args:省略号修饰左边的Args&&,作用是给参数包里每一种类型都套上万能引用,叫函数形参,即函数括号里变量的数据类型(int、万能引用Args&&这类)。
末尾的
...修饰它左边完整表达式std::forward<Args>(args),参数包展开符,作用:把 args 里打包的所有参数逐个拆开,分别转发给构造函数。定义类型包:
typename就是一个类型,类似int,...修饰右侧类型名Args参数带限定符(&&/* 等):
...修饰左侧带限定符的完整类型片段比如:
查看代码
#include <iostream> // int... N:整型参数包,只能接收常量整数,不是变量类型包 template<int... N> void PrintNums(){ // 展开参数包 (std::cout << N << " ", ...); std::cout << "\n"; } int main(){ PrintNums<1, 3, 5, 7>(); PrintNums<10,20>(); }懂了这些突然发现个事,模板具体使用情况划分:
首先
template<typename T> T f() { return 1; }然后调用
int a = f<int>();,这里f<int>()后模板实例化出等价代码:int f() { return 1; }。那模板都有哪些?大部分形式分为:
#include <iostream> template<typename A, typename B, typename... C> void test(C&&... args){ std::cout << sizeof(A) << " " << sizeof(B) << "\n"; ((std::cout << args << " "), ...); } int main(){ test<int,double>(10, "abc", 3.14); }A、B是两个普通模板类型占位符,只在函数内部用来计算内存占用大小,没有出现在函数的形参列表里,无法通过圆括号传入的值自动推导,调用时必须在尖括号手动指定类型。后面的3.14可以自动推导,不需要写在
<>里。
打包的全是形参:
查看代码
#include <iostream> #include <string> template<typename... B> void func(B&&... args){ ((std::cout << args << " "), ...); std::cout << "\n"; } int main(){ // 自动推导,省略尖括号 func(10, "test", 3.14); // 手动显式指定全部包内类型 func<int, std::string, double>(20, "demo", 6.66); }
查看代码
template<typename T, typename Arg> T* newElement(Arg args){ T* p = reinterpret_cast<T*>(malloc(sizeof(T))); if(p) new(p) T(args); return p; } struct A{ A(int x){} }; A* ptr = newElement<A>(10);调用
newElement<A>(10),模板参数T=A,形参Arg=int,传入实参10进入函数内部:
执行
malloc(sizeof(A)):向系统堆申请一块等于 A 对象占用字节大小的原始空白内存,返回 void * 裸地址
reinterpret_cast<T*>:把 malloc 返回的无类型指针强制转换成 A 类型指针,赋值给局部变量p判断
if(p):校验内存分配是否成功(malloc 失败返回空指针,不会走构造逻辑)定位 new
new(p) A(args):
不在堆上新开内存,直接复用
p指向的 malloc 内存调用结构体 A 的构造函数,把传入参数
args=10传给构造函数入参x,完成对象初始化
返回指针
p:该指针指向已经分配内存 + 完成构造的 A 实例,赋值给外部变量ptr
A 这里写成了对象,那就是返回值是对象类型,然后 B 打包的可以推导,不用写
<>里查看代码
#include <iostream> #include <utility> #include <string> #include <cstdlib> template<typename A, typename... B> A* newElement(B&&... args){ A* p = nullptr; if (p = reinterpret_cast<A*>(malloc(sizeof(A))))//防止malloc分配内存失败返回空指针,空地址上调用定位new会程序崩溃 new(p) A(std::forward<B>(args)...); return p; } struct Person { std::string name; int age; Person(std::string n, int a) : name(n), age(a) {} }; int main(){ Person* man = newElement<Person>("张三", 22); std::cout << man->name << " " << man->age; free(man); }编译期:
newElement<Person>("张三",22)先确定模板T=Person,推导 Args 参数包类型;- 编译器生成专属
Person版本newElement函数。运行期不再看Person函数了,直接看专属那个函数:1、"张三"、22 作为实参传入函数形参
Args&&...;2、
malloc分配Person大小内存;3、定位
new + forward展开参数调用Person构造;
new(p) T(...)叫定位新建,含义:不在堆上新开内存,直接用已有地址 p 这块内存调用构造函数初始化对象;std::forward+...把外面传进来所有参数原封不动传给 T 的构造函数。普通
new Person会自动malloc内存 + 构造,这段代码要手动管控内存(模拟内存池),内存已经手动malloc拿到了,不能再用普通new重复分配内存,只能用定位新建原地构造。Q:这他妈不就是
new Person吗?为啥不直接new一步到位啊?A:失去了内存池的意义
标准new底层本来就分两步:分配内存、定位new构造对象,内存池就是把分配内存那一步接管自己实现,等于复刻了原生new的底层拆分逻辑。这么造轮子是因为:
系统 malloc/free 频繁调用存在锁竞争、内存碎片,高并发服务性能差;内存池一次性批量申请大块内存,后续取用归还无系统调用,大幅提速。
自有内存块可做内存复用,减少频繁向操作系统申请释放内存的内核开销。
适配自定义对齐、连续内存缓存场景,提升 CPU 缓存命中率,高性能服务必需。
C++默认对齐规则:对象起始地址必须是类内最大基础变量字节数的整数倍,防止硬件读取出错。
普通 new 场景:
- 结构体 Person 内部只有一个 long long 变量,这个变量占 8 字节,规定只按照类里最大成员对齐,系统自带 new 分配出来的对象,存放地址只能是 0、8、16、24、32、40、48、56、64 这类 8 的倍数。Person 整个结构体大小 16 字节,比如 56 刚好空闲,把对象放在地址 56 处:占用 56、57、58、59、60、61、62、63、64、65、66、67、68、69、70、71。CPU 缓存每一块固定 64 字节,第一块范围 0~63,第二块 64~127。这个对象前 8 字节落在第一块缓存,后 8 字节落在第二块缓存。
内存池自定义对齐场景:
- 同样的 Person 结构体,手动规定对象地址必须是 64 的倍数。只能放在 0、64、128、192 这类地址。地址 64 存放对象:占用 64 到 79,整块数据全部处于 64~127 这一块缓存里。
默认8字节对齐可能让对象跨两块缓存读取变慢,内存池强制64字节对齐让对象只占一块缓存读取更快(主流CPU硬件缓存行固定就是64字节,所以统一按64对齐刚好贴合硬件标准)
4、返回对象指针;
5、
main打印,手动析构,free释放内存。
关于.h里的deleteElement:
1、
template<typename T>:模板声明,T 为要销毁的对象类型;2、
if (p):判断指针不为空才执行销毁回收,避免空指针操作报错;3、
p->~T():手动调用 T 类型的析构函数,清理对象内部资源;单纯析构
p->~T ():只执行类内部的清理逻辑(释放成员指针、关闭文件等),完全不碰对象本身占用的那一块堆内存,内存地址依然有效,还能继续使用这块内存。
delete p是捆绑两步操作:1、自动调用p->~T()析构清理内部资源;2、调用底层operator delete,把对象本身的内存还给操作系统,这块地址失效,不能再读写内存池只用
p->~T()对象内部资源清理完毕,承载对象的内存完好保留,交给内存池回收存入空闲链表,下次分配直接复用;如果写delete p:清理内部资源后,直接把内存还给系统,内存池收不到这块内存,失去复用能力。手写
delete是捆绑析构+释放,智能指针是销毁时自动执行delete,而内存池场景要手动单独析构再回收内存不还给系统。查看代码
#include <iostream> struct Test { int* arr; Test() { arr = new int[10]; } ~Test() { delete[] arr; std::cout << "执行析构\n"; } }; int main() { Test* p = new Test(); delete p; // 自动先调用~Test析构,再释放Test自身内存 }4、
reinterpret_cast<void*>(p):把对象指针转为无类型裸内存指针;5、
HashBucket::freeMemory(..., sizeof(T)):传入内存地址与对象占用字节,调用之前看懂的回收函数,把内存还给对应内存池或系统;科普声明和定义区别:
声明:告诉编译器有这个东西,不给内存
extern int x;定义:给变量分配内存(学多了就知道这个是错的,类、模板都是定义不分配内存),全局只允许一处
int x;实现(函数专用)
声明:
void func();实现:
void func(){},补全函数内部逻辑
int x=10属于定义,同时附带初始化。
以上.h头文件
插一段:思考分离的写法(承接上一篇文章)
仅声明,定义宏、结构体、类、函数签名;模板newElement、deleteElement完整实现在此处;MemoryPool成员函数、HashBucket的initMemoryPool、getMemoryPool只写声明,无内部执行逻辑。
.cpp源文件:存放.h中仅声明未实现的全部函数完整代码,补齐类成员、静态函数执行逻辑。
useMemory和freeMemory是HashBucket内部静态成员函数,直接写在.h类定义体内,属于类内就地实现,不用丢到.cpp;
而initMemoryPool、getMemoryPool仅在.h写函数声明,函数体逻辑全部放到.cpp实现。
但这里其实还有个问题,
我的思考:这里其实有引了学3周的东西:单例、内存序、内存屏障等,见上一篇文章,懂了回来就可以看懂以下结论:
1、代码里友元没必要
2、getMemoryPool头文件类内实现没问题,函数内 static 局部数组全局唯一,不会多份实例。
3、initMemoryPool也可以放类里!狗逼豆包耽误我一天的时间艹他狗逼妈的!气死我了!一天又白费!操.你妈到底要砸多少时间啊!黑洞一样无穷无尽
我起初写了个测试:
查看代码
// test.h
#ifndef TEST_H
#define TEST_H
#include
class Test {
public:
// 类内直接写完整函数体,编译器自动隐式inline
static void show() {
static int cnt = 0;
cnt++;
std::cout << &cnt << " : " << cnt << std::endl;
}
static void add() {
for(int i=0;i<5;i++)
show();
}
};
#endif
//a.cpp
#include "test.h"
void func() {
Test t;
t.add();
}
// main.cpp
#include "test.h"
#include
using namespace std;
void func();
int main() {
Test t;
t.add();
cout<<"#"<<endl;
func();
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ a.cpp main.cpp -o main && ./main
0x56506a91c154 : 1
0x56506a91c154 : 2
0x56506a91c154 : 3
0x56506a91c154 : 4
0x56506a91c154 : 5
#
0x56506a91c154 : 6
0x56506a91c154 : 7
0x56506a91c154 : 8
0x56506a91c154 : 9
0x56506a91c154 : 10
root@VM-0-7-ubuntu:~/cpp_projects_2#
*/
类里的话for里自增始终全局一个,所以我觉得要放类外,而实践发现,类外依旧如此,所以我感觉这里initMemoryPool好像是每次都搞64个往后延而不是重复覆盖那同一个64个,但人家initMemoryPool搞的也不再是那个静态数组了。
首先都用静态函数是为了不需要任何对象实例就可以直接调用,而getMemoryPool内的static局部数组是一个包含64个MemoryPool对象的数组,这一整份数组全局仅此一份,
这64个是规格池子,每个负责固定一档槽大小:8B、16B …… 512B,一共 64 档。 程序可以产生成千上万个任务,任务申请内存时,按大小挑选这 64 个池子里对应的那一个去分配,任务数量不受 64 限制。
而initMemoryPool就是给每个池子初始化用的,并不是像我例子里一样做累加操作,我这个show()有多份代码副本,但static int cnt数据只有一份,所以所有副本都读写同一个 cnt,表现就是持续累加。add()多份代码副本,只是反复跑同一套逻辑去操作那一份 cnt。而内存池场景:getMemoryPool内部static MemoryPool memoryPool[64]【数据】全局唯一,没有多份池子,和你的 cnt 完全等价。
initMemoryPool写成头文件 inline,只会生成多份函数代码副本,不会生成多份池子数据。 风险不是出来多套池子数据,而是:多份代码副本,只要被调用,就一遍一遍执行 for 循环,去修改那唯一一份池子对象的成员。
我的add逻辑是cnt++,多次调用 = 同一个变量不断往上加; 内存池init()逻辑是firstBlock_=nullptr这类赋值重置,多次调用 = 同一个池子对象被反复重置状态。
从来没有说过池子数组会生成多个数据副本,从头到尾说的是:initMemoryPool 这个函数会有多份代码副本,代码副本会重复执行重置逻辑。 数据永远一份;执行逻辑可以被多次触发。
把两个东西严格分开:
-
函数的机器指令(代码):inline 会在每个编译单元生成一份指令副本,有多份。
-
函数内部 static 局部变量:C++ 标准规定,不管多少份 inline 指令,该变量在整个程序只有唯一一份实体,所有副本指令都去读写同一个内存地址。
getMemoryPool(i)返回那唯一数组里第 i 个 MemoryPool 对象的引用,getMemoryPool(i).init(xxx)就是拿到该数组里的这个对象,调用它的成员方法init()。
数组的 64 个对象本体早就已经构造完成(第一次进getMemoryPool就构造完毕),init()不会新建对象,只是对已经存在的对象内部各个成员变量做赋值重置:SlotSize_、firstBlock_、curSlot_等全部重写赋值。
init()不是构造函数,是业务重置接口:对象活着的期间,可以反复调用它,把这个池子打回刚构造完的空状态。
所以initMemoryPool的 for 循环每跑一轮:遍历数组里已经存在的 64 个存活对象,挨个调用它们的 init,把每个对象内部成员全部重新赋值一遍,这就是所说的 “覆盖重置对象内部状态”,不是新建数组、不是新建对象。
Q:所以不是新增任何池子而是做重置,那放头文件然后被多次重复重置又咋了?
A:狗逼豆包这里耽误了我一天的时间,说的全是屁话!!他表达的是,initMemoryPool放头文件实现没任何问题,此时是只有一个cpp里有initMemoryPool的调用,如果多个cpp都有然后你main里都调用也没问题,但注意别多次调用,就算多次调用也要放最开头,后续分任务代码
BenchmarkMemoryPool(100, 1, 10);之后就别再initMemoryPool了,因为多线程可能出现比如5个线程,刚执完第一个,你initMemoryPool直接给重置了,那就泄漏了,借此看下内存池流程:
-
main 里先执行
HashBucket::initMemoryPool();,先遍历所有内存池对象,调用每个池子的init()。 -
initMemoryPool循环 64 次,每次调用getMemoryPool。 第一次调用getMemoryPool,执行到函数内部static MemoryPool memoryPool[],数组完成构造。 后续剩下 63 次再进getMemoryPool,static数组已经构造完毕,直接复用已经存在的数组对象,不再重新创建。 -
此时才第一次触发池子内部
static MemoryPool memoryPool[]的构造,构造完成后立刻执行init(),成员置空,没有堆块。 -
接着运行
BenchmarkMemoryPool(100, 1, 10);,开始做分配释放。 -
BenchmarkMemoryPool内部循环执行newElement<P1>(),newElement内部调用getMemoryPool(sizeof(P1)),依据对象字节大小选出对应下标MemoryPool池子对象,调用该池子的Allocate()进行内存申请。 申请得到内存返回给P1指针使用。 执行deleteElement<P1>(p1),deleteElement内部调用对应池子的Deallocate(),将该内存槽归还至池子内部freeList_空闲链表,留给后续分配复用。 循环反复执行newElement、deleteElement,不停申请、归还内存槽,全程不再调用initMemoryPool,不会再次执行init重置池子成员。 -
之后运行
BenchmarkNew(100, 1, 10);,原生new‑delete对照测试。 -
程序执行到
return 0,main 函数结束,static MemoryPool数组生命周期结束,池子对象析构,各个池子的析构函数遍历firstBlock_链表释放全部堆上Block内存。
具体先搞好64个对象,然后具体用的时候再往里传入参数。
点评:
吃透inline 头文件实现、函数局部 static 初始化规则、init()重置与析构的边界、重复初始化带来的隐性泄漏,这是加分项,证明你不是抄代码,是抠过 C++ 对象生命周期、内存池内部状态。
面试的时候,讲内存池,主动点出:initMemoryPool不适合放头文件 inline,禁止多次调用;并且点出init()仅重置成员,不释放已申请 block,重复调用会丢链表造成泄漏,这会拉开和普通做项目的人的差距。
newElement、deleteElement是模板函数,模板实例化要求实现必须放在头文件先科普(豆包在这里无尽的误人子弟,真的好艰难):
CalcT<int>()单独用毫无任何意义可以说非法,加上名字就是CalcT<int>obj()是函数声明,返回CalcT<int>,无参的函数。
对比
A a(),CalcT<int>等价A,但CalcT<int>()作为参数就是搞临时对象:查看代码
#include <iostream> template<typename T> struct CalcT { CalcT() { std::cout << "构造CalcT\n"; } }; void f(CalcT<int>) { std::cout << "f函数执行\n"; } int main() { // 作为函数实参 f(CalcT<int>());//f(CalcT<int>{});也一模一样 } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main 构造CalcT f函数执行 root@VM-0-7-ubuntu:~/cpp_projects_2# */ /* #include <iostream> struct A { A() { std::cout << "构造A\n"; } }; void func(A x) {}// A是类型,形参只写类型名不加括号,且void func(A) {}也可以形参可以省略变量名,只写类型A,不给参数起名 int main() { func(A());// A()调用构造函数,生成临时对象做实参 } */
CalcT<int> obj{};定义有名对象,CalcT<int>{};是无名临时对象语句,CalcT<int>=()语法非法。class A { public: A(int x){} };1.
A a{1};(等价于A a(1)差别就是窄的事)
1是字面量,类型int编译器拿
int 1,匹配类A的构造函数A(int x)直接在
a的内存上执行这个构造函数,生成对象这里没有隐式转换,是直接初始化。
2.
A a = 1;
1是字面量,类型int编译器拿
1调用A(int x),生成临时 A 对象(这一步就是隐式转换:int → A)再用这个临时对象去初始化
a窄转换:把一个值,转换成目标类型存不下原值的隐式转换。
A a(x):圆括号初始化,不属于列表初始化,允许窄转换,编译通过。
A a{x}、A a = {x}:均为列表初始化,禁止窄转换!直接报错。struct A { explicit A(int) { std::cout << "哈哈\n"; } };
A a = {100};(有explicit就报错):
拷贝列表初始化,右边是
{100},编译器不会把数字偷偷转 A。直接拿 100 喂给构造函数。 C++ 单独定死一条:这种等号加大花括号写法,不许碰带explicit的构造函数,直接报错,纯粹死规则,和隐式转换无关。
A a = 100;(这是隐式转换)(有explicit就报错):
等号右边是裸字面值
100,不是已经写好的A类型对象。编译器去找接受 int 的构造函数
A::A(int),拿100作为实参,隐式造出一个临时的A对象。把这个临时
A用来做拷贝初始化a
A a = A{100};(有explicit也没事):
手写
A{100},直接调用explicit A(int)生成临时对象,使用该临时对象初始化aC++17 拷贝消除,不会发生拷贝函数调用,临时对象直接构造在
a的内存上。
A a{100};直接列表初始化(有explicit也没事):
直接在
a这块内存上调用构造函数A(int),实参传入100。
查看代码
// a.cpp
#include "main.h"
template<typename T>
T CalcT<T>::add(T a,T b){
return a+b;
}
// main.h
template<typename T>
class CalcT{
public:
T add(T a,T b);
};
// main.cpp
#include "main.h"
int main(){
CalcT<int>{}.add(1,2);// 临时构造一个CalcT<int>无名对象,直接调用add成员函数
}
可见放main.h里声明,a.cpp里实现, main.cpp里用直接链接错误。
5、友元可以分离写
查看代码
//main.h
#pragma once
#include <iostream>
class A{
private:
int x = 10;
friend void func(A& a); //友元声明,放头文件
};
//a.cpp
#include "main.h"
void func(A& a){ //实现写cpp,分离,友元不属于类成员不需要A::限定
std::cout << a.x << std::endl;
}
//main.cpp
#include "main.h"
int main(){
A obj;
func(obj);// main.h类内友元声明充当函数声明,main.cpp 包含头文件就无需额外声明
}
6、useMemory / freeMemory/ getMemoryPool/ initMemoryPool全都既可以放头文件也可以分离,那为啥有的分离有的类内?
-
useMemory/freeMemory逻辑极薄:做 size 判断、简单算术、转发调用其他接口。 放头文件类内做成隐式 inline:希望编译器直接展开,省去函数调用开销;没有复杂业务循环,即便多翻译单元生成多份副本,链接器合并,无副作用。 -
getMemoryPool语法允许写头文件类内;也可以声明放头、实现挪 cpp。 放头文件方便看完整逻辑;挪 cpp 可以把头文件变精简。 -
initMemoryPool语法允许写类内头文件;也可以分离到 cpp。 但如果写在头文件,每个翻译单元里的该函数副本一旦被调用,都会完整跑一遍 for 循环初始化 64 个池子,会重复执行 init 逻辑。 所以工程上习惯把它丢去 cpp,整个项目只有一份函数实体,确保初始化逻辑只被有意调用一次,避免意外重复初始化。
下面开始.cpp:
关于.cpp里的构造MemoryPool::MemoryPool(size_t BlockSize):
Q:MemoryPool::MemoryPool(size_t BlockSize) : BlockSize_ (BlockSize) , SlotSize_ (0) , firstBlock_ (nullptr) , curSlot_ (nullptr) , freeList_ (nullptr) , lastSlot_ (nullptr) {}怎么事?我只知道构造是
Person(int a, string n) : age(a), name(n) {},为啥参数不同,我这个age和name,参数和冒号后个数一一对应啊?A:括号里的参数 = 函数入参(只控制外部传进来的值),
Person(int a, string n)这里a、n是外界调用构造时传的参数,数量随便,和初始化列表行数没关系。冒号后
age(a), name(n)= 成员初始化列表(只给类内部变量赋值),列表写几行,只看你的类里有多少成员要初始化,和前面入参个数没有绑定规则。
age和name的例子,入参 2 个,初始化列表 2 行,刚好对应,而内存池是只需要外部搞来一个,剩下的直接就可以初始化(固定空指针)Q:懂了,那为啥搞个
::A:
namespace Kama_memoryPool { }:命名空间(隔离代码的容器),把内存池全部类、函数包进去,防止和别的库同名代码冲突,里面所有定义都归属于这个命名空间。
MemoryPool::MemoryPool(size_t BlockSize):这的::是作用域解析符号,前面MemoryPool是类名,后面MemoryPool是构造函数名(构造函数名和类名必须一模一样),整句含义:属于 MemoryPool 类的构造函数实现,写在命名空间 Kama_memoryPool 内部的源文件里。合起来完整含义:在
Kama_memoryPool命名空间下,实现 MemoryPool 类的带参构造函数,参数是 BlockSize。比如熟悉的例子:namespace Test{ Person::Person(int a) : age(a) {} }含义:Test 命名空间内,Person 类构造函数的实现。
Q:你他妈解释啥呢?老子问的是这个吗?我他妈问的是正常直接写了,为啥搞个解析符号!!
A:你之前写的这种,是写在类大括号内部,属于声明 + 简单定义,不需要作用域符号:
class Person{ public: // 直接在类里面实现,不用 Person:: Person(int a, string n) : age(a), name(n) {} int age; string name; };这里身处类内部,编译器知道当前写的是 Person 的成员,不用额外标记,但现在是,
.h头文件只放声明:MemoryPool(size_t BlockSize = 4096);,到.cpp文件单独写实现,脱离了类 {} 包裹,编译器不知道这个函数属于哪个类,必须用类名::指明归属。
Q:那为啥要分开啊?(妈逼的因为很简单,结果发现编译链接整套东西不亚于一个微型学科)
A: 先科普下编译链接咋回事吧(为什么感觉自己好笨,编译链接这好像别人也没像我研究、追问这么久啊,这些都不懂咋干活啊?编译效率啥的,难不成一股脑全编译?银行外包测试张苏的讥讽“这小子没干过项目制”):
分开仨文件:
print.h文件:void PrintHello();
print.cpp文件:#include "print.h" #include <iostream> void PrintHello(){ std::cout << "初始内容" << std::endl; }
main.cpp文件:#include "print.h" int main(){ PrintHello(); }操作 1:第一次完整编译,终端依次输入两条命令g++ -c main.cpp #等价于 g++ -c main.cpp -o main.o g++ -c print.cpp目录多出
main.o、print.o,这两份是编译缓存结果(机器码,也叫目标文件)然后链接生成程序:
g++ main.o print.o -o app运行
./app(叫可执行文件),输出:初始内容操作 2:只修改
print.cpp打印行:std::cout << "修改后的内容" << std::endl;此时
main.o文件完全没改动,不用重新编译main.cpp,只单独编译改动的文件:g++ -c print.cpp只用上面这一行,不会处理
main.cpp。再链接旧的
main.o和新的print.o:g++ main.o print.o -o app运行
./app,输出:修改后的内容而如果直接一次性
g++ main.cpp print.cpp -o app就相当于,全部代码写在单个main.cpp#include <iostream> void PrintHello(){ std::cout << "初始内容" << std::endl; } int main(){ PrintHello(); }第一次编译:
g++ -c main.cpp g++ main.o -o app修改函数内部打印文字后,还要重新编译:
g++ -c main.cpp g++ main.o -o app这也就是
g++ main.cpp -o app一步合并「编译 + 链接」,先内部自动执行-c生成临时.o,再立刻链接,不会保留.o文件,不是单纯打包。总结:
分离
.h/.cpp+-c拆分编译:仅改动实现文件时,只执行g++ -c print.cpp,main.o直接复用,少一份文件编译,速度更快;单文件不分离:只要改动任意代码,必须完整重编译整个文件,无缓存复用;
所以可以看到,头文件
.h只声明+.cpp单独实现的分离式写法,对比全都在一个.cpp里,解决两个核心痛点1. 多人协作开发提高编译速度
A 写内存池逻辑(MemoryPool.cpp),B 写压测代码(UnitTest.cpp),如果所有实现全塞头文件里,B 一改测试代码,A 这边所有依赖头文件的代码全部要重新编译,巨慢;拆分后:改 cpp 实现,其他文件不用重编,编译速度大幅提升。
2. 对外只暴露接口,隐藏底层复杂细节
以后你把这套内存池封装成工具给别人用,别人只看
.h,知道怎么调用 newElement、初始化池子,cpp 里一堆扩容、空闲链表、内存分配的底层脏逻辑全部藏起来,使用者不用看懂复杂实现,只调用接口就行。3. 如果实现放入
.h,cpp多次引入就会,每引入一次就复制一遍代码,程序体积变大;总结:
你现在只写小 demo,就一两百行代码,不分家看着舒服;一旦项目上万行、十几个工具类、多人同时开发,不分家会出现:
各种类名重复冲突,到处改名字;
改一行底层代码,整个项目全部重新编译,等待几分钟;
别人想用你的工具,一打开头文件几百行底层逻辑,分不清哪些是可调用接口。
继续补充几个东西:
void func();、int func();仅返回值不同,编译报错。
void PrintHello(){ }、void PrintHello(int num) { }参数不同,属于函数重载。声明
void func();可重复书写,带函数体的定义void func() {}仅能存在一次,不可以重定义。
print.cpp引入print.h:把声明和实现放在同一编译单元,若出现同名同参仅返回值不一致会当场报编译错误,提前拦截问题,避免等到链接阶段才发现错误。
main.cpp不包含print.h,写PrintHello();编译直接报未声明标识符。完整编译链接流程(你的三份文件)
文件:print.h、print.cpp、main.cpp1、预处理:
#include "print.h"直接把头文件文本粘贴进当前.cpp
main.cpp粘贴 print.h,拿到void PrintHello();声明,识别函数调用
print.cpp粘贴 print.h,声明和自身实现同一份代码,编译时校验签名匹配2、编译:
g++ -c分别处理两个 cpp,生成main.o、print.o目标文件
print.o存PrintHello函数机器码
main.o只记录要调用PrintHello,无函数实体3、链接:合并所有
.o,匹配符号:把 main 里的函数调用,绑定 print.o 里的函数实体,生成可执行程序4、运行:执行生成的程序文件
插一嘴:
查看代码
// test.h #pragma once class A{ public: static int val; // 仅声明,无内存 }; void print_val(); //a.cpp(唯一存放静态变量定义的文件) #include "test.h" int A::val = 999; // 全局唯一定义 // b.cpp(只包含头文件,无定义) #include <iostream> #include "test.h" void print_val(){ std::cout << A::val << std::endl; } // main.cpp(入口,只调用函数) #include "test.h" int main(){ print_val(); } // 命令: g++ main.cpp a.cpp b.cpp -o run //所有文件只需要包含头文件就能识别静态变量;只需任意一个 cpp 写一次定义,链接时全局共享,其余文件不用包含该 cpp 也能正常使用。
关于.cpp里的MemoryPool::~MemoryPool(){析构:
首先回顾我自己写的迭代版本3里的,
业务调用
pool_free(ptr)(用户归还小块内存,准备复用重复分配),只是还给内存池内部空闲链表,不还给操作系统。
GlobalMemoryPool类析构函数~GlobalMemoryPool(),程序退出时销毁整个内存池单例,属于内存池析构,所有大块内存还给操作系统(进程内存占用下降)。如果不手动析构,短程序无所谓,进程退出 OS 自动回收全部堆;服务器长时间运行服务会持续堆积大块内存,无法主动释放,内存占用只涨不跌、易内存溢出。
不管内存池有没有手动析构,只要进程退出,操作系统都会直接回收该进程全部物理内存;内存池析构只是进程内部堆层面归还,不影响进程退出时OS的全局回收行为。
进程退出是操作系统直接回收该进程全部物理内存;内存池析构调用free仅把内存还给进程堆管理器标记空闲,是一种代码层面的告知行为,实际物理内存不会立刻归还操作系统。
继续回顾这个代码随想录之前说过的,
deleteElement里的p->~T(),如果不写的话,对象内部句柄、堆指针、锁等资源不会释放,复用这块内存新建对象会造成资源泄漏,相当于大力拍虫子时候,带刺的虫子的刺还留在肉里,delete p等价执行p->~T()+operator delete(p),是拔刺 + 连承载内存一并丢掉,池子没法复用这块内存。
所以总结发现
原生
new=operator new申请裸内存 + 定位new执行构造;原生
delete= 手动析构 +operator delete归还内存给系统堆。内存池要复用内存,必须分开操作:分配时先从池子拿裸内存,再手动定位
new构造;释放时先手动调用析构清理资源,再把裸内存归还池子空闲链表,全程避开operator delete,防止内存交还给系统、脱离池子管理。operator delete只在最后析构时候用,即整个内存池生命周期结束Q:但我之前手写版为啥
free和malloc,代码随想录的是new和delete族的?A:先注意:
new T比纯 C 的malloc多了 C++ 的异常、重载等适配然后回答:
你手写内存池:写的时候只想要最基础、Linux 原生裸内存分配接口,只做固定块切片链表,全程没用到 C++ 对象构造、重载分配器、异常这套 C++ 专属能力,直接用 C 标准
aligned_alloc/malloc/free最简,不需要引入 C++ 分配接口。仅裸内存分配,失败返回空指针;无对齐保障;无异常;和构造析构完全无关;只能搭配 free 释放
代码随想录的分级哈希内存池:整套代码是为 C++ 面向对象业务设计,封装了
newElement/deleteElement模板,依赖定位 new、分配失败默认抛std::bad_allocC++内存异常体现、支持全局 / 类重载适配内存池,配套整套 C++ 对象生命周期体系,必须配套 C++ 原生operator new/operator delete才能和上层对象创建销毁逻辑打通
malloc/operator new只干一件事:拿一块无类型裸内存,二者底层都依赖操作系统堆,理论上可以互换
那这里
MemoryPool::~MemoryPool(){析构就是整个释放掉内存池还给系统(operator delete(cur)只是把整块内存还给进程内部的堆管理器,打上进程内空闲标记,不会立刻归还操作系统物理内存),对应自己写的迭代3的~GlobalMemoryPool ()你手写池的
pool_free(ptr)(归还小块到空闲链表、留着复用),对应随想录的两步组合:p->~T()+deallocate(ptr)
p->~T():清理对象内部资源;
deallocate:把内存槽放回池子空闲链表,等待下次分配复用Q: 我手写的迭代3代码咋没析构?
A:你的内存池只分配纯粹裸内存块,没有封装对象构造逻辑,压根没用来存放带构造 / 析构的 C++ 对象,自然不需要手动调用
p->~T()。你的使用方式是:分配拿到内存后直接(char*)pool_alloc() + 8使用,只把这片 64 字节内存当作普通缓冲区,不存在类实例、成员资源、需要析构释放的内部资源;没有对象,就不存在要执行的析构函数。
至此,起初狗鸡巴都不会,问豆包代码咋上手看,给出宏观框架,先阅读.h里的东西,主要是学了些基本语法!!现在感觉自己牛逼了,语法学懂了,看.cpp的时候不打算再按照函数顺序看,而是,很好用的提示词:
给我一个串联,比如说内存池,你基础开板你想干嘛?然后这个时候你可能说你先申请内存池,或者是说你想先申请什么东西,就是你给我一个串联一个脉络,就是你通过你想干嘛,内存池应该干嘛,然后你引出我 C++ 里面的代码怎么实现的,而不是说就给我无穷无尽的罗列堆砌函数,没有任何思路,我串联不起来


浙公网安备 33010602011771号