














开始说细节:
一些语法直接备注到了代码里,省着看一眼代码看一眼博客费劲
代码思想直接就写博客里,
但如果博客只写重要的思路和疑难,不提那些【注释只在代码里的函数】就会比较乱,因为对于这种陌生代码只有清楚知道看代码块的流程,先看哪个函数后看哪个函数,才能懂,所以只注释在代码里的这里直接提一句。
关于.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"= 传入函数的实参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、拷贝构造
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)); }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 实例,赋值给外部变量ptrA 这里写成了对象,那就是返回值是对象类型,然后 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(){},补全函数内部逻辑
以上都是.h头文件的:
仅声明,定义宏、结构体、类、函数签名;模板newElement、deleteElement完整实现在此处;MemoryPool成员函数、HashBucket的initMemoryPool、getMemoryPool只写声明,无内部执行逻辑。
.cpp源文件:存放.h中仅声明未实现的全部函数完整代码,补齐类成员、静态函数执行逻辑。
useMemory和freeMemory是HashBucket内部静态成员函数,直接写在.h类定义体内,属于类内就地实现,不用丢到.cpp;
而initMemoryPool、getMemoryPool仅在.h写函数声明,函数体逻辑全部放到.cpp实现。
但这里其实还有个问题,
我的思考:这里其实有引了学3周的东西:单例、内存序、内存屏障等,见上下一篇文章,懂了回来就可以看懂以下结论:
1、代码里友元没必要、
2、为了好看方便,但实际getMemoryPool写在头文件类内实现没问题,函数内 static 局部数组全局唯一,不会多份实例。
下面开始.cpp:
关于.cpp里的构造MemoryPool::MemoryPool(size_t BlockSize):
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文件:#include "print.h" #include <iostream> void PrintHello(){ std::cout << "初始内容" << std::endl; }
main.cpp文件:#include "print.h" int main(){ PrintHello(); }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,只单独编译改动的文件:只用上面这一行,不会处理
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 字节内存当作普通缓冲区,不存在类实例、成员资源、需要析构释放的内部资源;没有对象,就不存在要执行的析构函数。
关于.cpp里的:
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。