续啃《代码随想录》的《内存池项目》 —— 上一篇是关于分离写法衍生的思考,这篇开始逐行代码死磕追问豆包 + 无尽思考实践串联(说无锁必考,啃透彻了无锁编程及其他所有相关,然后再问就他妈给我一顿道歉说这玩意不考,是高级才要会的,浪费好多时间),又续啃编程指北的ptmalloc细节自主扩展程度也堪比中级了
看代码随想录的 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。
但这里哈希的例子(异或公式),或者 内存池的
((size + 7)/8)-1公式都是叫散列函数,都有可能发生碰撞,也就是不同输入算出同一个下标,差别是内存池就要搞到同一个池子,不打散。
而哈希防止热点桶,必须再做一步打散:
拉链法:同一个下标挂一条链表。
开放寻址法:向后找数组里下一个空位存放。
第二层:单个 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 字节内存当作普通缓冲区,不存在类实例、成员资源、需要析构释放的内部资源;没有对象,就不存在要执行的析构函数。
关于.cpp里的Slot* MemoryPool::popFreeList(){ :
freeList_是空闲链表,如果为空就申请内存块,如果非空(比如1->2->3)那就从1开始拿出来使用。
我的思考:
首先开开胃说个简单的 —— try就是个摆设:
try块内,直接写throw,或是调用的函数内部间接throw,才会抛错误,例如
try{throw int(100); },catch(...)捕获从try块抛出来的任意throw异常。可是事实上这里的
std::atomic::load()成员函数本身不会抛出 C++ 异常,标准库atomic的 load 不抛异常。
oldHead->next这一步:解引用指针,如果oldHead是非法地址:CPU 报段错误 (SIGSEGV),Linux 进程收到信号直接终止,SIGSEGV 是内核给进程的信号,不属于 C++ 异常机制,catch(...)对信号不起任何作用,这里整个 try 作用域里面,不存在任何会执行throw的代码
此处狗逼豆包气疯我了!!!但通过错的不考的也多了些思考,加深对CAS无锁编程的理解
首先豆包说的是,初始 freeList_=X:
线程 A:
oldHead = freeList_.load()获取节点 X。线程 B:执行 popFreeList,CAS 成功把 X 从空闲链表摘除,X 交给别的线程使用。
线程 A:因为oldHead 是线程 A 本地拷贝,freeList_已经被 B 修改,但 oldHead 本地值不会自动刷新,
if (oldHead == nullptr) return nullptr;不会返回,执行newHead = oldHead->next.load(),此时 X 已经脱离空闲链表,B 线程也在访问 X 内部的next原子成员。豆包就说【物理内存没有释放,硬件地址有效,大概率不会崩溃,但逻辑出错】,但这他妈的没任何问题,
首先
freeList只是一个空闲链表,比如此时连接a->b,说明a、b两个小内存块是空闲,此时如果a、b都被用了,那freeList为空,可是a的next指针依旧指向b,只不过用不到a的next指针而已,内存块的布局永远有next这个指针,那:
A
load得到X,挂起B
CAS成功摘除X,freeList_变为nullptr,链表彻底空A 恢复,本地
oldHead仍为X,跳过判空返回(因为本地oldHead不空),读取X‑>next得到旧残留值,执行CAS;预期oldHead(X) 和全局freeList_(nullptr) 不匹配,CAS失败,回到循环开头,执行oldHead = freeList_.load ()读到nullptr,函数return nullptr。所以无非是多执行了一次CAS而已,没任何错误。
啥叫 UB:
并发无同步读写同一个非原子对象构成数据竞争,数据竞争会导致未定义行为UB
访问已经销毁的对象(解引用指向已销毁对象的指针)也是 UB,但这里不保证一定出现段错误崩溃:
如果该内存已经还给操作系统,地址无效触发段错误,程序终止。
如果内存只是逻辑销毁,但物理页还在进程地址空间内,不触发段错误,只会读到垃圾值,程序继续运行。
所以本身没UB问题,豆包说有UB导致我满心欢喜的给了如下结论:
Q:既然有UB问题,代码随想录为什么这么写?
A:
目标是简历项目,要展示无锁链表知识点。如果非UB就很复杂完全不是我该学的(淹没内存池核心业务逻辑不适合当demo改动巨大,C++之父考虑的东西),或者该互斥锁牺牲性能。
内存池特性:槽不会归还系统内存,硬件层面可以兜底,现实运行几乎不暴露问题。
好奇为啥搞这么麻烦,直接修改全局的freeList链表呢?非要搞个局部的newHead和oldHead?
这里被狗逼豆包耽误了2天时间,导致有如下感想:
上一篇文章搜“例逻辑)(代码”里搞局部是为了配合内存序,而这里局部是为了搞自己的小箱子不干扰CAS的比较逻辑
基于 C++17 实现并发小对象内存池,采用原子指针构建无锁空闲链表,实现 pop 逻辑的时候重点辨析了全局共享状态与CAS线程私有中间快照:链表头freeList_是共享原子状态,代表内存池客观的空闲集合;而oldHead、newHead属于每一轮 CAS 操作的线程本地中间快照变量,必须存放于函数栈帧,不能提升为对象成员。即便给中间变量加上 atomic,只要变成共享存储,多线程下会发生快照被篡改,引发 slot 内存重入分配,造成别名访问。atomic 只解决内存可见性与指令重排,无法规避业务逻辑层面共享存储带来的快照覆盖问题,借此掌握原子原语能力边界:
CAS 线程本地快照:oldHead/newHead,每轮读取共享头得到的瞬时快照,必须线程私有
共享持久状态:freeList_,池子唯一客观状态,多线程共同读写
内存别名分配:同一个 slot 指针返回给两个不同调用方
原子原语能力边界:atomic 只处理内存模型,不能防护上层业务逻辑的数据篡改
表达的技巧(放大优势)
不要先说功能,直接抛出这个陷阱点,面试官立刻就知道你不是看博客抄 demo。
主动抛出反例:“我推演过,如果把本地快照变量改成类成员,哪怕全部原子修饰依然出错”,证明做过逆向推演,不是看懂现成代码。
落脚点:原子不是万能银弹,无锁结构难点不在调用原子 API,在于区分哪些状态要共享、哪些快照必须隔离在线程栈。
事实上,全局和局部都没问题,
局部变量本身想保证的是自己内部小箱子作为期望值,如果一样说明中途没人动过,如果不一样说明被改动过,只需要遵守CAS规定,判为失败,然后将预期值改为当前原子变量真实值,接着重新进入CAS即可,
局部里比如1->2->3,
A线程开始干活,读取freeList此时的1,old为1,new期望为2,此时B线程插入进来,把2拿走了,freeList指向2,old如果是全局会被线程B改为1(其实是没变),然后线程B用1,freeList为2,假设再用一个,freeList为3,old为2,此时回到线程A,此时CAS里freeList已经是3,old无论是否重新读取都和之前存的1或者新的2不等,直接赋值重试即可
而new就算为全局,啊........不对,写到这豆包又道歉了,说全局确实会引发问题
执行线程 A:共享 oldHead=1,newHead=2,待执行 CAS
切到线程 B:B CAS 成功,freeList_变为 2,节点 1 被 B 取走,oldHead是1,
切到线程 C:C 读取共享 oldHead 得到 2,
切回线程 A:此时 freeList_恰好等于 2,执行 CAS 成功,拿走节点 2,new是2,所以free也是2
所以用了2下次别人还会用2。
有点类似 ABA 问题
搜无锁编程属于啥岗位,说 高级开发 的 楼教主
学了2周,居然这么高级,
牛客网看了个帖子什么优化内存池,评论说你会内存池吗不会别写,博主说简历注水,标题确实求一份工作,当时以为人均C++之父的水平都没工作,吓得不敢再看任何平台了、贴吧乌烟瘴气劝退、知乎全是戾气劝退,闭门造车问豆包,结果经常给我说必须学必考,然后说这个太深了对不起不考,这个是错的对不起,又说无锁必考,学完CAS又说是中级的C++,百度说高级的。
可是残酷的行业规则
豆包说各大鱼皮、编程指北等博主不会回复我,为了自己的口碑,不会承担风险帮我引荐,一旦回复就是质变
职场公司是投资不是慈善
医院一个病人就是一单生意,限额到了就撵人
手里核武器级别的加分项只作用于面试室内,它解决不了社招制度层面的门槛,进不去面试屋
岗位适配性,厚重的经历没用,不能拿来换工作
。
思考写法是否可以优化:
查看代码
Slot* MemoryPool::popFreeList(){ while (true){ Slot* oldHead = freeList_.load(std::memory_order_acquire); if (oldHead == nullptr) return nullptr; // 队列为空 Slot* newHead = nullptr; newHead = oldHead->next.load(std::memory_order_relaxed); if (freeList_.compare_exchange_weak(oldHead, newHead, std::memory_order_acquire, std::memory_order_relaxed)) return oldHead; } } /* 代码随想录的这个写法啰嗦了,直接循环CAS不就行了,CAS失败了会直接把原子变量的当前真实值赋值给预期参数(oldHead), 所以这个大while写法,只需要挪动oldHead的判空,然后期望更新的新值直接不用newHead变量 我的做法是如下: */ Slot* MemoryPool::popFreeList(){ Slot* oldHead = freeList_.load(std::memory_order_acquire); if(oldHead == nullptr) return nullptr; while(!freeList_.compare_exchange_weak(oldHead, oldHead->next.load(std::memory_order_relaxed), std::memory_order_acquire, std::memory_order_relaxed)){ if(oldHead == nullptr) return nullptr; } return oldHead; }先解释下:
while(!freeList_.compare_exchange_weak(oldHead, oldHead->next.load(std::memory_order_relaxed), std::memory_order_acquire, std::memory_order_relaxed)){整个CAS是原子,
oldHead->next.load()也是原子 load,但它在 CAS 外部执行,不属于原子事务,两步中间可被其他线程打断。科普术语:
oldHead叫局部指针变量。
oldHead->next.load () 叫成员函数,因为
next是Slot内部的std::atomic<Slot*>成员,.load()是std::atomic的成员函数假设第一个也是成员函数,那这语句的顺序是,先无固定顺序的原子的执行完两个参数运算,期间可能有其他线程插进来,然后原子的执行CAS逻辑。
做流程复盘验证:
场景 1:正常 CAS 成功,链表 1‑>2‑>3,
freeList_ = 1
oldHead = freeList_.load()→oldHead = 1
oldHead != nullptr,跳过 return进入 while 条件:计算第二个实参
oldHead->next.load()得到 2,调用compare_exchange_weak全局
freeList_等于oldHead(1),CAS 成功;把freeList_修改为 2;compare_exchange_weak返回 true
!true为 false,不进入循环体,跳出 while
return oldHead;返回 1,弹出完成。场景 2:其它线程竞争,抢占走节点,
freeList_被改成 2
oldHead = freeList_.load()→oldHead = 1不为空,往下走
while 条件,取
oldHead->next得到 2,执行 CAS全局
freeList_已经是 2,和预期oldHead(1)不相等,CAS 失败CAS 把全局真实值 2 写入
oldHead,返回 false进入
{}:oldHead=2非空,不 return回到 while 条件:计算
oldHead->next.load()得到 3,执行 CAS 尝试把freeList_改成 3场景 3:纯虚假失败,无其它线程修改,
freeList_ =1不变
oldHead = freeList_.load()→oldHead =1不为空
while 条件,取
oldHead->next得到 2,执行 CAS没有外部修改,但
compare_exchange_weak发生虚假失败CAS 将全局真实值 1 写入
oldHead,返回 false进入
{},oldHead=1非空,不 return回到 while 条件,再次读取
oldHead->next得到 2,再次执行 CAS,大概率成功,跳出循环,returnoldHead(1)场景 4:并发下别的线程把
freeList_置nullptr
oldHead = freeList_.load()→oldHead=1不为空
while 条件,取
oldHead->next得到 2,执行 CAS全局
freeList_已经变为nullptr,CAS 失败CAS 把
nullptr赋值给oldHead,返回 false进入
{},命中if(oldHead == nullptr),直接return nullptr,函数结束,不会回到 while 条件,规避空指针解引用。
关于.cpp里的void MemoryPool::allocateNewBlock(){:
BlockSize和BlockSize_是4096表示申请的大内存池,然后给void*类型的newBlock,
然后
reinterpret_cast<Slot*>(newBlock)->next = firstBlock_;:
CPU 只看字节,类型是编译器给内存的解析规则,new int 就是告诉编译器按 int 规则解析对应内存,这里扒开底层没写new int所以你operator new后还需要想new int一样做个解释,因为内存地址单纯只是数字,硬件层面它完全没有类型,CPU 只认字节。真正起作用的是编译器的类型规则,此处
reinterpret_cast<Slot*>(newBlock)就是告诉编译器:把这块原始字节内存,按照Slot结构体的内存布局去解析访问,只有转成Slot*以后,编译器才认得->next这个成员偏移,才能访问对应内存位置写入链表指针,经过reinterpret_cast<Slot*>(newBlock)之后,编译器把该地址当作Slot实例起始地址:按照Slot布局,起始 0‑8 字节就是 next 成员,->next就直接往0-8偏移量读写数据,这块 4096 字节大块只把开头这一段按 Slot 结构解析,其余字节编译器不管,不做这个强制转换,newBlock是void*,void*不能使用->访问成员。这里把新内存块强转成解释成Slot指针,将它next指向旧块链表头firstBlock_。
然后
firstBlock_ = reinterpret_cast<Slot*>(newBlock);:
上一步的 cast 只属于表达式临时结果,不会改变 newBlock 本身的类型
我觉得只要强转为Slot*就要用他的next成员,否则内存布局没有意义啊,但其实不是,内存布局只在你解引用(写
->)的时候才生效;只拷贝指针地址,完全不消耗、不使用该内存布局。
写
reinterpret_cast<Slot*>(newBlock)->next:才启用 Slot 布局,取前 8 字节当作 next。写
firstBlock_ = reinterpret_cast<Slot*>(newBlock):仅仅复制地址值,完全不碰那块内存的前 8 字节,内存布局在这里根本没有参与运算。强转只是给地址贴个
Slot*标签,标签不等于必须动用结构体布局,只有->才会把布局拿出来用。
firstBlock_单纯存新申请大块内存的首地址, 后续写firstBlock_->next拿这个首地址,按Slot布局,读取该地址开始的 8 字节,也就是大块内存头部那 8 字节,存放的就是上一个旧大块内存的地址。然后
char* body = reinterpret_cast<char*>(newBlock) + sizeof(Slot*);:
sizeof(Solt*)是8字节,这样跳过前面的8字节然后
size_t paddingSize = padPointer(body, SlotSize_);:
结构体内部补填充字节、内存池对槽位做地址对齐、原子类型对齐,底层都是同一套硬件对齐规则,然后基本全部自动对齐,比如结构体
int+double编译器自动在 int 后面插入填充字节查看代码
#include <iostream> struct S { int a; double b; }; int main() { std::cout << "sizeof(int) = " << sizeof(int) << '\n'; std::cout << "sizeof(double) = " << sizeof(double) << '\n'; std::cout << "sizeof(S) = " << sizeof(S) << '\n'; std::cout << "alignof(S) = " << alignof(S) << '\n';// 结果为 8,由最大成员 double 的对齐决定,alignof(S) 获取类型的对齐要求 } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp a.cpp -o main && ./main sizeof(int) = 4 sizeof(double) = 8 sizeof(S) = 16 alignof(S) = 8 root@VM-0-7-ubuntu:~/cpp_projects_2# */内存池是手动管理原始裸内存,编译器不会帮你,必须手写代码算出填充字节,手动把指针挪到对齐地址。
那普通的变量,没对齐顶对跑慢点,而原子CAS、load、store 这些原子硬件指令,是CPU硬性规定必须对齐,否则判定为UB,运气好崩溃,运气差,原子指令直接丧失原子性,表面程序正常跑,多线程逻辑彻底乱掉,极难复现、极难查 bug。
char*是指针类型,C++ 语法不允许指针直接做%取模运算,%只能用于整数类型,所以必须reinterpret_cast<size_t>(p)把指针转成地址整数才能取模,且比int的有符号的范围广。rem 为 0:地址已对齐,返回 0,无需填充;否则返回 align‑rem,即需要向后跳过的填充字节数。
假设槽大小 32 字节,104 地址:104 ÷32 =3 余 8,不是 32 倍数。 如果你强行把 32 字节槽直接放在 104 地址,部分 CPU 访问未对齐地址会直接崩溃;就算不崩溃,读写速度大幅下降,所以不能直接在 104 放槽。 104 往后挪 24 字节到 128,128 是 32 倍数,才可以存放 32 字节槽。 且对齐填充产生的24字节就是内部碎片,纯粹浪费。
至于为啥按照32来对齐,其实这只是一个巧合,因为64位CPU 内存总线一次访问粒度 8 字节,正好内存池分配的都是8的倍速,所以自然用任意槽子来做对齐,反例假如业务逻辑是7的倍速开操作,那必须按照8的倍速来对齐了。
然后
lastSlot_ = reinterpret_cast<Slot*>(reinterpret_cast<size_t>(newBlock) + BlockSize_ - SlotSize_ + 1);:
标记本大块内存里允许的槽的上界(终止边界),代表不能超过这个地址分配槽,防止越界写到块外面
newBlock是整块内存起始地址,加上整块大小BlockSize_,往回退一个槽大小SlotSize_,再加 1,得到边界指针。它不是合法槽地址,是警戒边界;分配时判断curSlot_ < lastSlot_,成立说明块内还有空间可以切槽。随便举例子 ,1~10,然后想用的槽子是5,1+10-5+1直接得到7,最后一个合法的是6~10的6,而7就是非法的,≥都不行
关于.cpp里的void* MemoryPool::allocate(){:
curSlot_来源
allocateNewBlock开辟新大块的时候初始化curSlot_, 分配槽:先把当前curSlot_存到 temp 作为返回地址,再执行curSlot_ += SlotSize_ / sizeof(Slot)向后移动,假设移动后槽耗尽,但本次分配还能用,不会触发开辟新块,下一次进入 allocate才会执行curSlot_ >= lastSlot_判断,才去开新 block。块尾部残余零碎字节 块末尾剩下不足一整个
SlotSize_的字节,直接丢弃,不会拿来分配。不会做碎片整合,直接浪费。这套内存池不处理尾部碎片。关于
curSlot_ += SlotSize_ / sizeof(Slot);:
指针算术规则:C++ 里
Slot*做 += 运算,单位不是字节,是sizeof(Slot)。
curSlot_ += 1→ 地址增加sizeof(Slot)字节。
SLOT_BASE_SIZE:源头基数,生成槽字节,而具体怎么靠指针在大块上移动,就要用sizeof(Slot);,适配指针步长计算规则,比如你需要32的槽子即
SlotSize_是32,sizeof(Slot);是8,除法发现有4个,那就curSlot指针移动4,假设Slot结构体不是8字节,抛弃
Slot*做指针自增,全部改用char*做字节偏移,直接按真实槽字节移动,不受结构体sizeof影响:char* curSlot; curSlot += SlotSize_;,需要链表节点时再reinterpret_cast<Slot*>(curSlot)转类型。每个 newBlock(4096 字节大块)最开头 8 字节是
Slot* next,用来把各个 block 串成单向链表;第一个 block 该字段初始为空,后续新 block 头插,该字段指向之前的firstBlock_。每个 block 最开头那 8 字节就是Slot结构体,仅存atomic<Slot*> next指针,用来串联各个内存块。误区:
我一直误以为这个next的Slot是大块有的,即4096大块前8个字节用来当作串联指针,把所有4096串起来,可是事实上有个细节注意点,假设用32字节的槽子,
32 字节槽分配出去:业务完整占用全部 32 字节,前 8 字节业务随便覆盖改写。
deallocate归还时:reinterpret_cast<Slot*>(ptr),把这块内存起始 8 字节原地当做std::atomic<Slot*> next写入链表指针,接入freeList_空闲链表。其实我突然回忆起一件事,这里搜“分身术”当时辱骂豆包,但其实就是如此啊,既然在 free 的时候当 next 又可分配出去当数据来用。
然后此文搜“更优秀,结果直接段错误了”是解读错误的问题,这里都懂了只是真的好复杂。
至此,起初狗鸡巴都不会,问豆包代码咋上手看,给出宏观框架,先阅读.h里的东西,主要是学了些基本语法!!现在感觉自己牛逼了,语法学懂了,开始串联(提示词):
给我一个串联,比如说内存池,你基础开板你想干嘛?然后这个时候你可能说你先申请内存池,或者是说你想先申请什么东西,就是你给我一个串联一个脉络,就是你通过你想干嘛,内存池应该干嘛,然后你引出我 C++ 里面的代码怎么实现的,而不是说就给我无穷无尽的罗列堆砌函数,没有任何思路,我串联不起来
碎碎念:
之前我刷算法题就好奇,北邮那些导师真不一定代码能力强,他们咋审考研手写代码的啊,好疑惑。连acm圈的q神都不看别人代码。
另外编程指北说没几个面试官有你清楚,那我的内存池代码真的很多面试官自己都看不懂啊,这他妈他咋考我????说真的我不觉得写这个内存池项目的代码随想录他本人有我理解的透彻!!!
高潮大串联:
纠正误区:
和豆包沟通半天,我一直错误的以为一次申请4096给任务用
比如用8字节就切0~7,然后用16字节就切8~23,大错特错!!
假设两个任务一个用8,一个用16字节,那就开两个4096块。
闲谈几个前设梳理:
每一个内存池实例各自拥有自己独立的 freeList_二手空闲链表,64 个池子就有 64 条互相隔离的二手链表,
MemoryPool.h里std::atomic<Slot*> freeList_;,freeList_是MemoryPool类的成员变量。 每创建一个MemoryPool对象,就伴随生成一条属于它自己的二手链表。HashBucket::memoryPool[64]数组有 64 个 MemoryPool 对象,于是就有 64 条独立 freeList_,互不串门。
关于
curSlot_:属于单个 MemoryPool 对象。 它存的是当前 4096 大块中下一个全新槽的起始地址。 只用来取还没被业务用过的全新槽。
比如大块4096,是给0号池子或者叫槽子用的,那就是一律统一都切割8字节,你用了一个,8字节没了(0~7),该从8开始切割了 ,curSlot记录的就是8。
关于
lastSlot_:这块 4096 里面最后一个合法全新槽的起始地址,它的地址一定小于整块 4096 内存的末尾地址,到不了 4096 字节的最末尾。
假设不用4096用100方便举例子,整块起始地址1,槽大小10,lastSlot存地址91。
对比写法 :
写法一、
int* p = new int(10);对外看不到operator new,分配内存、构造对象两步语言层面绑定封装在一起,写法二、拆分:
void* raw = operator new(sizeof(int)); int* pi = new(raw) int(10);//raw与pi地址完全相等。 new(raw) int(10)返回值就是raw,只是做了隐式类型转换成int*new(placement new)叫定位new语法,括号内
raw就是指定的内存位置,定位到这然后构造int数据。
new(p):p是已经拿到的裸内存指针,告诉编译器不要去堆分配内存,直接使用p指向这片已经存在内存。无论哪种写法的int(10)都不是临时,是定位 new 直接在 raw 指向的内存上就地构造 int 对象,int (10) 是构造初始化语法,不产生临时变量。
而
int temp = int(10);的int(10)就是纯临时对象表达式,生成栈上临时 int,再拷贝。临时对象 = 在栈上创建、没有名字的对象;但
new(p) P1(a,b)里,构造出来的对象根本不在栈。无名≠一定是临时;在哪块内存区域创建,才决定是不是临时对象,不是看有没有变量名。
然后看完美转发:
Args写啥都行,
Args&&... args,拆开成 4 块:Args&&...args
Args:模板参数包的名字,装所有传入参数的类型。
&&:万能引用符号。
...(省略号,参数包展开符):告诉编译器,Args可以收纳 0 个、1 个、多个类型。
args:函数形参包名字,接收外面传进来真实的值。
template<typename... Args>:
typename... Args:...作用:允许Args打包任意多个类型。
create(buf, a)→Args打包 1 个类型:int&
create(buf,1,2,3)→Args打包 3 个类型
Args&&... args
Args&&:万能引用。 后面的...:把打包好每一个类型,都套上&&,生成对应形参;同时把外面传入的实参全部收进args。函数体内
args...后面的
...:把包拆开,把里面每一个参数全部丢给构造函数。Demo(args...)等价于,传 3 个参数时:Demo(arg1, arg2, arg3)。调用示例
create(buf,20)
typename... Args:袋子Args装入类型int。
Args&&... args→int&& args,接收字面量 20。函数内部写
args...:把袋子拆开,取出 args 传给 Demo 构造。注意:args 虽然类型是int&&,但 args 本身是函数局部变量,是左值(所有函数形参全部都是该函数的局部变量,存放在函数栈帧)。如果没有
...template<typename Args> Demo* create(void* buf, Args&& args)不能接收多个参数,只能传1 个参数。
...就是专门用来处理可变数量参数。例子:
查看代码
#include <iostream> #include <utility> struct Demo{ Demo(int& x) { std::cout << "调用左值构造\n"; } Demo(int&& x) { std::cout << "调用右值构造\n"; } }; template<typename... Args> Demo* create(void* buf, Args&&... args){ new(buf) Demo(args...); //没有forward return reinterpret_cast<Demo*>(buf); } int main(){ char buf[sizeof(Demo)];//定义字符数组 buf,内存大小等于 Demo 类型所占字节,提供一块原始未初始化内存,用于 placement new int a = 10; create(buf, a); // 调用模板函数 create,传入 buf,左值 a, 实参 a 是 int 左值,匹配 Args&&,推导:Args = int&, 引用折叠:Args&& → int& && → int&。最终该形参类型:int&。形参args拿到 a, 形参本身是函数内局部变量,属于左值,进入 create 函数体,placement new,在 buf 指向内存构造 Demo 对象,传入 args,args 是函数形参,为左值;匹配构造函数Demo(int& x),把 void*性质的 buf 强制转换成 Demo*指针返回,返回值未接收 create(buf, 20);// 调用模板函数 create,传入 buf,字面量 20(右值),实参20是int右值,匹配Args&&。推导:Args = int。引用折叠:Args&& → int && → int&&。最终该形参类型:int&&。形参本身是函数内局部变量,属于左值,进入 create 函数体,placement new,在 buf 指向内存构造 Demo 对象,传入 args,args 是左值,依旧匹配构造Demo(int& x) /* 这里有个很绕的点 int&& x 的类型是 int&&,只允许用右值完成初始化,x是右值引用类型的具名变量,20 作为右值正好可以绑定给 int&& 类型的x,但是函数内部写 x,传给 Demo 构造,x 会被当作左值去传,所以发现: 能拿右值初始化 ≠ 使用变量名 x 的时候它还是右值,变量名字本身永远是左值,20 只用来初始化 x,初始化完毕,20 的右值身份就用完了,后面再写 x,就只剩左值, 想要再次产出右值表达式,必须 std::forward<int>(x)。 不要把「变量的类型」和「写变量名得到的表达式行为」当成一回事 void func (int&& x): 变量的类型:int&&,写在参数位置,代表 x 只能用右值来初始化。 写变量名得到的表达式行为:代码里直接写 x,此时这个表达式是左值,会按左值去传参 */ } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp a.cpp -o main && ./main 调用左值构造 调用左值构造 root@VM-0-7-ubuntu:~/cpp_projects_2# */如果改成
new(buf) Demo(std::forward<Args>(args)...);,还原参数原始的值类别。左值仍为左值,右值仍为右值,匹配对应构造函数,输出:调用左值构造 调用左值构造
小总结:
转发引用(万能引用):是参数的声明语法
Args&&...,只要模板参数Args,后面跟&&,它就是转发引用。这个身份只看写法,跟你用不用 forward 毫无关系。 声明层面的属性,编译推导照常执行。std::forward:是用来恢复值类别的工具,就算你拥有转发引用
Args&&...,函数体内args这个变量名字,永远是左值,不加 forward:推导依旧正常完成,但是原始实参的左 / 右值属性传递的时候丢失。语法合法,编译能过,只是逻辑行为不符合转发预期。所以有forward就叫完美转发,好处就是把实参原本的左 / 右值身份原样交给目标对象的构造函数。
实参是右值时,会触发移动构造,避免不必要的拷贝;
实参是左值时,走拷贝构造;
不做完美转发的话,无论传入左还是右值,到构造函数全部当成左值,永远只能走拷贝,白白丢掉移动的机会
注意:
Args&&...是转发引用 ≠ 自动保留左右值。
Args&&...负责正确推导类型;std::forward<Args>(args)负责把原本的值类别还原出来。只写
args...不写std::forward<Args>(args)...,模板参数Args推导正确,折叠后的形参类型正确;推导照常发生:
传左值 a →
Args=int&,参数变成int&传右值 20 →
Args=int,参数变成int&&但只要函数内写
args...,变量名本身是左值,不管推导出来是int&还是int&&,对外传递统统变成左值,丢失原始实参的值类别。
需要强制全部按左值转发,不保留实参右值属性,就直接传
args...,不写forward。
应用到内存池里就是:
P1* p1 = newElement<P1>();class P1 { int id_; }; // sizeof(P1) = 4template<typename T, typename... Args> T* newElement(Args&&... args){ T* p = nullptr; if ((p = reinterpret_cast<T*>(HashBucket::useMemory(sizeof(T)))) != nullptr) new(p) T(std::forward<Args>(args)...); return p; }步骤 1:调用
HashBucket::useMemory(sizeof(T))
sizeof(T)=4return getMemoryPool(((size + 7) / SLOT_BASE_SIZE) - 1).allocate();
SLOT_BASE_SIZE=8(4+7)/8 = 11/8 = 1,下标1‑1 = 0→ 取0 号内存池。 0 号池子经过init后:SlotSize_ = 8。 调用getMemoryPool(0).allocate()。步骤 2:进入 MemoryPool::allocate ()
popFreeList():空闲链表无回收槽,返回nullptr。加锁
mutexForBlock_。判断
curSlot_ >= lastSlot_,如果成立调用allocateNewBlock()向系统申请 4096 字节大块。从大块取出一个8 字节的槽,
curSlot_向后移动 8 字节。解锁,返回这个 8 字节槽的起始裸地址,返回的是8 字节原始堆内存,不是 4 字节。 内存全部是堆上残留垃圾数据,没有对象,仅仅一块内存。
步骤 3:reinterpret_cast<T*>
原始拿到的是代表 8 字节槽开头的裸内存地址(
void*,只代表堆内存编号,编译器不知道这是什么类型),reinterpret_cast<P1*>强行告诉编译器:把这块内存当成 P1 对象的内存看待,赋值给指针 p,此时p就被解释为:
整块槽:0‑7 字节,一共 8 字节。
P1 对象只使用:0‑3 字节。
4‑7 字节:内部碎片,闲置。
步骤 4:定位 new
Args...是空参数包,std::forward<Args>(args)...展开为空。 等价代码:new(p) P1();P1 没有手写构造函数,隐式默认构造,内置类型
int id_不会初始化:
P1 对象生命周期正式开始。
p->id_= 内存中原有的随机垃圾值(位于该槽的 0‑3 字节)。槽的 4‑7 字节保持垃圾,闲置。
参数包为空,
std::forward被编译器直接抹除,不产生机器指令,不是 bug,没有参数可转发。步骤 5:return p
返回对象指针 p,使用者只能看到 P1 那 4 字节对象,感知不到背后 8 字节槽。
结果就是在拿到8字节槽内存的原地调用P1编译器合成默认构造函数,造出一个合法生命周期但内置成员为随机垃圾值的P1对象,p1 存的就是这块 8 字节槽的起始地址,后续用
p1->id_访问前 4 字节的成员,后面 4 字节是碎片闲置。
再举个例子,代码随想录的例子没用上这个完美转发功能,因为都没参数:
查看代码
#include <iostream> #include <utility> // 简易 newElement:模板 + std::forward 转发参数,在堆上构造对象 template<typename T, typename... Args> T* newElement(Args&&... args){ return new T(std::forward<Args>(args)...);// 只写args变量名永远是左值 } // ========== 测试一 ========== class P1 { public: P1(int v):id_(v){ std::cout << "P1(int) " << id_ << "\n"; } int id_; }; // ========== 测试二 ========== class P2 { public: P2(int v):id_(v){ std::cout << "P2(int) " << id_ << "\n"; } P2(const P1& other){ //拷贝构造,接收左值 id_ = other.id_; std::cout << "P2 拷贝构造\n"; } P2(P1&& other){ //移动构造,接收右值 id_ = other.id_; std::cout << "P2 移动构造\n"; } int id_; }; // ========== 测试三 ========== class P3 { public: P3(int& v):id_(v){ std::cout << "P3(int&) " << id_ << "\n"; } int id_; }; int main(){ // 测试一 std::cout << "---测试一---\n"; int x = 100; auto p1 = newElement<P1>(x); //x: int左值 auto p2 = newElement<P1>(200);//200: int纯右值 delete p1; delete p2; /* std::forward<int>作用: 传入int左值x,forward<int>(x)产出int左值,调用P1(int v) 传入int右值200,forward<int>(200)产出int右值,调用P1(int v) 现在 P1 只有接收 int 值的构造函数,拷贝 / 移动构造体现不出来,给 P1 加上拷贝、移动构造才能肉眼看到转发效果: 目标:给newElement加一个构造参数,简单跑通调用。 缺陷:构造函数是值接收 int,左值、右值传入,运行输出看不出 forward 有没有生效,只能编译层面存在转发。 所以引出测试二 */ // 测试二 std::cout << "\n---测试二---\n"; P2 obj(1000); auto p3 = newElement<P2>(obj); // 左值 → 拷贝构造 auto p4 = newElement<P2>(std::move(obj)); // 右值 → 移动构造 delete p3; delete p4; // 实参为左值 → 优先选用const T&(拷贝构造) // 实参为右值 → 优先选用T&&(移动构造) // std::forward 的作用:把万能引用推出来的类型,还原回原始实参的值类别(左值 / 右值),交给构造函数做重载匹配 // 如果没有写移动构造,右值实参会降级调用拷贝构造 // 测试三 std::cout << "\n---测试三---\n"; int val = 666; auto p5 = newElement<P3>(val); auto p6 = newElement<P3>(666); /* 这个调用使得模版实例化生成一份实实在在的函数 P3* newElement(int& args){ return new P3(std::forward<int&>(args)); } val是左值int&,所以Args是int&,然后Args& &&折叠推导为int&,args就是int&了,执行return new P3(std::forward<int&>(args)),std::forward<int&>(args)产出左值int&,即return new P3(左值),这里就带着实参val已经去匹配构造函数的v了 构造函数形参int& v是非 const 左值引用可以绑定左值即这里的val,不能绑定右值临时对象 绑定指的是构造形参int& v绑定传入表达式 所以 auto p6 = newElement<P3>(666); 会报错,改成P3(const int& v)即可 这就是std::forward保留左右值的证明:实参原本的值类别被原样传递给构造函数 原来按值传 int 的写法,掩盖了这个现象 非 const 左值引用int&只能绑定左值,不能绑定右值,这个规则是为了防止你修改一个临时(右值),修改完立刻销毁,修改毫无意义 比如:int& b = 666; 666 是临时右值,临时马上销毁;如果允许int&绑定,你通过 b 去修改临时对象,修改完对象直接消失,修改全部白费,C++ 直接禁止这种行为。 const int&可以绑定右值,因为不能修改,没有上述风险,临时对象生命周期被延长到引用的生命周期,且因为是 const,不允许修改临时,不会产生无效修改的问题 当 const 左值引用绑定到临时对象时,临时的生命周期延长到和引用变量 ref 相同 const int& ref = 666; 先生成临时int,值666 ref绑定这个临时 临时不会在分号处销毁,而是等到ref离开作用域才销毁 非const的不行 反例:函数返回值场景,延长不会传递(高频坑) const int& fun(){ int x = 100; return x; } x是局部变量,函数结束销毁;返回的const引用绑定已经销毁的对象,悬垂引用! ⚠️ 生命周期延长只发生:临时直接绑定到本作用域的const引用变量,返回、间接绑定都不会延长。 反例:绑定到子对象,只延长临时本体,子对象不延长 struct S{ int a; }; const int& r = S{}.a; S{}是临时,r绑定它的成员a;S{}生命周期延长,a有效。 1. 只有const T&(const 左值引用)直接绑定临时对象 (右值),才触发生命周期延长;T&& 右值引用绑定临时也会延长生命周期。 2. 延长仅作用在同一条语句,直接绑定到局部引用变量 3. 通过函数返回、间接层级绑定,不会触发延长,会产生悬垂引用。 4. int& 非 const 左值引用:完全不允许绑定右值,无任何延长逻辑。 int&& rval_ref = 999; T&&右值引用绑定临时,同样触发临时生命周期延长,rval_ref有效 */ delete p5; } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ a.cpp -o a && ./a ---测试一--- P1(int) 100 P1(int) 200 ---测试二--- P2(int) 1000 P2 拷贝构造 P2 移动构造 ---测试三--- P3(int&) 666 root@VM-0-7-ubuntu:~/cpp_projects_2# */爷牛逼吗?
内存池VS无内存池,关于new这一点我追问的知识点:
1、减少系统调用
普通 new:循环 new 1000 次小对象,触发 1000 次 operator new,陷入内核。
内存池:仅少数几次向系统申请 4096 大块,之后从池内切槽,用户态完成分配。
2、降低内存碎片
普通 new:交替 new 8 字节、new16 字节再交错释放,堆产生大量零散无法合并的小碎片。
内存池:每个池子槽尺寸固定,释放的 8 字节槽只能分配 8 字节申请,不会产生不可用碎片。
3、复用释放内存
普通 delete:内存还给堆管理器,不一定立刻还给 OS,但要经过堆管理器复杂逻辑。
内存池:deallocate 直接把槽丢进 freeList_,下次分配优先拿二手槽,不返还系统。
4、降低锁开销
普通 new:全局堆一把大锁,多线程并发分配全部串行排队。
内存池:freeList_使用 CAS 无锁操作;仅开辟新大块才加锁,分配二手槽完全无锁
Q:为啥搞俩类
A:
MemoryPool代表单个内存池实例,存这套池子自己的数据:SlotSize_、curSlot_、freeList_、firstBlock_,每个池子有独立的这组变量,负责单种槽大小的分配释放。
HashBucket:只做管理层,管理那 64 个MemoryPool对象集合,不做实际内存分配干活,只做调度,里面全部是静态:getMemoryPool、initMemoryPool、useMemory、freeMemory全部 static。 不需要去 new 出一个 HashBucket 对象,不需要实例化,直接HashBucket::xxx()调用。内部静态数组memoryPool[64]是静态局部变量,全局只存一份 64 个内存池,不靠对象持有。
Q:为啥东一榔头西一棒子,freeMemory条线,一会类里一会类外又跳转多个。
A: 这之前的疑惑,整个博客写完,发现自己仔细顺一遍,这个问题就不需要再回答了
开始串联:
开板
main:先执行
initMemoryPool
里面功能是遍历
getMemoryPool(i).init((i + 1) * SLOT_BASE_SIZE);函数64次,第一个遍历执行单例的getMemoryPool,搞出64个元素的数组,每个元素是MemoryPool类型对象(存静态数据区),后63次遍历直接不再搞单例,直接返回数组对应下标对象引用,在创建好的64个数组上做重置操作,即给该池子赋值SlotSize_,清空firstBlock_/curSlot_/freeList_/lastSlot_全部状态,此时池子还没有堆上 4096 大块内存。然后
BenchmarkMemoryPool(100, 1, 10);,场景:
先申请8字节的:
里面重点是
P1* p1 = newElement<P1>(),
他又执行
HashBucket::useMemory (sizeof (P1))
判断 size≤512,计算下标:((size+7)/8)-1,执行
return getMemoryPool(((size + 7) / SLOT_BASE_SIZE) - 1).allocate();,
由于搞过单例不会再搞,直接返回引用,
(size + 7) / SLOT_BASE_SIZE+7向上按 8 字节对齐然后计算需要多少个 SLOT 单元,getMemoryPool(下标)拿到对应规格的内存池实例,然后.allocate()从它内部空闲链表取出一个slot节点,返回节点指针,这里的逻辑是看popFreeListint有没有空余的二手槽,此时由于新开的,所以狗 JB 都没有,freeList_为空 → 返回nullptr,进入锁mutexForBlock_,判断curSlot_ >= lastSlot_,刚初始化二者都是 nullptr,条件成立,直接allocateNewBlock,
allocateNewBlock一顿操作:operator new(4096)堆申请大块内存、头插更新 firstBlock_、padPointer 做地址对齐、赋值 curSlot_、lastSlot_,就可以得到可用内存地址(注意temp类型是Slot*,allocate函数返回类型是void*,C++ 允许Slot*隐式转为void*,地址不变,只是解释规则变了),
temp = curSlot_、curSlot_ += SlotSize_ /sizeof (Slot),释放锁,这个可用地址
return给useMemory
- 此时
useMemory得到可用内存地址,在拿到的裸内存上构造 P1 对象,placement new (p) P1(),return (T*) p;用完释放,执行
deleteElement<P1>(p1):
deleteElement这里先析构(这个析构不是init那个重置):说几句科普:
initMemoryPool:内存池容器层面重置。重置池子管理元数据(SlotSize_、firstBlock_、curSlot_、freeList_、lastSlot_),不碰已经分配出去的槽内存、不碰对象。堆上 4096 大块内存不释放。
p->~T():对象实例层面析构。销毁这个 T 对象自己持有的资源,不改动内存池元数据、不释放槽内存。且堆对象:只做
new T,不写delete p;,析构函数完全不会执行,delete p;是语法,编译器在此处生成两段逻辑:
调用
p->~T()(析构可以自动生,或者自己写,但delete必须手动写,只是内存池复用才导致只析构先暂时不delete)调用
operator delete释放内存缺了
delete p;,两段都不跑。析构干的事:释放对象内部持有的资源(假设内部写了new然后写析构比如
,或者文件句柄等,无论A对象在堆栈搞,栈自动析构,堆手写delete自动执行两步 和 只手写单独析构,都会释放内部new的东西),不释放对象本身所在的那块内存。对象本体这块内存归内存池管理。
栈对象离开作用域编译器自动调用析构,栈内存同步回收,完全不用手动。
p1->~P1();调用P1析构函数销毁对象,不释放底层slot内存然后调用
HashBucket::freeMemory(p1, sizeof(P1)):计算同样下标 ((size+7)/8)-1,定位到对应规格内存池实例,不同规格池子互相隔离,8字节池子释放的槽只会还给8字节池子freeList_,不会流入16字节等其他池子
调用
getMemoryPool(下标).deallocate(p)
执行
pushFreeList((Slot*)p):把释放掉的slot头插入freeList_空闲链表,作为二手槽缓存,不调用系统operator delete,大块4096内存不还给堆假设再次申请同规格还是8字节:
P1* p2 = newElement<P1>()
HashBucket::useMemory(sizeof(P1))
getMemoryPool(下标).allocate()
popFreeList():freeList_已有之前归还的二手slot,直接取出链表头部slot返回,不再走allocateNewBlock申请新4096块拿到地址,回到
newElement,placement new(p) P1();
useMemory不管是从全新块拿槽,还是从popFreeList拿回收回来的槽,只返回裸内存地址,上面没有存活的 T 对象。 所以无论拿到的是新槽还是回收复用的槽,都必须执行这一句placement new,在该地址上构建 T 对象。
void* newBlock = operator new(BlockSize_);只开辟裸内存,不构造对象这件事早就干过了演示跨规格隔离:申请16字节规格对象
P2* p3 = newElement<P2>()
HashBucket::useMemory(sizeof(P2)),算出新下标,命中16字节规格MemoryPool实例,该池子freeList_独立,看不到8字节池子归还的二手槽
getMemoryPool(下标).allocate()
popFreeList()返回nullptr,触发allocateNewBlock,分配属于16字节池子的4096大块拿到地址,回到
useMemory执行placement new(p) P2();
注意:
我误以为是递归,但递归是函数自己调用自己,而整条链路:
main → initMemoryPool → getMemoryPool → init,main → BenchmarkMemoryPool → newElement → useMemory → getMemoryPool → allocate → allocateNewBlock,全部是A 调用 B、B 调用 C,函数之间互相调用,没有函数调用自身,属于普通函数调用链,不是递归。
我的思考:
首先三高:
高并发:
- 就是程序能够同时处理大量连接 / 大量请求,这套配套知识体系,epoll、无锁编程、多线程、锁、非阻塞IO,多路复用啥的。
高可用:
- 基础岗位只需要懂概念(故障、降级、重试、容灾),不用亲手实现整套高可用架构;进阶岗才要求落地,尽量少宕机,故障也尽量不中断服务
高性能:
- 懂得内存池,减少频繁new/delete带来堆分配开销;掌握CAS无锁链表减少锁竞争;理解内存对齐;分清裸内存分配和对象构造销毁;理解移动语义、完美转发避免不必要拷贝。 属于内存层面的高性能手段。
代码随想录的
void MemoryPool::deallocate(void* ptr){、void* MemoryPool::allocate(){为啥起这么怪的名字?内存池里的函数其实借用了C++的标准库模板类接口std::allocator的名字命名,即C++原生就有std::allocator,实际工作基本不用,都是vector、list、map底层默认就用
std::allocator,容器的第二个模板参数就是分配器,一般不手动指定就是默认这个,封装 malloc/free;规定一套分配器规范,让容器可以替换底层内存来源,不能干:池化、空闲对象缓存,deallocate 直接释放回堆,不做内存缓存。本身性能没有特殊优化,就是薄封装 operator new/delete,但容器依旧用它,原因两点:
- STL 容器定位是通用组件,要适配所有普通业务场景,不能内置专用内存池。专用内存池有场景限制、有额外开销,不适合当做通用默认实现。
- 预留分配器模板参数,给有高性能需求的人可替换选项,普通人直接开箱即用默认版本。
总结:默认版本追求通用兼容,不追求极致性能;追求极致性能,使用者自己替换分配器,容器代码不动。
所以我写的内存池也算手写了可替换高性能的自定义分配器!
可是实际工作既不会手写内存池,也不会用 C++ 的那个接口(它主要作为 STL 容器的默认内置配件,间接工作),那有啥用?
那手写内存池的意义(这里搜融会贯通):
吃透 C++ 内存完整链路:operator new、placement new、对象析构、内存回收,分清「分配裸内存」和「构造 / 销毁对象」、
看懂开源高性能库源码:很多开源组件内部自带简易内存池,看懂自己手写的实现,才能读懂别人的。
面试:手写内存池是 C++ 后端、游戏开发高频面试题,考察内存、指针、原子 CAS、锁、对齐这些底层功底。
具备选型能力:知道什么场景下应该上内存池,什么场景下千万别瞎写内存池。
说个细节思考:
命名空间:
namespace Kama_memoryPool命名空间,用来防止名字冲突,把内存池全部类、函数包在这个域内,避免和外部其他库符号重名,如果你直接把HashBucket、MemoryPool丢到全局域,万一你自己代码 / 第三方库也定义同名HashBucket,编译报重定义,命名空间把你整套内存池的名字隔离在Kama_memoryPool域,不和全局、std 域互相干扰。举例子: 不用命名空间,全局直接写
class HashBucket{}; 如果别的头文件也有class HashBucket{}→编译报错符号冲突。 套上 namespace,只有写Kama_memoryPool::HashBucket才会访问到你的类,不会跟 std 以及其他库名字打架。
main.cpp 开头
using namespace Kama_memoryPool;:把这个命名空间全部名字拿到当前作用域,于是可以直接写HashBucket::initMemoryPool(),不写
using namespace Kama_memoryPool;,就要加命名空间限定:Kama_memoryPool::HashBucket::initMemoryPool();,示例:
Kama_memoryPool::P1* p = Kama_memoryPool::newElement<Kama_memoryPool::P1>();
而末尾
} // namespace xxx,这里的namespace memoryPool只是注释标记,表示这是该命名空间的结束大括号,无编译作用,纯阅读提示。
是否多生产消费:
pushFreeList:多个线程可以同时执行 CAS 头插放入空闲节点(生产者)
popFreeList:多个线程可以同时执行 CAS 取出空闲节点(消费者)
上一篇文章说了带锁版本的单生产单消费:
cv 版本(空队列线程休眠,CPU 开销低):
队列空:
cv.wait(),线程休眠挂起,让出 CPU,被 notify 唤醒后才抢锁执行,CPU 不浪费。 锁范围内做等待,依靠条件变量完成线程休眠唤醒。
忙等版本(空队列持续循环轮询,CPU 占用高):
队列空:解锁,直接进入下一轮循环,不停加锁判队列、解锁,CPU 一直空转跑循环,不会休眠。没有条件变量,没有线程唤醒机制。
无锁也学了但不考,中级的玩意,多生产多消费是高级的玩意。
而内存池根本不是生产者消费者模型,它只是一块内存复用管理器,这里只有1个工作线程,既做内存分配,又做内存释放,自己分配自己释放,不存在独立生产者、消费者。
但当前 main 调用参数
BenchmarkMemoryPool(100, 1, 10),nworks=1,只创建1 个线程。 单线程:只有单生产者单消费者,不存在多线程并发 push/pop 空闲链表,无法触发多生产者多消费场景,只有一个线程,内部线性顺序执行,线程接收的是一整块可执行函数体,函数体内部写了多少逻辑,线程就完整执行全部逻辑,不需要逐个赋值任务,具体流程:
启动 1 个工作线程,这个线程一共要跑 10 大轮。
每一大轮开始,记下当前 CPU 时间,clock () 只统计该进程占用 CPU 的时间片,不统计挂起等待的时间,和墙上现实时间不挂钩,比如结果是10,和分秒无关(没关系)。
每一大轮内部,重复做 100 回分配释放 P1 P2 P3 P4。
100 回全部做完,记下此时 CPU 时间。
算出这一大轮消耗的 CPU 时间,把消耗值加到总耗时变量上。
接着进入下一大轮,重复上面过程,直到 10 大轮全部做完。
线程任务结束,主线程等待该线程结束。
打印累计出来的总 CPU 耗时
细节流程:
BenchmarkMemoryPool(100, 1, 10); // ntimes=100 nworks=1 rounds=10
std::vector<std::thread> vthread(nworks),创建存 1 个线程对象的容器。
for(size_t k = 0; k < nworks; ++k),k 等于 0 进入循环。
vthread[k] = std::thread([&](){ ... })[&](){ }是 lambda:[&]按引用捕获外部变量,大括号内是线程要跑的函数体,把这个函数交给 std::thread 生成子线程,赋值给 vthread [0],全程也只有他一个线程。子线程函数体内:
for(size_t j = 0; j < rounds; ++j),j 从 0 到 9,循环 10 次。
size_t begin1 = clock(),读取当前 CPU 时钟计数。
for(size_t i = 0; i < ntimes; i++),i 从 0 到 99,循环 100 次。循环体依次执行:
newElement<P1>(); deleteElement<P1>(p1);newElement<P2>(); deleteElement<P2>(p2);newElement<P3>(); deleteElement<P3>(p3);newElement<P4>(); deleteElement<P4>(p4);i 循环结束,
size_t end1 = clock()读取 CPU 时钟计数。
total_costtime += end1 - begin1,把本轮 j 的时钟差值加到 total_costtime。j 循环 10 轮全部跑完,子线程函数结束。
for(auto& t : vthread) t.join();,main 线程等待这个子线程运行结束。
printf打印 nworks、rounds、ntimes、total_costtime。统计包含newElement(分配+构造)与deleteElement(析构+回收内存),原生版本统计包含new与delete。
关于误以为是生产者消费者的思考与总结:
这份代码使用 CAS 无锁链表管理空闲槽,但是没有生产者‑消费者模型。 CAS 只是并发链表的实现手段,生产者消费者是特定业务模型(一方生产数据、一方消费数据、队列做缓冲),二者没有强制绑定。
改成
nworks > 1,例如BenchmarkMemoryPool(100,8,10),8 个线程同时执行 newElement、deleteElement,多个线程并发执行 pushFreeList、popFreeList,也不是多生产者‑多消费者,生产者消费者模型有明确语义:一部分线程专门生产任务,另一部分线程专门消费任务,队列承担任务缓冲,这里 8 个线程做的完全一模一样:既分配又释放,每个线程既是释放者又是申请者,不存在角色划分,只是单纯多线程竞争空闲链表。
传入 nworks>1 开启多线程跑这份代码,代码可以跑,但是存在 ABA 问题:
popFreeList优化版本存在 ABA问题。1、freeList:A → B → C
2、T1 读到 oldHead=A,读得 next=B,挂起。
3、T2 pop A;pop B;pop C;全部取出;
4、T2 pushFreeList(A),A->next=nullptr;freeList:A。
5、T1 恢复,CAS 发现头是 A,CAS 成功,返回 A。 T1 内部记录的 next 依旧是B,但真实 A->next 现在是 nullptr。T1 拿到 A,它逻辑上认为下一个节点是 B,B 已经被取出,链表信息错位。
这就叫ABA问题,是无锁 CAS 链表本身的并发漏洞,属于底层同步机制问题,ABA问题解决手段:指针标记(tagged pointer)。
小块的析构:
newElement向堆申请大块内存全程常驻程序,deleteElement仅析构对象归还内存池,不会把堆内存交还操作系统。
流程是:
最开始搞64个元素的数组
static MemoryPool memoryPool[64]是静态数组,存放在静态存储区,每个数组都会使用时候开辟堆空间,反复deleteElement复用,然后退出程序后C++ 静态变量生命周期机制:程序退出自动逐个调用 64 个 MemoryPool 的析构,不用循环写64次析构会自动执行析构,每个 MemoryPool 自己的析构函数会执行,析构函数内部 while 循环遍历自己的大块内存链表,调用
operator delete释放该池向堆申请的各个 block 还给 OS。比喻:
类似于施工队施工建立几个坑位,然后人拉屎后走就清理复用给其他人用,然后没人上厕所了直接拆除坑位。
关于需要大块内存:
分配阈值:>512 字节,直接走原生
operator new(size)。 ≤512 字节:按大小算出下标,交给对应 MemoryPool 分配。释放阈值:>512 字节,直接
operator delete(ptr)。大于 512 字节的内存,内存池完全不管理这些大块,
分配:直接
operator new返回裸指针,内存池不记录、不保存指针、不把它们串进任何链表。释放:用户调用
deleteElement,走到HashBucket::freeMemory判断 size>512,直接执行operator delete(ptr)还给系统堆。内存池不保存这些指针,没有容器存它们,没有链表挂它们,全靠调用方传进来的 ptr 直接还给系统。内存池不对大块做记账。
说点冗余但引出静态 VS 堆:
getMemoryPool(i).init((i + 1) * SLOT_BASE_SIZE);第一次执行getMemoryPool,创建造出 64 个完全独立的内存池对象
0 号池子:固定槽 8 字节
1 号池子:固定槽 16 字节
…… 每个池子(memoryPool[0]、memoryPool[1]、... ...)都有自己:
SlotSize_、firstBlock_、curSlot_、lastSlot_、freeList_、互斥锁。 互相完全隔离,变量互不干扰。然后每个池子
init,给自己设置独一份SlotSize_,当池子内没槽,
curSlot_ >= lastSlot_成功,就调用本实例的allocateNewBlock():void* newBlock = operator new(BlockSize_);
allocateNewBlock()是成员函数,属于某一个 MemoryPool 实例。谁调用,谁向系统申请一块 4096。加到自己的firstBlock_链表,只切当前池子的槽大小。 别的池子看不见这块内存,不会拿来切别的尺寸。执行流程:
1、
useMemory(8)→ 下标 0 →memoryPool[0].allocate(),memoryPool[0]内部curSlot_ >= lastSlot_成立 → 调用自己的 allocateNewBlock →operator new(4096)。👉 memoryPool [0] 拿到第一块 4096。
2、
useMemory(16)→ 下标 1 →memoryPool[1].allocate(),memoryPool[1]内部curSlot_ >= lastSlot_成立 → 调用自己的 allocateNewBlock →operator new(4096)。👉 memoryPool [1] 拿到第二块 4096。
firstBlock_是大块链表的表头指针,永远指向最新申请出来的那块大块,具体可用位置由curSlot_、lastSlot_管控。
首次申请 4096:
firstBlock_存该大块地址。再次申请新 4096:新大块的 next 指向旧
firstBlock_,firstBlock_更新为新大块地址,形成新块插链表头部的单向链表。
static MemoryPool memoryPool[64]在静态存储区,不在堆,程序启动就分配,程序结束才释放,内存地址编译期就确定,内存排布为连续一大片静态内存,依次完整存放 memoryPool [0] 全部成员,紧接着 memoryPool [1] 全部成员,以此类推,每个对象都拥有一套属于自己的成员,所以里面的curSlot_、lastSlot_属于内存池实例本身的成员,也顺势存静态存储区。而堆只用来存后面
operator new(4096)申请出来的大块内存,由operator new/malloc申请,运行时手动申请、手动释放。内存池向系统申请的 4096 字节大块,就放在堆,用firstBlock_指向。
curSlot_、lastSlot_存的数值是堆上 4096 大块内部的内存地址,也就是指向 firstBlock_所指向那块堆内存里面的偏移位置,变量本体不在堆,指向的内容在堆。Q:为啥这么搞?
A:静态存储区:内存池实例本身(那些指针变量)
static MemoryPool memoryPool[64];程序一启动,这 64 个内存池对象就直接分配好了。 对象里面的firstBlock_、curSlot_这些指针变量本体,就躺在静态存储区,程序启动就有,程序结束才销毁,固定就 64 套,数量编译期就确定。堆:4096 大块内存
new char[4096]出来的大块内存。 程序刚跑的时候,还没有这块内存。 只有调用分配,不够用了,才临时去堆上申请一块 4096。 把这块堆地址赋值给静态区里的指针firstBlock_。大白话: 静态区放账本(
firstBlock_等指针,记录地址)。 堆放实际存放数据的 4096 内存块。 账本永远在静态区;账本上写的号码,指向堆上面的房间。 账本本身不在堆,房间在堆。
账本数量固定:64 套内存池,编译期就定,适合静态。
房间数量不确定:不知道程序会申请多少块 4096,运行时才新增,只能堆
memoryPool[0].allocateNewBlock()里,this就是指向memoryPool[0] 这个对象本身的指针。函数内部所有firstBlock_、curSlot_等价于this->firstBlock_,只操作 0 号实例的成员。
整个项目有 64 个内存池,对应处理小内存。规定阈值:超过 512 字节,直接原生 operator new,完全不走内存池这套逻辑,也不受内存池管理,不会挂到 firstBlock_链表上。
64 个池子,每个池子槽大小固定:第 0 号池子 8 字节、1 号 16 字节…… 一直到 63 号 512 字节。
注意:程序启动的时候,这 64 个池子只是对象被创建,并没有向系统申请任何堆内存,不会预先切片、不会预先划分一大堆槽。切片这件事不会提前做。
当业务要分配一块内存:
先看要的内存大小。大于 512:直接原生 new,流程结束。
小于等于 512:计算大小落到哪一档,找到对应的那一个内存池。比如说要 10 字节,落到 16 字节规格,拿 1 号池子干活。
进到这个对应内存池的
allocate(): 优先去无锁空闲链表freeList_拿别人释放回来的旧槽。空闲链表有东西,直接返回这块内存。如果空闲链表是空,就去用这块池子自己已经申请出来的大块内存里面那些全新没使用的槽。
curSlot_ >= lastSlot_代表当前这一大块内存全部切出来的槽已经用光。这时才调用allocateNewBlock(),此时此刻才向系统申请一大块(BlockSize_,默认 4096)内存。拿到这一大块新内存之后: 这块大块内存最开头拿一段充当管理用 Slot 节点,用来串在
firstBlock_链表,记录这个大块内存。跳过这个管理节点,再做内存对齐,剩下的连续空间,现场切片,切成这个池子固定尺寸的槽(比如 1 号池子就全部切成 16 字节一块一块)。更新curSlot_、lastSlot_标记槽的起止位置。后续分配就不断挪动 curSlot_取这些切片槽。。。
附上内存池最终代码:
不仅有《代码随想录》源码,还有啃豆包追问后的收获、思考注释、诸多优化修改、勘误
MemoryPool.h:
查看代码
#pragma once
#include <atomic>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <memory>
#include <mutex>
namespace Kama_memoryPool{
#define MEMORY_POOL_NUM 64 //总共创建 64 个独立MemoryPool
#define SLOT_BASE_SIZE 8 //槽基础尺寸
#define MAX_SLOT_SIZE 512 //超过 512 字节的内存不使用内存池,直接原生分配
/*
具体内存池的槽大小没法确定,因为每个内存池的槽大小不同(8的倍数)
所以这个槽结构体的sizeof 不是实际的槽大小
*/
struct Slot {
std::atomic<Slot*> next; // 原子指针
};
class MemoryPool{
public:
MemoryPool(size_t BlockSize = 4096);
~MemoryPool();
void init(size_t);
void* allocate();
void deallocate(void*);
private:
void allocateNewBlock();
size_t padPointer(char* p, size_t align);
// 使用CAS操作进行无锁入队和出队
bool pushFreeList(Slot* slot);
Slot* popFreeList();
private:
int BlockSize_; // 内存块大小
int SlotSize_; // 槽大小
Slot* firstBlock_; // 指向内存池管理的首个实际内存块
Slot* curSlot_; // 指向当前未被使用过的槽
std::atomic<Slot*> freeList_; // 指向空闲的槽(被使用过后又被释放的槽)
Slot* lastSlot_; // 作为当前内存块中最后能够存放元素的位置标识(超过该位置需申请新的内存块)
//std::mutex mutexForFreeList_; // 保证freeList_在多线程中操作的原子性
std::mutex mutexForBlock_; // 保证多线程情况下避免不必要的重复开辟内存导致的浪费行为
};
class HashBucket{
public:
static void initMemoryPool(); //函数实现写在memorypool.cpp文件, 作用是批量初始化全部 64 个MemoryPool,内部代码写在.cpp,给所有池子分配各自固定尺寸
static MemoryPool& getMemoryPool(int index);//静态函数声明,参数index是数组下标,功能是根据下标取出对应的MemoryPool实例
static void* useMemory(size_t size){
if (size <= 0)
return nullptr;
if (size > MAX_SLOT_SIZE) // 大于512字节的内存,则使用new
return operator new(size);
/*
关键字new,完整流程
int* p = new int;
等价拆解:
1. 调用 operator new(sizeof(int)) 拿一块裸内存
2. 在这块内存执行定位new,调用int构造函数
delete p;
等价拆解:
1. 先执行 p->~int() 析构,p是int*,p->~int()显式调用 int 的析构函数,内置类型 int 析构什么都不做,是空操作
2. 调用 operator delete(p) 归还内存
而此处只需要一块原始内存,没有要立刻构造的对象,不需要执行构造逻辑,因此直接调用底层operator new,省去构造步骤
int* a = new int;
拿到:一块sizeof(int)裸内存 + 自动执行int构造,内存绑定int类型,只能存 int 数据。int* a = new int;:堆上创建一个 int 对象,a 是指向该对象的指针,*a 才是对象本身
operator new(100)
拿到:一块 100 字节无类型裸内存,不执行任何构造,没有绑定任何类型,单纯一片空白堆内存。
malloc(配套free)是 C 标准库函数,分配内存不会兼容 C++ 内存异常、重载的operator new逻辑;
operator new(配套operator delete)是 C++ 原生底层接口, 适配整套 C++ 内存机制
*/
// 相当于 size / 8 向上取整(因为分配内存只能大不能小)
return getMemoryPool(((size + 7) / SLOT_BASE_SIZE) - 1).allocate();
/*
若size = 5:5+7=12,12/8=1,对应下标 1-1=0,使用 8 字节池子
若size = 8:8+7=15,15/8=1,下标 0,刚好匹配 8 字节池子
若size = 9:9+7=16,16/8=2,下标 1,使用 16 字节池子
*/
}
static void freeMemory(void* ptr, size_t size){
if (!ptr) // 传入空指针直接结束,不做回收操作。
return;
if (size > MAX_SLOT_SIZE){ // 内存超过 512 字节,走原生operator delete释放
operator delete(ptr);
return;
}
getMemoryPool(((size + 7) / SLOT_BASE_SIZE) - 1).deallocate(ptr);// 分配时的计算逻辑找对应池子,调用池子deallocate()归还内存
}
/*
template<typename T, typename... Args>
friend T* newElement(Args&&... args);
template<typename T>
friend void deleteElement(T* p);
*/
};
template<typename T, typename... Args>
T* newElement(Args&&... args){
T* p = nullptr;// 先定义空对象指针,用来存分配好内存 + 构造完的对象地址。
if ((p = reinterpret_cast<T*>(HashBucket::useMemory(sizeof(T)))) != nullptr)// 根据元素大小选取合适的内存池分配内存
new(p) T(std::forward<Args>(args)...);// 在分配的内存上构造对象
return p;
}
template<typename T>
void deleteElement(T* p){
// 对象析构
if (p){
p->~T();
// 内存回收,本例 P1/P2/P3/P4 没有手写析构,调用编译器生成的默认析构函数,只销毁对象逻辑
HashBucket::freeMemory(reinterpret_cast<void*>(p), sizeof(T));
}
}
} // namespace memoryPool
MemoryPool.cpp:
查看代码
#include "../include/MemoryPool.h"
namespace Kama_memoryPool {
MemoryPool::MemoryPool(size_t BlockSize)//内存池构造函数,参数 BlockSize 代表单个大块内存总字节数,默认传 4096
: BlockSize_ (BlockSize)// 保存整块内存的大小
, SlotSize_ (0)//单个槽位大小初始为 0,等待 init 函数赋值
, firstBlock_(nullptr) //池子管理的第一块内存块,初始无;
, curSlot_(nullptr) //当前未使用的全新槽起始地址,初始无;
, freeList_(nullptr) //存放释放回收槽的原子链表头,初始空;
, lastSlot_(nullptr) //当前内存块可用槽的边界标记,初始空;
{}
MemoryPool::~MemoryPool(){
// 把连续的block删除
Slot* cur = firstBlock_;// firstBlock_ 是内存池所有大块内存链表头,cur 用来循环遍历每一块系统申请的整块内存。
while (cur){
Slot* next = cur->next;
// 等同于 free(reinterpret_cast<void*>(firstBlock_));
// 转化为 void 指针,因为 void 类型不需要调用析构函数,只释放空间
operator delete(reinterpret_cast<void*>(cur));
cur = next;
}
}
void MemoryPool::init(size_t size){ // 重置内存池所有状态,绑定专属槽尺寸,为后续分配做好初始化准备
assert(size > 0); //断言校验传入槽尺寸必须大于 0,非法尺寸直接中断程序,防止分配异常; 断言就是括号里的条件必须成立,如果不成立,程序直接崩溃并打印报错位置,仅调试生效发布会直接删掉所有 assert。这种参数错误属于代码写错,不是运行时可修复业务异常,崩溃强制你立刻改代码
SlotSize_ = size ;//给当前内存池固定单槽大小(比如下标 0 池子赋值 8、下标 1 赋值 16);
firstBlock_ = nullptr; //清空内存块链表头,代表池子目前没有向系统申请任何大块内存;
curSlot_ = nullptr; //清空全新未使用槽的起始指针
freeList_ = nullptr; //清空存放回收槽的无锁空闲链表
lastSlot_ = nullptr ;//清空当前内存块可用槽的边界标记
}
void* MemoryPool::allocate(){
// 优先使用空闲链表中的内存槽
Slot* slot = popFreeList();
if (slot != nullptr)
return slot;
Slot* temp;
{
std::lock_guard<std::mutex> lock(mutexForBlock_);
if (curSlot_ >= lastSlot_)
// 当前内存块已无内存槽可用,开辟一块新的内存
allocateNewBlock();
temp = curSlot_;
// 这里不能直接 curSlot_ += SlotSize_ 因为curSlot_是Slot*类型,所以需要除以SlotSize_再加1
curSlot_ += SlotSize_ / sizeof(Slot);
}
return temp;
}
void MemoryPool::deallocate(void* ptr){
if (!ptr) return;
Slot* slot = reinterpret_cast<Slot*>(ptr);
pushFreeList(slot);
/*
Q:这里感觉好啰嗦啊,为啥东一榔头西一榔头的,包这么多个函数呢?
A:
这是预防性封装,当前代码并无第二个调用位置,属于超前设计,不是必须这么写
单一职责思想:deallocate 负责指针判空、类型转换;CAS 链表操作交给独立函数,功能切割
为隔离复杂的 CAS 自旋重试逻辑,不让分配回收主函数被循环、内存序参数淹没,主函数只保留业务逻辑
预判未来扩展,预留复用点,假设后续新增别的回收路径,可以直接调用该函数,不用重复写 CAS 代码
*/
}
void MemoryPool::allocateNewBlock(){
//std::cout << "申请一块内存块,SlotSize: " << SlotSize_ << std::endl;
// 头插法插入新的内存块
void* newBlock = operator new(BlockSize_);
reinterpret_cast<Slot*>(newBlock)->next = firstBlock_;
firstBlock_ = reinterpret_cast<Slot*>(newBlock);
char* body = reinterpret_cast<char*>(newBlock) + sizeof(Slot*);
size_t paddingSize = padPointer(body, SlotSize_); // 计算对齐需要填充内存的大小
curSlot_ = reinterpret_cast<Slot*>(body + paddingSize);
// 超过该标记位置,则说明该内存块已无内存槽可用,需向系统申请新的内存块
lastSlot_ = reinterpret_cast<Slot*>(reinterpret_cast<size_t>(newBlock) + BlockSize_ - SlotSize_ + 1);
}
// 让指针对齐到槽大小的倍数位置
size_t MemoryPool::padPointer(char* p, size_t align){
// align 是槽大小
size_t rem = (reinterpret_cast<size_t>(p) % align);
return rem == 0 ? 0 : (align - rem);
}
// 实现无锁入队操作
bool MemoryPool::pushFreeList(Slot* slot){
while (true){
// 获取当前头节点
Slot* oldHead = freeList_.load(std::memory_order_relaxed);
// 将新节点的 next 指向当前头节点
slot->next.store(oldHead, std::memory_order_relaxed);
// 尝试将新节点设置为头节点
if (freeList_.compare_exchange_weak(oldHead, slot, std::memory_order_release, std::memory_order_relaxed))
return true;
// 失败:说明另一个线程可能已经修改了 freeList_
// CAS 失败则重试
}
}
// 实现无锁出队操作
/*
Slot* MemoryPool::popFreeList(){
while (true){
Slot* oldHead = freeList_.load(std::memory_order_acquire);
if (oldHead == nullptr)
return nullptr; // 队列为空
Slot* newHead = nullptr;
// try{
newHead = oldHead->next.load(std::memory_order_relaxed);
// }
// catch(...){
// continue;
// }
if (freeList_.compare_exchange_weak
(oldHead, newHead,
std::memory_order_acquire, std::memory_order_relaxed))
return oldHead;
}
}// 这个是代码随想录的写法,我觉得不好,做了优化,理由见博客
*/
//我优化版本
Slot* MemoryPool::popFreeList(){
Slot* oldHead = freeList_.load(std::memory_order_acquire);
if(oldHead == nullptr)
return nullptr;
while(!freeList_.compare_exchange_weak(
oldHead, oldHead->next.load(std::memory_order_relaxed),
std::memory_order_acquire, std::memory_order_relaxed)){
if(oldHead == nullptr)
return nullptr;
}
return oldHead;
}
//优化完毕
void HashBucket::initMemoryPool(){
for (int i = 0; i < MEMORY_POOL_NUM; i++)//循环遍历全部 64 个内存池;
getMemoryPool(i).init((i + 1) * SLOT_BASE_SIZE);//取出下标 i 对应的内存池实例,给当前池子设置单槽尺寸,SLOT_BASE_SIZE = 8, 第 0 个池子 8 字节、第 1 个 16 字节…… 第 63 个 512 字节,刚好覆盖 0~512 字节的分配需求
}
// 单例模式
MemoryPool& HashBucket::getMemoryPool(int index){// 引用返回,省去对象复制开销,外部可直接调用 allocate、deallocate 修改池子内部数据;
static MemoryPool memoryPool[MEMORY_POOL_NUM];// 函数内静态数组,程序生命周期里只初始化一次,一次性创建 64 个 MemoryPool 实例,不会重复创建销毁。static 让数组存放于静态存储区,去掉static:数组变成函数局部栈数组,函数调用时在栈上创建,函数退出直接销毁,无法留存 64 个内存池实例
return memoryPool[index];//参数int index:数组下标,范围 0~63,匹配不同 8 倍数槽尺寸的池子,按下标取出对应池子,返回引用而非拷贝,全程操作同一个池子对象
}
} // namespace memoryPool
UnitTest.cpp:
查看代码
#include <iostream>
#include <thread>
#include <vector>
#include "../include/MemoryPool.h"
using namespace Kama_memoryPool;
// 测试用例
class P1 {
int id_;
};
class P2 {
int id_[5];
};
class P3{
int id_[10];
};
class P4{
int id_[20];
};
// 单轮次申请释放次数 线程数 轮次
void BenchmarkMemoryPool(size_t ntimes, size_t nworks, size_t rounds){
std::vector<std::thread> vthread(nworks); // 线程池
size_t total_costtime = 0;
for (size_t k = 0; k < nworks; ++k){ // 创建 nworks 个线程
vthread[k] = std::thread([&]() {
for (size_t j = 0; j < rounds; ++j){
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++){
P1* p1 = newElement<P1>(); // 内存池对外接口。P1是测试用的简单类,成员只有 1 个int,sizeof(P1) = 4字节
deleteElement<P1>(p1);
P2* p2 = newElement<P2>();
deleteElement<P2>(p2);
P3* p3 = newElement<P3>();
deleteElement<P3>(p3);
P4* p4 = newElement<P4>();
deleteElement<P4>(p4);
}
size_t end1 = clock();
total_costtime += end1 - begin1;
}
});
}
for (auto& t : vthread)
t.join();
printf("%lu个线程并发执行%lu轮次,每轮次newElement&deleteElement %lu次,总计花费:%lu ms\n", nworks, rounds, ntimes, total_costtime);
}
void BenchmarkNew(size_t ntimes, size_t nworks, size_t rounds){
std::vector<std::thread> vthread(nworks);
size_t total_costtime = 0;
for (size_t k = 0; k < nworks; ++k){
vthread[k] = std::thread([&]() {
for (size_t j = 0; j < rounds; ++j){
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++){
P1* p1 = new P1;
delete p1;
P2* p2 = new P2;
delete p2;
P3* p3 = new P3;
delete p3;
P4* p4 = new P4;
delete p4;
}
size_t end1 = clock();
total_costtime += end1 - begin1;
}
});
}
for (auto& t : vthread)
t.join();
printf("%lu个线程并发执行%lu轮次,每轮次malloc&free %lu次,总计花费:%lu ms\n", nworks, rounds, ntimes, total_costtime);
}
int main(){
HashBucket::initMemoryPool(); // 使用内存池接口前一定要先调用该函数
BenchmarkMemoryPool(100, 1, 10); // 测试内存池
std::cout << "===========================================================================" << std::endl;
std::cout << "===========================================================================" << std::endl;
BenchmarkNew(100, 1, 10); // 测试 new delete
}
CMakeLists.txt:
查看代码
# CMake minimum version
cmake_minimum_required(VERSION 3.10)
# Project name
project(MemoryPoolProject)
# Set C++ standard
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# Include directories
include_directories(include)
# Source files
file(GLOB SRC_FILES "src/*.cpp")
file(GLOB TEST_FILES "tests/*.cpp")
# Add the executable for the main target
add_executable(${PROJECT_NAME} ${SRC_FILES} ${TEST_FILES})
# Add compile options
target_compile_options(${PROJECT_NAME} PRIVATE -g -pthread)
# Link libraries
target_link_libraries(${PROJECT_NAME} pthread)
# Link libraries (if any)
# target_link_libraries(${PROJECT_NAME} <library_name>)
关于cmake:
前言:
先编译下,出问题了,居然手写内存池是更慢的,然后惊呆了,笑死了这他妈是培训项目??这截图放的,长脑子了吗?
之前小林错误勘误我写了一堆、编程指北的C++勘误写了一堆、代码随想录的项目有错误,有时候真好奇,这些玩意只有我一个人在用吗????他们不是那么多粉丝呢吗???
POJ之前一崩半个月,之前编程指北崩了也是,到处查。
如今代码随想录的项目,就这?截图都是错的啊
。自己实践发现也是手写内存池更慢(这里搜“大,且经常自旋”,也是同样的问题,且也写了很多其他无法复现的东西),哎,太多东西编译器实践和C++语法不同了,wx搜“八股文的意义”,呵呵。真的浪费太多时间。
反复辱骂豆包追问出来的价值一个亿的东西:
Q:为啥手写内存池反而更慢
A:
CPU 物理 2 核,同一时刻最多 2 个线程实实在在跑在 CPU 上。开再多线程,操作系统只是快速切换,不是真正并行。大量线程并发争抢 CAS 的时候,大部分时间线程抢不到 CPU,CAS 反复失败重试,重试开销放大。只有物理核心数大于等于测试线程数,大量线程可以同时真实并行运行,竞争场景才会真实复现。
多核 + 足够多并发线程:原生 new/delete,大量线程同时分配释放小对象,ptmalloc 会出现 arena 冲突,触发锁开销。你的内存池绝大多数分配释放操作只是争抢 freeList_链表头,只在新增大块内存时才走互斥锁,锁触发频率极低。此时原生 new 的锁开销占比大幅上升,你的内存池的开销占比相对更低,性能差距就显现。
Q:没懂
A:
glibc ptmalloc64 位默认 arena 上限等于
8*物理核心数腾讯云。2 核机器,arena 最多 16 个。 线程数 ≤ arena 数量,ptmalloc 尽量分配不同 arena 给不同线程,极少发生锁争抢,锁等待开销几乎没有。即便没有锁争抢,拿锁、释放锁这套 mutex 操作本身依旧有微小 CPU 开销,只是不会出现线程阻塞等待别的线程释放锁。
只有线程数目 > arena 最大数量,多个线程被迫共用同一个 arena,此时才会出现真正锁冲突,线程阻塞等待 mutex,锁开销会明显放大。
现代 glibc 带 tcache 线程本地缓存,小对象分配释放优先走 tcache,完全不碰 arena 锁,进一步压低低并发场景下 new/delete 的开销。
落到你的 2 核(nproc可以查看)机器实测场景: 你的测试线程数量不大,ptmalloc 可以分配不同 arena,tcache 也生效,new/delete 整体开销很低。反观你的内存池,每次分配释放都要跑 freeList_的 CAS 循环,这套固定开销盖过 new 的开销,所以跑分 new 更快。 只有并发线程数量超过 arena 上限,大量线程争抢同一个 arena 锁,new 的阻塞锁开销暴涨,你的内存池才会跑。
arena 就是 ptmalloc 给线程准备的一块独立内存地盘,尽量让线程在自己地盘分配内存,减少锁争抢,可以理解成堆分配器给线程用的内存缓存块,但它带锁,多个线程会抢同一个 arena。
科普:
new底层在 Linux 上最终调用malloc,glibc 的 malloc 实现就是 ptmalloc。 也就是你代码里new P1底层走到 ptmalloc。
new P1:C++ 语法层
operator new:C++ 标准库封装
malloc:C 标准库封装
ptmalloc:glibc 内部实现(真正底层分配逻辑)调用链路:
new P1→operator new→malloc→ ptmalloc (glibc 用户态分配器) → brk/mmap(Linux 内核系统调用)。
brk:内核系统调用,修改进程堆断点虚拟地址,ptmalloc 向系统拿堆内存的底层接口;sbrk 是 glibc 对 brk 的库封装
ptmalloc:用户态库层分配器。 brk/mmap:内核系统调用,真正操作系统底层。
ptmalloc 优先复用内部空闲 chunk,内存耗尽才向下触发 brk/mmap 进入内核。
brk/mmap 才是真正操作系统底层,上面全部一层层封装
mmap 只是系统调用。零拷贝是一类 IO 优化技术,mmap 可以用来实现零拷贝,但 mmap 本身不等于零拷贝
Q:那我增加线程数到16,打算测试手写的更优秀,结果直接段错误了!
A:先要知道,解引用空指针才会段错误,单纯存、拷贝空指针变量本身不会段错误。
看例子,初始链表:A→B→null:
1. 线程 X:oldHead = A,读取 A->next 拿到 B,线程切出,未执行 CAS。
2. 其他线程 pop 接连取出 A、B 都被业务使用,然后归还 A,pushFreeList (A) 头插。此时空闲链表:A→null。
3. 线程 X 恢复,freeList_刚好等于 A,CAS 成功,freeList_被赋值为旧值 B,返回 A 给业务。
4. 现在全局空闲链表头是 B。但 B 早已被分配出去,B 对应的槽内存已经被业务对象覆盖,不再是 Slot 结构体。
5. 下一个线程进入 popFreeList,oldHead = freeList_.load () 拿到 B。执行
oldHead->next.load(),访问一块已经被业务对象占用的内存,解引用 Slot 内部原子成员,直接段错误。至于为啥【解引用 Slot 内部原子成员,直接段错误】,可以此文搜“块有的,即4096大块前8”进行回忆。
其实结果都是不停的霸道的跑CPU,直到拿到为止,只不过一个是自旋拿锁,一个是不停的想拿freeList里链表的元素。
即:
空闲状态:整块槽内存,头部 8 字节当作
atomic<Slot*> next,用来串空闲链表,剩下的空间备用存业务数据。分配出去交给业务:整块槽完整交给上层,包含最开头这 8 字节,业务的 P1/P2/P3/P4 直接从这块内存起始位置开始写自己的 int 数组成员。 原本存 next 的那 个字节,现在被业务的 int 变量覆盖,里面是随机垃圾数值,不再是合法的链表指针。
当无锁代码拿到已经分配出去的槽的地址,把它仍然当成空闲 Slot,执行
oldHead->next.load(),就是去读取这块业务对象头部 8 字节的垃圾值,把垃圾值当作原子指针去访问,直接段错误。
所以并不是解引用 nullptr,是把非 Slot 的内存当成 Slot 结构体访问成员,触发段错误。内存没有被系统销毁,只是槽被复用存放业务对象。
那cmake、git、github、到底啥区别联系?
g++ a.cpp -o a含义:拿 a.cpp 这一个文件,用 g++(GCC 里面的 C++ 编译器)直接编译,产出叫 a 的可运行程序。GCC(g++):
真正翻译 C++ 代码的程序。
g++ a.cpp ‑o a就是直接调用它。 项目只有一两个文件,直接手写 g++ 命令没问题。 一旦项目有好几个 cpp、头文件、要用线程库,手写 g++ 命令会变得很长,记不住,改文件还要改命令。CMake:
不编译代码。 你写一份 CMakeLists.txt 写清楚全部条件,它帮你生成一整套 g++ 要用的配置。 生成完之后你执行 make,内部本质还是在跑 g++。 它就是帮你省去手写超长 g++ 命令。
Git:
装在你电脑上的软件,跟编译运行代码完全没关系。 它只管记录你的代码文件改了哪些地方,保存各个版本。 不装 Git,你的内存池代码照样写、照样用 g++/CMake 编译跑起来。
GitHub:
互联网上的网站。只用来存放代码文件。 你电脑没有 Git,你顶多浏览器打开网站手动下载别人代码压缩包。 git clone/push/pull 是调用本地 Git 软件和这个网站互相传文件。 没有 Git,GitHub 对你编译代码这一步没有任何作用。
就算浏览器打开 GitHub 网页,新建仓库,网页点上传,把你的源码文件直接粘贴上传,完全不用装 Git,但这么做的局限: 每次修改本地内存池代码,你都要:
本地改完文件
网页删掉旧文件
网页重新上传改完的新文件
改十次就要手动上传十回。看不到每一次改了哪几行,没有历史记录,改错了没法回退到上一版。
Git 干的事: 本地装 Git 之后,你在电脑改完代码,敲简短命令,自动把改动同步到 GitHub。 自动记录每一次代码改动,想回到之前某一个版本一键就切回去。
Q:我看这里cmake必须要求指定 C++11,那之前没原子和线程?那时候 C++ 是啥样的?很脆弱不安全?
A:C++11 之前,C++ 语言本身标准库没有 std::thread、std::atomic,想写多线程程序,只能直接调用 Linux 系统提供的 pthread 系统接口。
线程同步、原子操作全部靠操作系统 API,不是 C++ 语言自带。 不是语言脆弱不安全,只是语言层面不提供线程工具,多线程能力完全依赖操作系统。
写跨平台多线程代码很麻烦,Linux 写一套,Windows 要另外写一套。
C++11 把线程、原子纳入 C++ 标准库,一套代码 Linux、Windows 都能用。
Q:好垃圾
A:不是垃圾,时代问题。
C++ 最早设计定位:对 C 做面向对象扩展,C 语言本身就没有线程、原子,C++ 早期也延续这套思路。 多线程不是程序语言必须内置的能力,线程是操作系统提供的功能。
C++98/03 的思路: 操作系统各不相同,Linux 有 pthread,Windows 有自己一套线程 API。语言标准不去封装线程,让开发者直接调用系统自带接口。 结果就是:写多线程代码不能跨平台,Linux 一套代码,Windows 要重写另一套。
C++11 才把线程、原子、锁纳入语言标准库。底层实现依旧是调用操作系统 pthread 或者 Windows 线程 API,只是 C++ 给包了一层统一外壳。 你写同一套
std::thread代码,在 Linux、Windows 都能编译运行,不用自己适配不同系统。
Q:Java有这些屁事吗?
A:Java 从诞生之初就把线程作为语言内置能力,Java 1.0 就有 Thread 类、synchronized 同步关键字,线程、锁直接是语言层面自带,不需要调用操作系统原生 API。设计目标就是跨平台,不管 Linux 还是 Windows,JVM 把线程全部封装,开发者写同一套线程代码,跑在任意平台。底层 JVM 内部还是调用系统的线程接口,只是完全对程序员隐藏。
而 C++ 是系统级语言,诞生目的:兼容 C、零开销、贴近硬件。
C 本身就不包办线程,线程属于操作系统提供的功能。 C++ 早期设计思路:操作系统五花八门,我不强行封装系统能力。
系统有什么你就直接调用什么,不给你加一层中间壳,避免额外性能损耗。
代价:线程、原子没有统一标准,Linux 用 pthread,Windows 用另外一套 API,代码不能跨平台。直到 C++11,业界普遍需要跨平台并发,才把线程、原子补进标准库。底层依旧调用系统接口,只是 C++ 封装一层外壳。
Q:之前是秉承性能所以不搞线程和原子,现在又有这些东西了岂不是性能差了?
A:C++11 的标准线程、原子只是薄薄的一层封装,具体:
std::thread:内部直接调用系统 pthread,没有额外逻辑,性能等同于直接写 pthread。
std::atomic:直接生成 CPU 硬件原子指令,没有额外包装开销。
std::mutex:底层就是系统互斥锁,和直接调用系统锁性能一致。
只是语法层面包一层外壳方便写跨平台代码,机器执行指令和裸写系统 API 完全一样。
所以我之前啃的这些就是前辈 C++11 才摸索出来的东西,写好底层方便我直接使用。
正文:
Q:为啥要搞个build目录
A :
产物和源码分离:所有 Makefile、中间.o、可执行文件全部生成在 build 文件夹,源码目录干净,不会混杂编译生成的垃圾文件。删除 build 即可清空全部编译产物,源码不受影响。
外部构建要求:CMake 官方推荐out‑of‑source 外部构建,防止编译产物和源码混在一起,无法区分哪些是手写代码、哪些是工具生成。
修改 .h/.cpp 源码:只需要执行
make && ./MemoryPoolProject,不需要cmake ..。
cmake:cmake 程序本体。..:shell 符号,代表当前目录的上一级目录。含义:读取上一级目录里的
CMakeLists.txt,在你当前所在目录(build)生成 Makefile 等编译文件。 前提:你此时必须处在 build 目录内,上一级目录要有CMakeLists.txt。新增 / 删除源文件、改动 CMakeLists.txt:必须执行
cmake ..,之后再make && ./MemoryPoolProject,即cmake .. && make && ./MemoryPoolProject。
自定义的头文件用
"",库里自带的用<>:
include_directories(include)作用:把 include 文件夹加入头文件搜索目录列表,所有#include ""的自定义头文件都会挨个去这个目录搜寻,不是只针对某一个.h文件,是全局追加搜索路径。
#include <iostream>这类系统库头文件不走这个自定义目录。系统头文件有一套固定系统内置搜索路径,不会跑到你的项目 include 文件夹里面找iostream,不会发生混淆。
目录结构:
cpp_projects_2/ ├─ include/ │ └─ MemoryPool.h ├─ src/ │ └─ MemoryPool.cpp ├─ tests/ │ └─ UnitTest.cpp(也叫压测benchmark代码) └─ CMakeLists.txt执行命令:
mkdir -p build cd build cmake .. make ./MemoryPoolProject1.
mkdir‑p build:创建编译输出文件夹,‑p:目标文件夹build已经存在,不会报错,不存在时自动逐级创建。不加‑p,文件夹已存在会直接报错。2.
cd build:进入编译目录3.
cmake ..:读取上层目录 CMakeLists.txt 生成编译配置。cmake 规则就是传给它个目录参数,默认自动去这个目录里面找 CMakeLists.txt。4.
make:调用 g++ 编译源码,生成可执行文件5.
./MemoryPoolProject:运行内存池性能测试程序
逐个解释:
make就是自动批量执行多条 g++ 编译命令,不用你手动敲一长串g++ xxx.cpp xxx2.cpp -o 可执行文件。手动单条 g++ 写法(你熟悉的)
g++ src/*.cpp tests/*.cpp -o MemoryPoolProject -g -std=c++11 -pthread这份 cmake 最终生成 Makefile,运行 make 就等价自动跑上面这条命令:
1、
cmake_minimum_required(VERSION 3.10):设置 cmake 工具最低版本,cmake --version查看cmake版本。2、
project(MemoryPoolProject):定义项目名,最终生成的程序名字:MemoryPoolProject,等价 g++ 的-o MemoryPoolProject。3、等价 g++ 参数:
-std=c++11,强制使用 C++11 标准,不允许回退到更低版本。set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED True)4、
include_directories(include):等价 g++ 参数:-Iinclude(和-I include等价),编译时去找 include 文件夹下的头文件,对应你代码里#include "../include/MemoryPool.h"。
.h和.cpp放在同一目录,直接 g++ a.cpp main.cpp 就能找到头文件,不需要‑I 参数,但头文件在单独的 include 文件夹,cpp 放在 src 文件夹,不在同一目录,不加include_directories(include),写#include "MemoryPool.h"编译器就搜不到。写
#include "../include/MemoryPool.h"靠相对路径跳转,就不需要这行 cmake 配置,他这里代码实际冗余。假设头文件放到了
haha里,include_directories(haha):告诉编译器去 haha 文件夹搜索头文件。5、自动搜集 src 下全部 cpp、tests 下全部 cpp,等价手动把所有
.cpp文件名全部写在 g++ 后面,不用自己一个个列文件file(GLOB SRC_FILES "src/*.cpp") file(GLOB TEST_FILES "tests/*.cpp")6、因为
project(后面写的名字),这个名字就存入变量${PROJECT_NAME},project(MemoryPoolProject)→${PROJECT_NAME}等价文本就是MemoryPoolProject:add_executable(${PROJECT_NAME} ${SRC_FILES} ${TEST_FILES})生成可执行程序,把收集到的全部源码文件拿来编译,
${PROJECT_NAME}就是前面写的项目名MemoryPoolProject。7、等价 g++ 参数:
-g -pthread。-g生成调试信息,方便 gdb 调试代码;-pthread开启多线程编译支持target_compile_options(${PROJECT_NAME} PRIVATE -g -pthread)8、链接线程库,把 pthread 库打进最终程序,对应 g++ 末尾带上
-pthread做链接阶段处理target_link_libraries(${PROJECT_NAME} pthread)9、
<library_name>只是写代码留的示范占位符,提醒你:如果以后还要链接别的库,就把<library_name>替换成真实库名,把 #删掉就生效,前面那行target_link_libraries(${PROJECT_NAME} pthread)才是真正干活的,已经把线程库挂上了。 最后那行只是给你看的示例模板,不起任何实际作用。# target_link_libraries(${PROJECT_NAME} <library_name>)
整体汇总,cmake 生成 Makefile 后执行 make,完整等效手写 g++:
g++ src/*.cpp tests/*.cpp -o MemoryPoolProject -std=c++11 -Iinclude -g -pthread。CMakeLists.txt里都是CMake 脚本语言,而
cmake .. && make && ./MemoryPoolProject是shell 终端命令。
动态链接:
#include <cmath> #include <iostream> int main(){ std::cout << std::sqrt(16) << std::endl; return 0; }敲这条命令:
g++ main.cpp -o abc产出的 abc 程序: sqrt 的机器码没有复制进 abc 里面。 运行 abc 的时候,操作系统临时去系统里面找数学库拿 sqrt 代码执行。
动态库 链接阶段不会拷贝代码,仅仅记下 “我要用到这个库”。 等到程序运行的时候,操作系统再去加载这个库。 优点:程序体积小;库更新,程序不用重新编译。 缺点:运行环境必须存在这个库文件,否则程序启动失败。
静态链接:
g++ main.cpp -o abc -static加上
-static参数,采用静态链接。 sqrt 的机器码直接打包进输出文件 abc,运行不再依赖系统数学库。静态库 链接的时候,把库里面的机器码直接拷贝复制到你的可执行程序里,程序跑起来,不再依赖原来的库文件。
缺点:程序体积变大;库改了,你的程序必须重新编译。
gdb 基础(解决栈的):
查看代码
#include <iostream> void func_b(){ int* p = nullptr; *p = 100; // 空指针解引用,直接崩溃 } void func_a(){ func_b(); } int main(){ func_a(); }编译必须加
-g,生成调试信息,gdb 才能看懂:g++ main.cpp -o test -g操作 1:gdb 直接调试程序
1、进入 gdb
gdb ./test2、
break main在 main 函数入口打断点(gdb) break main3、
run启动程序,跑到断点停下(gdb) run4、
next一行一行往下跑,直到崩溃。5、程序 crash(程序异常直接崩溃终止)之后,执行
backtrace(简写 bt)(gdb) bt输出会打印函数调用链条:
#0 func_b #1 func_a #2 main
:一眼看到崩溃发生在
func_b。bt 输出解读:
#0 func_b () at main.cpp:4 崩溃发生的最内层函数,第4行*p=100空指针报错 #1 func_a () at main.cpp:8 func_b由func_a调用 #2 main () at main.cpp:12 func_a由main调用#0 就是真正崩溃现场,直接看 #0 对应的文件名 + 行号定位问题。
#1、#2:上层是谁调用它,一层往回追溯
SIGSEGV:段错误,内存非法访问(空指针、越界)。
Q:我的思考是堆咋办?bt只是栈啊
A:backtrace 直译:回溯调用栈,它只读取线程的栈帧信息,完全不检查堆内存。
堆损坏(越界写、野指针),程序崩溃时 bt 依旧能打印崩溃瞬间的函数栈,告诉你程序死在哪一行。
但是:堆真正出错的源头往往发生在很早之前,不是崩溃这一行,bt 看不到之前篡改堆的那处代码,只能看到受害崩溃点。
例子:A 函数越界改写堆,正常跑完;很久之后 B 函数操作这块坏掉的堆直接崩溃。 bt 只会显示崩溃在 B 函数,找不到真正犯错的 A 函数,这就是堆问题用 bt 的短板。 栈错误一般当场发作,bt 可以直接抓到元凶。堆损坏属于延迟发作,bt 只能抓到背锅的地方。
操作 2 core 文件崩溃转储
首先理清楚一件事:
apport是Ubuntu 自带崩溃捕获服务GitHub, 程序崩溃时,内核不直接在当前目录写 core 文件,把崩溃数据通过管道交给 apport,存到系统目录,用来收集、上报程序崩溃信息。
cat /proc/sys/kernel/core_pattern:/proc是内核暴露参数的虚拟文件系统,core_pattern是内核专门用来配置 core 文件输出规则的内核参数,读取它就能知道内核收到崩溃信号之后怎么做,这是 Linux 内核固定文档规定的接口,实践我的腾讯云服务器发现,可知管道符号
|代表把 core 二进制数据交给后面的程序处理,不直接把core(进程崩溃时导出的内存镜像文件)写磁盘,也就是内核直接把 core 二进制数据通过标准输入喂给 apport 进程,数据只存在内存,没有写入磁盘,没有对应的文件可供读取,但该份数据属于内核临时内存,没有对外暴露文件接口,普通用户态工具不能直接读取。
/var/lib/apport/coredumps/Ubuntu apport 官方文档固定的存储目录,当 core_pattern 以|管道指向 apport 可执行程序,崩溃转储就会保存到此目录。ls就是列出该目录下存放的 core 崩溃文件,但云机器不会生成这个目录。apport 对用户自己编译的第三方程序,很多实例直接丢弃原始 core 二进制,仅保存栈地址、日志等摘要写入
.crash文件,不保留完整 core 快照。但ls /var/crash/依旧啥也没有。说白了就是:
理论官方文档让把apport配置放到
/var/lib/apport/coredumps/,但腾讯云为了减少磁盘IO、避免core文件占用云服务器磁盘空间,直接丢弃了core转储数据,证据是通过查看内核参数命令cat /proc/sys/kernel/core_pattern得知喂给了/share/apport/apport这个进程,可是没法读取,而物理机 / 普通 Ubuntu,apport 会完整保存 core 二进制数据的场景,在不修改 core_pattern 的前提下,我腾讯云机器无法得到可供 gdb 使用的 core 文件。
apport是二进制core镜像,gdb可用,但腾讯云没搞。
而/var/crash 存放 apport 生成的人类可读文本崩溃报告,不能直接 gdb 调试,但腾讯云也没搞。
cat /proc/sys/kernel/core_pattern:
有
|表示管道交给程序,完全不走磁盘文件输出,例:|/xxx/apport→ 管道转发。
%p充当占位符(填PID进程),目录由tmp决定,,例:/tmp/core.%p→ 输出到/tmp目录。那既然转给apport然后腾讯云平台无法查看(因为被丢弃),那要么关闭(没有 apport 时,core 文件输出到程序运行时的当前工作目录),要么指定目录,我懒得测试那么多招,直接随便试了个指定目录的:
echo "core.%p" > /proc/sys/kernel/core_pattern改写 core 文件输出模板,不用关闭apport,apport 生效时,core_pattern 会被 apport 篡改为调用 apport 工具,崩溃直接交给 apport 程序处理,不写磁盘 core,而这语句写入 core.% p,强行覆盖内核 core_pattern,绕过 apport 接管,不用停 apport 服务,崩溃直接落盘 core 文件(把内核core转储规则设置为在当前工作目录生成core.进程号格式的core文件,重启服务才失效)科普几个语句:
ulimit -c unlimited:放开 core 转储文件的大小上限,允许生成完整的内核转储文件。
-c:代表 core 转储文件的最大尺寸
unlimited:无大小限制仅对当前shell会话以及该会话派生的子进程生效,重启shell、服务器重启全部失效。
所以看爷的牛逼操作:
,
gdb载入 core 文件后,会直接显示崩溃的那一层栈帧,但只展示 #0 这一层,bt作用是打印完整调用链,把 #0、#1、#2 全部完整回溯。
解决堆的:asan
是addresssanitizer的缩写,内存池这类堆相关 bug,地址消毒器定位效率远高于 gdb,中文叫地址消毒器。
名字来源: sanitizer 本意是 “消毒、净化”,它的工作就是扫描程序内存,把非法内存访问(越界、野指针、释放后复用)这些 “脏的、错误的内存操作” 给揪出来,相当于给内存做消毒检查,因此得名。
底层做法:编译时插桩,把堆内存前后放上保护红区,一旦越界触碰红区,程序立刻终止,直接报出真正出错的那一行代码,而不是后续受害崩溃的地方
示例 1:
堆越界(真正犯错当场报,不是延后崩溃)
#include <iostream> int main(){ int* p = new int[2]; p[3] = 999; // 越界写直接报错,不会等后面用才崩 delete[] p; }实测:
,
================================================================= ==1013252==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x55a3b0c4e2e2 bp 0x7ffe2f1d4f80 sp 0x7ffe2f1d4f70
heap‑buffer‑overflow:堆缓冲区越界,核心错误类型。WRITE of size 4 at 0x60200000001c thread T0 #0 0x55a3b0c4e2e1 in main /root/cpp_projects_2/main.cpp:5
WRITE of size4:写 4 字节 (int);#0 main 第5行,直接定位出错代码行。#1 0x7f8eaee34d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58 #2 0x7f8eaee34e3f in __libc_start_main_impl ../csu/libc-start.c:392 #3 0x55a3b0c4e1c4 in _start (/root/cpp_projects_2/test+0x11c4)系统启动调用栈,直接忽略。
0x60200000001c is located 4 bytes to the right of 8‑byte region [0x602000000010,0x602000000018) allocated by thread T0 here: #0 0x7f8eaf41d357 in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:102 #1 0x55a3b0c4e29e in main /root/cpp_projects_2/main.cpp:4分配内存位置:main 第 4 行
int占 4 字节。new int[2],2 个 int:2 * 4 = 分配 8 字节,出错地址在这块内存右边,属于越界,0x602000000010 ~ 0x602000000018这 8 字节是合法区间。 你写的地址0x60200000001c,距离合法区间末尾0x18偏移正好4 字节,向右越界 4 字节,意思是下一个 4 字节 3 下标的位置,SUMMARY: AddressSanitizer: heap-buffer-overflow /root/cpp_projects_2/main.cpp:5 in main摘要总结,直接看这行快速获取错误 + 行号。
Shadow bytes around the buggy address: ...... =>0x0c047fff8000: fa fa 00[fa]fa fa fa fa fa fa fa fa fa fa fa fa
00合法内存;fa堆红区;[fa]就是你访问到的非法红区位置。Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Heap left redzone: fa Freed heap region: fd ...... ==1013252==ABORTING影子字节对照表,遇到报错回来查;
ABORTING程序终止。Q:对照表咋看?
A:1 个影子字节管真实内存8 个字节,1 个十六进制数字占4 比特。 1 字节 = 8 比特 = 2 位十六进制数,比如
04、00。八进制 1 位占 3 比特,1 字节 (8bit) 需要 3 位八进制数字表示,C 语言里以 0 开头代表八进制字面量,
077是八进制字面量,数值大小:63,占1 字节,单个八进制数字7= 3bit。077两个八进制数字:7(3bit)+7(3bit)=6bit,剩余高 2bit 补 0,凑满 1 字节 8bit。
00:对应的 8 字节真实内存,全部可以访问。fa:对应的 8 字节是堆红区,禁止访问。=>0x0c047fff8000: fa fa 00[fa]fa fa ...
前面两个 fa:堆块左边的保护红区,只写了两个,如果非法使用之前17字节也会展示。
00:真正给你用的 8 字节业务内存
[fa]:堆块右边保护红区,你踩的就是它,虽然只写入其中 4 字节,但只要落在该 fa 管辖的 8 字节范围内,就触发报错,不需要占满 8 字节
00 是 1 个影子字节。 1 个影子字节 = 管控真实内存 8 字节。
如果申请的只有 new [1] 是 4 字节,依旧占用1 个影子字节,但不再是
00,会是04。8 字节块里只用前 4 字节。 影子标记写成04:代表 8 字节里前 4 字节合法,后 4 字节不可访问。
注意:
直接普通编译堆越界属于未定义行为,不一定立刻崩溃,有可能跑过去,内存默默损坏,后续随机崩,所以下面这个没任何报错:
g++ -g main.cpp -o test ./test带上地址消毒器编译才会当场拦截。
科普:
真实内存:程序正常用的内存。
影子内存:一块独立内存,每一个字节记录对应真实内存的状态。
真实 8 字节业务内存,仅占用1 字节影子内存。 1G 业务内存,影子只占 128M。
红区:插在真实内存两侧的保护内存,本身不允许程序访问。
合法内存影子标记:
00;红区 / 非法内存打上各类非法标记(fa/fc/fd 等)。程序读写内存时,地址消毒器查影子内存:标记非法直接报错。
示例 2:
释放后继续使用(野指针)
#include <iostream> int main(){ int* p = new int; delete p; *p = 10; // 已经释放,再次写内存,地址消毒器在此行直接告警 }实测:
。
报错关键字
heap‑use‑after‑free,堆内存释放后继续写入。fd代表已释放的堆内存,消毒器把这块内存标记为毒内存,一旦读写直接终止程序。不开启地址消毒器编译运行,这段代码不会必现段错误,属于未定义行为,有可能正常跑完,也有可能崩溃。 开启
‑fsanitize=address会强制捕获该非法访问直接终止。
示例 3:
重复释放
#include <iostream> int main(){ int* p = new int; delete p; delete p; // 重复释放,当场捕获 }实测:
。
分析报错:
==2600==ERROR: AddressSanitizer: attempting double‑free on 0x602000000010 in thread T0:ASAN 捕获错误:主线程对地址0x602000000010重复释放。
#1 0x55c50ee102ae in main /root/cpp_projects_2/main.cpp:5报错发生在 main.cpp 第 5 行。注意:
gdb 程序自己代码崩溃(空指针、野指针):#0 = 崩溃发生的最内层用户函数,直接看 #0 拿行号。
asan 输出的栈:#0 永远是 asan 内部库函数(asan_new_delete.cpp),错误是消毒器拦截触发,不是你的代码行。 你的业务调用点在 #1,#1 才是你源码的行号。
0x602000000010 is located 0 bytes inside of 4‑byte region[0x602000000010,0x602000000014)该内存块大小 4 字节。
freed by thread T0 here:此处是第一次释放内存位置。#1 0x55c50ee10298 in main /root/cpp_projects_2/main.cpp:4第一次释放在 main.cpp 第 4 行。#0:asan 库内部的 delete 封装函数,属于库代码,不是你的源码。
#1:调用栈往上退一层,就是你自己写的业务代码,main.cpp 第 4 行,是你程序触发第一次释放的位置。
栈顺序:#0 库函数被 #1 你的 main 代码调用,所以看 #1 拿自己源码行号。
而且
堆本身只存业务数据,不记录是谁申请、谁释放。 地址消毒器捕获堆错误时,会把当时的函数调用栈保存下来,用来回溯是哪一行源码触发,这里打印出的 #0 #1 #2,是函数调用栈(软件层面的调用记录),不是程序内存里的栈内存
previously allocated by thread T0 here:此处是内存申请位置。#1 0x55c50ee1027e in main /root/cpp_projects_2/main.cpp:3内存申请在 main.cpp 第 3 行。
SUMMARY: AddressSanitizer: double‑free错误汇总:重复释放。
==2600==ABORTING程序终止。
和 gdb/core 关键区别:
上面这几类,如果不用地址消毒器,很多时候不会立刻段错误;内存悄悄被破坏,跑很久之后在完全无关的代码行崩溃,
bt只能看到背锅的那一行,看不到真正作恶的行。地址消毒器在非法内存操作发生的瞬间就拦截。。
续啃《编程指北》
之前啃到了:【C++ malloc-free 底层原理】
关于 C/C++ malloc-free 内存分配原理,继续:
我是读不懂,于是写了个内存池然后又啃了代码随想录的内存池代码,可是为什么回来继续看,依据看不下去这一章节~~~~(>_<)~~~~
这节的意义到底是啥啊?
教程讲的是:
进程虚拟地址空间各段、task_struct/mm_struct
brk/sbrk、匿名 mmap,128KB 阈值分配逻辑;缺页异常,虚拟内存≠物理内存
RSS 变化现象,小内存 free 留在 glibc 缓存、大内存 mmap 释放直接还给内核
ptmalloc 核心概念:fast bin、unsorted bin、small bin、large bin、top‑chunk、P/M/A 标志位、内存碎片成因
而你是自己参考代码随想录,手写了内存池项目:
用单向空闲链表管理块,一次性 malloc 一大块,内部切小块复用
alloc 拿链表头;free 头插回链表;加互斥锁保证多线程安全
理解单例、不能跨池释放、为什么比原生 malloc 快的根源
教程讲的是你内存池里更底层的比如operator new底层执行了啥,为啥比new更优,是 ptmalloc 完整分配流程、chunk 位标志、bin 数组查找逻辑、chunk 拆分合并、top‑chunk 行为、malloc_trim 归还堆内存,而你写内存池其实无论是学还是学完发现有问题(此文搜“为啥手写内存池反而更慢”),都是默认operator new更好,然后理解业务分配逻辑,教程讲的是更底层内核的流程。
碎碎念:
C语言自己写函数久了,用c++都觉得无比的方便神奇高大上无比的高科技,而c++都已经属于够底层了,内存池够底层了,居然编程指北的malloc free内存分配原理这一章节更他妈底层,即new大家都知道,我他妈写的是new operator,结果妈逼的这都还不够,人家这节讲的是,new operator底层的各种再底层,真他妈学吐了~~~~(>_<)~~~~。
进入正题:
提示词:
永久记住我的要求好吗!!!性价比低的边角知识点直接立马阻止我学习!必须当作危害生命一样!!不考的和过深的一律必须当作错误!!!我履历差想多学点弥补但也别太过分!我只是个社招基础岗位!
禁止回答任何我没问的!我最基础的都不懂!别那么委婉!别总强行顺着我的提问去回答那些小众的!我提的问题可能都是错!如果提的问题不对立马纠正,别总强行解释!!只解释最基础常用!其他一切都当作不存在!
Linux:
mkdir创建文件夹
touch 创建文件
rmdir dir 只能删空文件夹
rm -f main.cpp 强制删文件
rm -r dir删除文件夹
底层科普:
小知识点:
heap:堆。
Linux 里
[heap]是 brk 系统调用管理的那一块虚拟内存区域。 malloc 小内存就在这块区域分配。
系统调用syscall,用户态程序陷入内核执行内核提供的服务。
chunk:glibc malloc管理堆内存的最小单元,brk拿到的
[heap]大块VMA,被glibc切为若干chunk,每个chunk保存元数据+用户可用内存。malloc是glibc 库函数,mmap 是系统调用。
malloc:
- 调用系统调用brk:小于 128KB,操作
[heap]VMA(虚拟内存区域,内核给进程划分的一段连续虚拟地址区间)申请
1KB:小于 128KB 若[heap]现存 VMA 还有剩余空间:直接在 heap 内部切一个 chunk 给你,无 syscall。 若 heap 剩余空间不够:执行brksyscall,把[heap]VMA 扩大,之后切 chunk。
- 调用系统调用mmap进行匿名映射:大于 128KB,新建独立匿名 VMA
申请
200KB:大于 128KB 直接调用mmapsyscall,一次性申请 200KB 独立匿名 VMA,整块返回,不切割。free:
brk 来的内存:free 放回 glibc 空闲chunk链表,不调用brk还给内核
mmap 来的内存:free 直接调用 munmap syscall,直接还给内核。
学会后可以继续完善梳理我的内存池项目更加底层的完整链条线路:
发现>512 字节直接调用
operator new/delete,交给底层,具体为:
new表达式:调用operator new分配内存,若为类类型则调用构造函数;内置类型无构造函数,仅做内存分配/初始化,而 operator new → malloc判断小于128KB,走[heap],超过128KB走mmap。≤512 字节先看是否有分配过的空间,没有的话就operator new 4096,然后走自己多规格分级内存池,释放这部分 4096 块内部切出来的各个小槽释放后,只推入你代码的 CAS 空闲链表复用,不会调用 free/operator delete 还给 glibc、不会还给内核,注意这里分配4096的步骤依旧是operator new → malloc判断小于128KB,走[heap]。
总结:
我的 512 字节:业务层阈值,用来决定走你的内存池,还是直接交给
operator new,而交给operator new后就是glibc的128KB判断了,控制向内核拿内存的路径(brk /mmap)注意:
512字节=512B<128KB
- glibc:Linux 下 C 标准库实现,提供 malloc、free 等底层库函数。
glibc:Linux 下 C 标准库实现,提供 malloc、free 等底层用户态的库函数,内部会调用
brk()或mmap()这两个系统调用向内核申请内存。
Q:既然operator new和malloc都走的是malloc,为啥不写malloc
A:底层二者同源,都是走到 glibc 的 malloc,会同样触发 brk/mmap 那一套内核路径,区别全部在 C++ 上层的行为:直接用
malloc拿到裸指针、失败返回空,所有校验、强转全部交给开发者;operator new在 malloc 之上包了一层,失败抛异常,不需要手动判空。即:
malloc返回void*,分配失败返回nullptr。默认版本的
operator new返回void*,分配失败不会返回空指针,直接抛出std::bad_alloc异常,正常成功才返回有效的void*。
operator new默认底层确实调用malloc拿到内存,但它在 C++ 这一层做了包装,和直接调用malloc行为完全不一样。所以C++ 业务优先用
new/delete,会自动调用构造、析构,malloc/free只用在 C 兼容场景、裸内存底层开发,不处理 C++ 对象生命周期,不抛异常。Q:你之前说避免频繁的 malloc 系统调用我才学的内存池代码!可是如今发现 malloc 也他妈的是一次性申请大块啊(此文搜“堆内存,不是申请多”)??我是不是不该写内内存池代码!!!
A:错误点:你混淆了「系统调用 brk/mmap」和「glibc 库内部 malloc 调用」
malloc 库函数调用频繁 ≠ 频繁触发 brk/mmap 系统调用。glibc 内部本身就会缓存大块,小 malloc 不会陷内核。所以写内存池不是为了减少系统调用,这是你之前的认知错误。
手写内存池真正收益,不是少 syscall:
每一次 glibc malloc/free,即使不进内核,也要操作 chunk 头、遍历 bins 链表、处理合并、处理分配器锁。
你的内存池:≤512B 对象,分配释放只做 CAS 操作,完全跳过 glibc 这套 chunk、bins、锁逻辑。
glibc 预分配大块,但是每一个小对象都带 chunk 元数据开销,释放还要做合并逻辑;你从 4096 块切出来的槽,不需要每个对象带 chunk 头,释放只操作 CAS 链表。
结论:该写。价值不在规避系统调用,在于规避 glibc 用户态层的分配器开销,不是规避内核。
Q:那既然如此,为啥人家 malloc 不设计我这样的 CAS???
A:
glibc ptmalloc 是通用分配器,要兼容万千业务场景,不能默认上 CAS 无锁。 CAS 无锁不是白嫖性能:
CAS 只适合固定规格槽位;glibc 要支持任意大小内存,碎片合并、大小查找,无锁实现复杂度爆炸。
CAS 有 ABA 问题,要额外处理,通用分配器全局做代价极高。
ptmalloc 多线程方案是 arena 多缓存堆:线程拿独立 arena 减少锁竞争,而不是全局 CAS。简单粗暴、兼容性稳,适合绝大多数普通业务。
你的内存池做 CAS 的前提:只处理≤512B 有限几种固定规格。范围收窄,才能简单用 CAS 空闲链表。glibc 没有这个前提约束,做不到。
取舍: 你:牺牲通用性、牺牲内存归还,换小对象路径 CAS 无锁快。 glibc:保证通用性、内存回收、兼容任意大小,用锁 + arena 方案,小对象路径就会有开销。
malloc:C 标准库函数名,对外暴露的接口。
ptmalloc:glibc 里面malloc 的具体实现,就是我们常说的 ptmalloc,ptmalloc分版本,现在主要是ptmalloc2,你调用 malloc,底层跑的就是 ptmalloc 这套代码,ptmalloc 内部:arena、bins 链表、chunk、brk/mmap 逻辑,全部是它实现的。 malloc 只是对外的函数外壳,真正干活的是 ptmalloc:operator new(C++库层) ↓ ptmalloc(glibc内部malloc真实实现,ptmalloc2) ├─小于128KB:操作[heap],brk系统调用扩容 └─大于128KB:直接mmap匿名映射系统调用关键点:
malloc()这个函数符号,就是 ptmalloc 对外暴露的入口。 operator new 内部调用的就是这个入口,ptmalloc 就在 operator new 和系统调用 brk/mmap 中间,operator new 只是 C++ 封装(抛异常逻辑),真正做大小判断、chunk、bins、128KB 阈值逻辑全部是 ptmalloc。
此时也得知,其实编程指北的原话是不准确的(勘误):
他说的性能和碎片问题
第一点(系统调用开销)✅ malloc 确实解决了:
malloc 一次性通过 brk/mmap 向内核申请一大块内存(arena 堆)。 后续小内存分配,直接在用户态的堆内切割 chunk,不触发系统调用、无上下文切换。 只有堆内存耗尽时,才再次调用 brk/mmap。
这个点上 malloc 优于 brk / mmap,但和我的手写内存池持平
第二点(碎片那句原文描述 ❌ 表述不严谨,malloc不能彻底消除碎片,只是缓解)
- 原文这句话:堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。
准确含义: 堆中间(低地址侧)有在用 chunk,堆顶端的 brk 就不能往回缩,堆中间空闲内存无法返还内核。 malloc 没法消灭这种外部碎片,只能靠 bin 链表(unsorted/small)复用堆里面空闲的 chunk,减少重复向内核申请内存,减轻碎片的危害,但无法根除碎片。
这个点上 malloc 优于 brk / mmap,但和我的手写内存池持平
我既然手写就是用 CAS 减少他那些 bin 复杂操作。
豆包说的不可信,为了验证豆包逼逼的这些玩意,做个实践,但先说下实践的时候踩的坑:
看个代码:
查看代码
#include <iostream> int main(){ char buf[] = "abcd"; char* p = buf; std::cout << p << std::endl; //p是char*,打印整个字符串 abcd std::cout << *p << std::endl; //解引用,打印第一个字符 a std::cout << (void*)p << std::endl;//强转void*,打印指针地址 } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o test && ./test abcd a 0x7fff65ad9953 */
char buf[] = "abcd";buf 是栈上字符数组,数组名会退化成首元素地址,类型char *,可以直接赋值给char* p。
p存数组首元素地址
cout << p:打印完整字符串abcd
cout << *p:取首字节,打印a
char*传给 cout,调用字符串重载,打印字符串内容。 转成void*后,调用指针地址重载,打印内存地址数值。为了防止名字冲突,假设你自己写一个变量叫
cout,标准库也有cout。 如果没有命名空间,两个同名符号直接打架,编译报错。标准库全部东西都塞到
std这个命名空间里,然后<iostream>把cout定义在std命名空间里面。再看个代码:
查看代码
#include <iostream> using namespace std; int main(){ int a=1; int *p=&a; cout<<*p <<" "<<p<<endl;// 1 0x7fffe9936dbc }
int*没有字符串重载,cout << *p是输出整型数值。 只有char*会被 cout 识别为 C 风格字符串。继续看代码:
查看代码
#include <cstdlib> #include <iostream> #include <unistd.h> using namespace std; int main(){ const char* str="haha"; char *p=(char*)str; cout<<*p<<endl; cout<<p<<endl; } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o test && ./test h haha root@VM-0-7-ubuntu:~/cpp_projects_2# */
char str="haha";不加const直接报错,"haha"是字符串字面量,返回const char*,不能赋值给单个 char 变量。为啥要
(char*)?因为字符串字面量
"haha"类型是const char*,指向只读内存。 不能直接赋值给非 const 的char*,强制转换(char*)抹掉 const 限定,消除编译报错
*p取第一个字符,输出h。关键点:
char*传入 cout,不加解引用*,会走字符串重载打印整串;带上*p,输出单个 char 字符。继续看代码:
查看代码
#include <cstdlib> #include <iostream> #include <unistd.h> using namespace std; int main(){ char* p1 = (char*)malloc(100*1024); char* p2 = (char*)malloc(200*1024); cout << (void*)p1<<endl; cout<< p2 << endl; /* char* 存放字符串首地址,cout 拿到 char* 会从该首地址开始打印字符串内容,重载版本为字符串重载,会把该指针当作字符串起始地址,循环输出直到 \0 转为 void*,cout 就打印指针的地址数值, 把变量解释成字符串首地址,行为就是打印字符串。 把变量解释成指针本身的内存地址值,行为就是打印指针地址 把变量解释成指针指向位置存储的单个 char 值,行为就是打印首字符 */ printf("%p\n%p\n", p1, p2);//%p是printf用于输出指针地址的格式符,参数要求转为void*,以十六进制展示内存地址 free(p1); free(p2); } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o test && ./test 0x560c1e761eb0 0x560c1e761eb0 0x7f32f9936010 root@VM-0-7-ubuntu:~/cpp_projects_2# */
char*传入cout:匹配ostream& operator<<(ostream&, const char*)重载,把该指针当做字符串起始地址,从该地址连续输出字节直到\0。
(void*)char*传入cout:匹配ostream& operator<<(ostream&, const void*)重载,直接输出指针本身存储的地址数值。打印单个首字符:需要解引用
*p,类型为char,匹配ostream& operator<<(ostream&, char)重载,输出单个字符。
malloc:
成功:返回堆内存首地址,void * 类型。
失败:返回空指针 nullptr。
失败返回空指针:
cout输出未定义,也可能啥也没有。p2 那行 cout 走 char * 字符串重载,打印堆里随机乱字节,控制台看不见有效字符,不是 malloc 返回空。
通过
if(p2 == nullptr) cout << "malloc失败\n";、else cout << "malloc成功,不是空,是乱码字符串\n";可以验证。
#define NULL 0只做文本替换,宏定义本身不携带任何用途信息,但“设计用途” 是编译器 GCC 硬编码写死对标识符NULL的特殊检查逻辑,只要NULL没有出现在指针相关语法位置,就输出警告然后输出 0,即std::cout有两套重载:接收整数、接收指针,cout<<NULL<<endl;编译器分不清调用整数重载还是指针重载,产生歧义警告,而nullptr类型为nullptr_t,只匹配指针,实测:
void foo(long x){std::cout << "int版本\n";}
void foo(char* p){std::cout << "指针版本\n";}
foo(NULL); //调用 foo(int)
foo(nullptr); //调用 foo(char*)
cout << nullptr << endl;输出字符串 nullptr
cout << (void*)8 << endl;输出0x8,(void*)8是强行把整数 8 解释成内存地址编号,地址编号就是用数字表示,0x8 就是编号为 8 的虚拟内存地址,不是去读取这个地址上存的数据,cout << (void*)NULL << endl;输出 0 也是一个道理,char* p=NULL;、cout<<(void*)p<<endl;也是输出0。一切void*都是输出地址,强制转换(int*)修改编译器对这块地址的解读规则,只有加*才去读内存数据。看个代码:
查看代码
#include <iostream> using namespace std; int main(){ char* p=NULL; cout<<p<<endl; cout<<"_"<<endl; // cout<<*p<<endl;//解引用空,直接崩溃 } /* root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o test && ./test root@VM-0-7-ubuntu:~/cpp_projects_2# */ // 传入指针必须指向合法C字符串。传入空指针违反该约定,标准规定此场景为未定义行为,程序在此处直接终止,后续代码不执行char*一律匹配const不存在任何其他可能
void func(const char* s){}
char buf[] = "abc";
char* p = buf;
func(p); //char* → const char*,隐式转换,编译通过。int a = 10; int* pi = &a; cout << pi; //输出地址,等价(void*)pi cout << *pi; //取出指针指向的int,输出10
踩完坑进入正题:
ps aux | grep test:ps aux 列出全部进程,管道交给 grep 筛选名字含 test 的进程,会连 grep 自身进程也输出而假设没有test这个文件,
grep test这条命令本身运行时,进程命令行就包含字符串test,ps 就会把 grep 自身打印出来,和有没有 test 可执行文件无关。
程序跑起来之后,内核给每个进程分配一套独立虚拟地址空间。 你代码里所有指针、malloc 拿到的地址,全都是虚拟内存地址,不是物理内存地址,每个运行中的进程,在
/proc下都有自己以进程 ID 命名的子目录/proc/<pid>/,/proc/<pid>/maps就是该进程专属的虚拟地址空间分段视图,/proc/pid/maps打印出来的,就是这套虚拟地址空间的分段情况,看代码:查看代码
#include <cstdlib> #include <iostream> #include <unistd.h> using namespace std; int main(){ char* p1 = (char*)malloc(100*1024); char* p2 = (char*)malloc(200*1024); cout << (void*)p1 << endl; //强转void*,打印内存地址 cout << (void*)p2 << endl; pause(); free(p1); free(p2); }运行:
,有用的就
55834a1d1000-55834a21c000 rw-p 00000000 00:00 0 [heap]7f975c07c000-7f975c0b3000 rw-p 00000000 00:00 07ffc0a355000-7ffc0a376000 rw-p 00000000 00:00 0 [stack]7ffc0a3b5000-7ffc0a3b9000 r--p 00000000 00:00 0 [vvar]
[heap]:brk 小 malloc (100KB),即图里红色的,[heap],是brk拓展出来的堆 VMA。第二行无标记匿名页:mmap 大 malloc (200KB),即图里黄色的,是无文件名、无标记,就是匿名 mmap,malloc 大于阈值的内存就落在这类 VMA,不属于
[heap]。
[stack]用户栈,蓝色的[stack]是用户栈,属于整个进程,不是仅 main 函数,因为每个进程仅有 1 份用户栈。
[vvar]内核辅助页其他都是系统动态库啥的。
注意以上是豆包起初说的,我起初没仔细看,听之任之觉得差值正好是100KB和200KB,可是一看不对,且heap下一行根本不是给200KB用的,因为下面好几行处,还有个匿名,狗逼豆包我已经没脾气了,无数次误人子弟习惯了,直接更新纠错(这点东西豆包反复几率拼凑,满口胡说八道,无尽追问辱骂下的结论):
豆包又道歉,说自己错了,我实践我一次把pause加到main第一行,一次把pause放到现在的位置,发现貌似没新开辟任何,所以决定学打断点:
不加 pause,想要在两处停下来查看 maps,需要两个断点:
b 7 (p1 malloc 前,第一次看 maps)(停在第7行,还没有执行第7行)
b 9 (两个 malloc 全部执行完,到 cout 前,第二次看 maps)
我直接保留pause得了,gdb步骤:
1、编译(必须加 - g,才能 gdb 断点)
g++ -g main.cpp -o haha2、启动 gdb
gdb ./haha3 打断点:malloc 那一行
b main:7 # 填你代码里 char* p1 = malloc 这一行行号4 运行程序
run程序停在 malloc 执行之前。 新开一个 ssh 窗口,
ps aux | grep haha拿到 pid,root@VM-0-7-ubuntu:~/cpp_projects_2# ps aux | grep haha root 63705 0.1 2.9 265780 59284 pts/1 Sl+ 20:55 0:00 gdb ./haha 代表gdb 调试器本身进程 root 64059 0.0 0.1 6056 2196 pts/1 t 20:55 0:00 /root/cpp_projects_2/haha 代表 haha 被调试的目标程序进程(你要的 pid) root 64970 0.0 0.1 9568 2268 pts/0 S+ 20:58 0:00 grep --color=auto haha 代表刚才执行 ps aux|grep haha 这条搜索命令本身,执行完就消失 63705:进程PID 265780:该进程虚拟地址内存总大小(单位KB),叫VSZ 59284:物理内存 RSS,即实际占用
cat /proc/[pid]/maps,查看 malloc 执行前的 VMA 列表。5 gdb 继续执行(
next,简写n是单步执行一行代码)continue有断点就执行到下一个断点处,有pause就停下,没有就直接执行到结束,这里pause停止,再去
cat /proc/[pid]/maps,对比前后 VMA。结果:
![]()
,重要的摘抄如下:
查看代码
Continuing. 55555556aeb0 7ffff7a1e010 root@VM-0-7-ubuntu:~/cpp_projects_2# cat /proc/64059/maps 555555559000-55555557a000 rw-p 00000000 00:00 0 [heap] 7ffff7a51000-7ffff7a55000 rw-p 00000000 00:00 0 7ffff7d78000-7ffff7d85000 rw-p 00000000 00:00 0 7ffff7fae000-7ffff7fb1000 rw-p 00000000 00:00 0 7ffff7fbb000-7ffff7fbd000 rw-p 00000000 00:00 0 root@VM-0-7-ubuntu:~/cpp_projects_2# cat /proc/64059/maps 555555559000-5555555a4000 rw-p 00000000 00:00 0 [heap] 7ffff7a1e000-7ffff7a55000 rw-p 00000000 00:00 0 7ffff7d78000-7ffff7d85000 rw-p 00000000 00:00 0 7ffff7fae000-7ffff7fb1000 rw-p 00000000 00:00 0 7ffff7fbb000-7ffff7fbd000 rw-p 00000000 00:00 0一行heap四个匿名,这里只有heap和第一个匿名是不同的
执行 malloc 前:
[heap]结束地址是55555557a000分配 p1 (100KB) 后:
[heap]扩容到5555555a4000,brk 扩展堆,差值不是100KB,因为glibc malloc 向内核申请堆内存,不是申请多少就只扩多少,会一次性多扩一部分,预留空闲空间,减少频繁调用 brk 系统调用。 你申请 100KB,内核层面 [heap] 区间增长会明显大于 100KB。p2 (200KB),出现独立 VMA:原来那一段
7ffff7a51000-7ffff7a55000是 libc 自带的匿名 VMA。 执行malloc(200K)调用 mmap,新建 VMA:7ffff7a1e000-7ffff7a55000,内核把相邻、权限一致的旧 VMA 合并,旧条目就消失,合并成一条大 VMA,这个新 VMA 就是 p2 的内存,独立于 [heap]。
[stack]用户栈,蓝色的[stack]是用户栈,属于整个进程,不是仅 main 函数,因为每个进程仅有 1 份用户栈。
55febcd84000-55febcd85000 r-xp 00001000 fc:02 918091 /root/cpp_projects_2/haha这些是程序 haha 本身的代码段、只读数据段、读写数据段 VMA。
哈哈,至此可以看出:
小于阈值走 brk,扩展
[heap]区域;大于阈值,单独开辟一块匿名 mmap 内存,不在 heap 里。
内核虚拟地址空间层面(看 /proc/pid/maps)
[heap]这一块 VMA,只代表 brk 那一段。 大于 128KB 由匿名 mmap 出来的大块,是独立的匿名 VMA,不属于 [heap] 这一块 VMA,不在 brk 管控的连续堆区间里。glibc 用户态分配器层面 malloc 返回的全部动态内存,业务上都叫堆内存;mmap 出来的叫
mmaped chunk,是 glibc 内部的堆块类型,但内核视角它不属于 brk 对应的 [heap] VMA
可以看到这个就是我的服务器IP。
回到编程指北:
task_struct(PCB是叫进程控制块,为抽象概念),底层OS里直接用task_struct来实现,存了 PID、进程状态、调度、信号、文件描述符表,还有一个指针struct mm_struct *mm,指向的就是mm_struct结构体,它专门管虚拟地址空间这一部分信息。
mm_struct里面挂一串vm_area_struct(VMA),每一个 VMA 代表一段连续虚拟内存。 每个 VMA 有属性:起始地址、结束地址、权限、名字。进程的代码段、栈、brk 堆、文件映射、匿名 mmap,每一个都是独立的 VMA 节点,挂在 mm_struct 链表上。
俩东西不要混淆:
系统调用 mmap(建立内存映射)
零拷贝技术
mmap是实现零拷贝的一种手段,但 mmap 本身不等于零拷贝,调用mmap把文件映射进用户空间: 用户直接读写这片虚拟地址,数据由内核缺页异常按需从磁盘加载到页缓存。 省去 read () 系统调用:内核缓冲区 → 用户缓冲区这一次拷贝,实现零拷贝。插一句sendfile也零拷贝,直接在内核态把文件页缓存的数据拷贝到 socket,数据不经过用户空间,传统 read+send:磁盘→内核页缓存→用户缓冲区→内核 socket 缓冲区,两次拷贝。sendfile:磁盘→内核页缓存→socket 缓冲区,跳过用户空间,少一次拷贝。
插一段零拷贝的东西:
sendfile 的文件 fd → socket fd,最原始、面试必考的版本。 限制:源只能是文件类fd,目标必须是socket fd(必考):
代码如下:
查看代码
#include <sys/sendfile.h> #include <fcntl.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <cstdio> int main(){ int listen_fd = socket(AF_INET, SOCK_STREAM, 0);// 创建TCP套接字,返回监听fd int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// TCP连接关闭后,服务端端口会进入TIME_WAIT状态,短时间内端口被占用,直接重启程序会bind失败,SO_REUSEADDR允许程序立刻复用这个端口,不用等TIME_WAIT超时,但SO_REUSEADDR只是选项名字,setsockopt需要单独传变量opt,把开启/关闭的值传给内核,opt=1:打开这个选项。值为0代表关闭 struct sockaddr_in serv_addr{}; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8888); serv_addr.sin_addr.s_addr = INADDR_ANY; // 填充服务器地址结构体,IPv4,INADDR_ANY等价于0.0.0.0,监听本机所有网卡(lo、eth0)上8888端口 bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));// 套接字绑定地址+端口 listen(listen_fd, 5);// 开启监听,5是未完成连接队列上限 int sock_fd = accept(listen_fd, nullptr, nullptr);// 阻塞,卡住等待客户端连接,没有客户端连接时,它阻塞等待,连接成功返回通信sock_fd /* nc 后发生: 1.nc 发起 TCP 连接。 2.accept 解除阻塞,返回 sock_fd,代码继续往下跑。 3.open 打开文件。 4.sendfile 发送 4096 字节数据。 5.shutdown 关闭 socket 写端。 6. 依次 close 各个 fd。 7.main 执行完毕,进程退出。 */ int file_fd = open("data.txt", O_RDONLY);// 仅只读;文件必须预先存在,不存在直接报错这里只读打开data.txt,拿到文件fd // open("data.txt", O_RDWR | O_CREAT, 0644)// 读写模式;文件不存在则新建;0644是新建文件的权限 sendfile(sock_fd, file_fd, nullptr, 4096);// 零拷贝,内核直接把文件数据发给socket // char buf[4096]; // read(file_fd, buf, 4096); // send(sock_fd, buf, 4096, 0); shutdown(sock_fd, SHUT_WR);// 关闭socket写方向,发送FIN包通知客户端传输结束 close(file_fd); close(sock_fd); close(listen_fd); } // 注释掉的三行是 read+send 版本,要把数据读到你代码里定义的这个 buf(用户态内存),再调用 send 发出去,流程为:磁盘 →内核页缓存 →拷贝到用户态 buf →再拷贝到 socket 内核缓冲区 →网卡。两次拷贝经过用户空间 // sendfile 版本没有这块用户态 buf, 流程为:磁盘 → 内核页缓存 → 直接拷贝到socket内核缓冲区 → 网卡,全程不经过用户空间运行:
。
先做科普:
以太网:局域网主流有线网络标准。
互联网:众多局域网跨地域互联组成的网络。
同账号,同一个地域买的轻量机,内网之间可以互相访问;北京、上海分属不同地域,跨地域的两台轻量机不能用内网 IP 互通。
ip a:Linux命令,全称
ip address,查看网卡与IP地址,可以得到
lo:回环网卡,IP 127.0.0.1,scope host,仅本机内部。
eth0:网卡IP 10.2.0.7/22,网段广播地址10.2.3.255(主机位全为1),scope global,内网可访问。
docker0:无载波,未启用。
给 docker 用的虚拟网络接口,docker就是一个软件,用来打包程序。比如:你写好C++服务,打包进docker包。放到另一台服务器,直接启动,不用再手动装gcc、各种依赖库,环境和你本机一模一样,不会出现“在我电脑能跑,放服务器就崩”。
场景 A:你现在的做法(不用 Docker)
- 你的 C++ 程序,直接跑在 Ubuntu 服务器本体。 通信链路: 客户端 → 互联网 → 服务器 eth0 网卡 → 你的 C++ 程序。 docker0 完全不参与,闲置。
场景 B:用 Docker 打包你的 C++ 程序,
把 C++ 程序打包成 Docker 镜像
启动镜像,生成容器(一个隔离的小程序环境)
容器里面跑你的 C++ 程序 通信链路: 客户端 → 互联网 → 服务器 eth0 网卡 → docker0 虚拟网桥 → 容器内的 C++ 程序
别人不用手动装依赖,直接拉你的 docker 镜像启动容器,依赖全部在镜像里自带,外网请求先到宿主机 eth0 → 宿主机做端口映射 → docker0 网桥 → 容器内你的 C++ 程序,把程序 + 所有依赖 + 运行环境一次性打包,保证在任何装了 Docker 的机器上,运行环境完全一致,消除 “本地能跑,别的机器跑不了”,
环境隔离:容器里的库、配置不会污染宿主机系统。
部署极简:别人不用挨个装依赖、调配置,拉镜像一条命令直接启动。
快速启停、迁移,方便多实例部署。
- scope host:仅本机内部可达,数据包不能出本机。
scope:IP 地址的可达范围
scope global 不是互联网全球,代表在整个二层内网网段内可达,和公网互联网不是一回事。
二层:数据链路层,基于MAC地址转发。
- 网络编程你写了个服务器程序,跑起来,就可以做监听,此时
内网其他机器执行
nc 10.2.0.7 8888内网机器 → 云内网交换机 → eth0 → 你的 C++ 服务本机自连执行
nc 127.0.0.1 8888本机 bash → lo 网卡 → 你的 C++ 服务,不走 eth0外网电脑执行
nc 82.157.107.148 8888外网电脑 →互联网 →腾讯云网关 → eth0 →你的 C++ 服务nc 是 netcat,Linux 自带简易 TCP 连接测试工具,你敲
nc 82.157.107.148 8888,它就充当客户端,主动连上你这个 sendfile 程序,接收 data.txt 文件内容,nc 比手写个客户端好在不用写代码、编译,一行命令直接当客户端测你的服务,手写客户端好在能自己控制全部收发逻辑,自定义协议。eth0绑定的是内网IP:10.2.0.7,外网来的包打到公网IP:82.157.107.148,云厂商网关NAT,把包目的IP改成内网IP → 包发给eth0。
所以无论内外网,所有流量都走 eth0 这一块网卡。
就算本地家用电脑 eth0(网卡)也是:
内网设备(家里别的电脑)访问:流量直接走这块网卡。
外网互联网访问:外网数据包先到路由器,路由器 NAT 转发,再经过这块网卡进电脑。 同一块真实网卡处理两类流量。
目前我的:
内网 IP:
10.2.0.7(eth0 网卡的内网地址)公网 IP:
82.157.107.148(SSH 连接地址,云服务器对外 IP)你的代码里写了
INADDR_ANY,意思是同时监听这两个 IP 的 8888 端口。
别人用公网 IP 82.157.107.148:8888 连 → 走公网,数据包进 eth0
同云内网其他机器用 10.2.0.7:8888 连 → 走内网,数据包也进 eth0
我腾讯云只有一个实例,所以内网只有我一个(北京地域增加服务器才会有其他设备,北京和上海不互通),所以内网也可以通信
,内网包含两类通信:
本机 ↔ 本机(自身和自身)
本机 ↔ 同内网其他服务器(内网多机之间)。
int file_fd = open("data.txt", O_RDONLY);
成功返回非负整数文件描述符。
失败open返回‑1,程序不会自动终止,代码会继续往下执行
如果传给
sendfile(sock_fd, file_fd, nullptr, 4096);函数调用失败,但代码没有做任何错误判断打印,所以终端看不到任何提示,看起来像什么都没发生,后面的cout会继续执行。不存在就返回-1,所以最好不存在就创建,用
int file_fd = open("data.txt", O_RDWR | O_CREAT, 0644);0644 是新建文件的权限,使用O_CREAT时必须提供该参数。
懂了后说点其他的(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
现象一、
云里执行:
root@VM-0-7-ubuntu:~/cpp_projects_2# nc 82.157.107.148 8888
^C无输出
root@VM-0-7-ubuntu:~/cpp_projects_2# nc 10.2.0.7 8888
3bcd^C有输出
root@VM-0-7-ubuntu:~/cpp_projects_2# nc 127.0.0.1 8888
3bcd有输出
本地cmd:
C:\Users\GerJCS岛> nc 127.0.0.1 8888
^C无输出
C:\Users\GerJCS岛> nc 10.2.0.7 8888
^C无输出
C:\Users\GerJCS岛>nc 82.157.107.148 8888
^C无输出现象二、
我在VScode里加端口8888
,发现本地cmd的
C:\Users\GerJCS岛>nc 127.0.0.1 8888
3bcd有输出了,其他不变。
现象三、
再修改防火墙
增加8888,继续测试
,发现云里三条命令都通了,而本地cmd的,不仅 127,公网也有了,只有
nc 10.2.0.7 8888依旧无输出。
解释为啥(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
本地物理Linux和腾讯云都是一样的:eth0 唯一实体网卡(10.2.0.7),公网流量靠云平台 NAT 转发到这张网卡;本地回环 lo 纯虚拟环回网卡。
现象一、
云里:
关于
nc 127.0.0.1 8888有输出:
nc(客户端进程)发包,目标 127.0.0.1,内核识别这是 lo 地址,数据包不经过物理网卡,在内核协议栈内部直接交给本机监听 8888 的服务进程。数据全程不出这台机器。
关于
nc 10.2.0.7 8888有输出:
就只有这一台云机器。
nc 10.2.0.7 8888,是本机上的 nc 客户端,向本机 eth0 网卡的内网 IP(10.2.0.7)发起 TCP 连接。流程: nc 进程 → 本机内核 → eth0 网卡 → 本机内核 → 你的 server 服务。 数据包不会离开这台云服务器,只是在本机内核里绕 eth0 网卡这条路径,不是访问别的机器。
关于
nc 82.157.107.148 8888无输出:
你的云机器真实网卡上并没有 82.157.107.148 这个 IP。机器网卡只有内网 IP:10.2.0.7。 82.157.107.148 是轻量云平台在云机房网关做的 NAT:外网用户发包给这个公网 IP,网关把包翻译成 10.2.0.7,转发进你的云主机。
现在你在云主机内部执行 nc 公网 IP:8888: nc 发包目的地址 = 82.157.107.148。 包送到机房网关。网关 NAT 规则只处理从外网进来的包,不处理从云主机内部发出来、目的是自己公网 IP 的包,直接丢弃。 这个行为叫内网访问自身公网 IP,回环 NAT 失败,不是 lo 环回网卡,是 NAT 网关不支持反向回包。
区分:
外面 Windows 电脑 → 82.157.107.148:包从互联网过来,网关 NAT 正常转换,能进(前提防火墙放行)
云主机内部 → 82.157.107.148:包从云主机出来到网关,网关不做 NAT 转换,包不通。
本地cmd:
Windows 本地 cmd,未开启 VSCode 8888 端口转发
nc 127.0.0.1 8888:Windows 本机没有程序监听 8888,连不上,无输出。
nc 10.2.0.7 8888:10 段内网 IP,本地路由丢弃数据包,连不上,无输出。
nc 82.157.107.148 8888:公网包被云防火墙拦截,连不上,无输出。现象二、
首先科普 VScode 里设置 8888 含义:
端口列:8888 这个就是
ssh -L里的本地端口。是谁监听:VSCode(运行在你的 Windows 电脑这个进程),在 Windows 本机的 127.0.0.1 上,监听 8888 端口。 含义:你 Windows 电脑上所有程序,比如 cmd 里 nc,访问127.0.0.1:8888,就会连上 VSCode 这个监听端点。转发地址列:localhost:8888 这里的
localhost,是远程云服务器 Ubuntu 主机,不是 Windows。 含义:数据包穿过 SSH 加密隧道到达云服务器后,最终投递目标:云服务器本机的127.0.0.1:8888,也就是你的 sendfile 服务程序。Windows cmd 执行
nc 127.0.0.1 8888→ 连接【端口:8888】(Windows 本机 VSCode 监听端口)→ 数据包封装进 SSH 通道 → 传到云服务器 → 转发到【转发地址:localhost:8888】(云主机里你的 C++ 服务)。注意:
VSCode Remote SSH端口转发:本地电脑开127.0.0.1:8888,流量走SSH隧道,转发到云服务器内部127.0.0.1:8888。你的服务在云机上只监听127.0.0.1:8888也能被本地访问。
说人话就是 VScode 的端口转发写 8888,他只监听本地的 127,然后转发给云的 127。
Windows 本地 cmd,开启 VSCode 8888 端口转发后
nc 127.0.0.1 8888:本地 127.0.0.1:8888 由 VSCode 监听,数据包通过 SSH 隧道转发到远端云主机服务,连接成功,拿到 3bcd。
nc 10.2.0.7 8888:不受端口转发影响,10 段内网 IP,本地路由丢包,无输出。
nc 82.157.107.148 8888:不受端口转发影响,公网包被云防火墙拦截,无输出。现象三、
云里
nc 82.157.107.148 8888有输出:
云平台网关开启了Hairpin NAT(NAT 回流)。 之前不通:云网关安全策略拦截了 8888 端口的回流数据包。 新增防火墙放行 8888 之后,网关允许该流量,云主机发出的目标为公网 IP 的包送到网关,网关做 DNAT 转成内网 10.2.0.7,再发回你的云主机,链路通了。
本地cmd的公网有输出:
本地 Windows 访问公网 IP
82.157.107.148:8888:数据包从 Windows 走外网 → 云网关,网关执行 DNAT,把公网 IP:8888 转为内网10.2.0.7:8888,转发进云主机。 这个是正常外网访问流程,不需要 Hairpin NAT。Hairpin NAT 只负责云主机内部访问自己公网 IP 的场景。 VSCode 端口转发不参与这条流量。本地cmd的
10.2.0.7 8888依旧没有:
Windows 执行
nc 10.2.0.7 8888,10.2.0.7 是云服务器内网 IP,这个 IP 属于云机房内网网段,你的 Windows 机器不在这个内网里,本机路由表里没有去往10.2.0.7的路由,数据包直接丢弃。VSCode SSH 端口转发只接管 Windows 127.0.0.1:8888,不会拦截你访问
10.2.0.7:8888的流量,所以转发不生效,无法建立连接。云主机防火墙放行 8888,只改变云主机侧的入站过滤规则,不能给 Windows 增加内网路由。
总结(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
VSCode SSH 端口转发:只管Windows 本机 127.0.0.1:8888。Windows 访问这个地址,流量走 SSH 加密隧道进云机,转发到云机内部的目标地址,不走公网、不受云防火墙限制。
云服务器防火墙(安全组 / 平台防火墙):管公网直接访问 82.157.107.148:8888的流量,控制外网数据包能不能进入云主机,和 VSCode 端口转发无关。
研究这些属于啥知识点啊?(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
TCP/IP 网络 + Linux 套接字编程 + 云平台 NAT / 防火墙,属于后端服务端网络基础,细分:
TCP 套接字编程:socket、bind、listen、accept 这套服务端模型
Linux 网络协议栈、环回网卡 lo、物理网卡 eth0,本机访问本机不同 IP 的数据包走向
NAT 原理:DNAT、Hairpin NAT(NAT 回流)
云服务器安全组 / 防火墙:控制入站流量,属于云网络运维 / 云平台网络知识
SSH 本地端口转发(ssh -L),属于SSH应用层隧道原理
IP 路由基础:路由表决定数据包往哪发,跨网段无路由直接丢包
属于 Linux C++ 后端面试网络模块核心基础。
有意义吗?(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
有意义,贴合你Linux C++ 高性能服务端社招目标。
你现在啃的:socket、TCP 数据包流转、NAT、SSH 端口转发、云网络,正是面试网络模块高频考察内容,不只是背 API,是理解数据包完整走向,很多候选人只会背 socket 函数,讲不通包怎么走。
结合你写的 sendfile 零拷贝服务代码,代码 + 抓包 + 多场景验证(lo / 内网 ip / 公网 ip、端口转发、防火墙开关),是工程落地层面的理解,面试很加分,能区分纸上谈兵的人。
边界区分:Hairpin NAT、云安全组属于云平台运维知识,面试不会深挖运维细节,但知道原理、能解释现象足够,不用往运维方向深挖,但属于云主机网络运维 + TCP 网络工程实操、线上部署、排查网络连通性的工程实操,学会连通性排查手段(nc、看防火墙),以后写 socket 服务出问题,能自己定位是代码问题还是网络拦截。
短板角度:这部分属于网络基础,是必过关卡;吃透之后,再继续往 epoll、reactor、TCP 拥塞 / 滑动窗口、内核协议栈细节推进即可。
一句话概括:你现在这种自己写服务,多场景动手验证现象的学习方式,比单纯看书刷题有效很多,对目标岗位对口。
记录 & 回忆学习过程中的边角提问(哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈):
Q:防火墙是啥?我知道灭火器。
A:轻量云这里叫防火墙,作用:控制外网能不能打进你这台云机器,拦截未放行端口的数据包,和电脑 Windows 防火墙是一类东西,和灭火器完全无关。
只是比喻,没有真实的火,防火墙本意:古时候城堡城墙外会燃起一圈火,挡住敌人冲进来。
引申到网络:它像一道隔离屏障,拦住外部非法网络数据包,只放行你允许的流量,所以取名防火墙。
你上面那个设置页面就是你轻量云防火墙,就是在云服务器外网入口拦包,没添加 8888 规则时,外网发来访问 8888 的包直接丢弃。
Q:中国互联网是不是有墙,是一个东西吗?
A:不是同一个东西。
你轻量云面板里的防火墙:单台服务器的网络访问控制,只管这一台机器,由云厂商提供,只控制这台主机哪些端口能被访问。
中国的网络管理措施:是国家层面大范围的网络管理机制,作用范围是整个国内互联网出口,二者层级、用途、实现完全不一样,不能混为一谈。
Q:中国网络的防火墙为啥要有?是防外面进来还是防止访问外?
A:从技术作用来讲,它双向都有管控,核心目的是维护网络安全、防范有害信息、维护网络秩序:
阻挡境外流入的违法有害信息、网络攻击、恶意内容从外部网络进入国内互联网。
同时规范国内用户对境外网站的访问,依据法律法规管理跨境网络访问行为。
Q:我平时翻Q导管子用的工具是干啥的?
A:试图绕过这套跨境管理,私自访问境外网络。
sendfile文件 → 文件(不考但好奇):
sendfile早期内核不支持,2010年的2.6.33内核版本才支持的,代码如下:
查看代码
#include <sys/sendfile.h> #include <fcntl.h> #include <unistd.h> #include <cstdio> #include<iostream> using namespace std; int main(){ int src_fd = open("in.txt", O_RDONLY);// O_RDONLY:只读,源文件只读取数据,不需要写入,所以 src_fd 用 O_RDONLY,不添加写标记,open返回-1,打开失败,不会自动创建 int dst_fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); // O_WRONLY:只写。目标文件仅写入数据,不需要读取,所以 dst_fd 用 O_WRONLY // O_CREAT:文件不存在则创建 // O_TRUNC:文件存在则清空原有内容 off_t offset = 0;// offset 标记源文件读取起始位置,0 代表从文件开头读 sendfile(dst_fd, src_fd, &offset, 4096);// 参数顺序:目标 fd,源 fd,偏移指针,待拷贝长度 cout<<offset<<endl;// &offset:传入 offset 变量地址。sendfile 执行成功后,内核会自动更新 offset,值变为已经拷贝完成的字节数 close(src_fd); close(dst_fd); } /* sendfile: in.txt 磁盘 → 内核页缓存 → 直接拷贝到 out.txt 内核页缓存 → 磁盘 全程不进用户空间 read + write 流程:in.txt 磁盘 →内核页缓存 →**拷贝到用户态 buf** →再拷贝回内核页缓存 →磁盘,两次数据拷贝进出用户空间 char buf[4096]; ssize_t n = read(src_fd, buf, 4096); if(n > 0) write(dst_fd, buf, n); */
mmap匿名(必考) :
这就是内存池 operator new 4096 时候干的事,底层走mmap匿名大块:
查看代码
#include <iostream> #include <sys/mman.h> #include <unistd.h> using namespace std; int main(){ void* p = mmap(nullptr, 4090, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);//MAP_ANONYMOUS:不绑定磁盘文件,直接申请一块全新的匿名虚拟内存 cout<<p<<endl; if (p == MAP_FAILED){ perror("mmap fail"); return 1; } char* buf = (char*)p; buf[0] = 'A'; pause(); //卡住进程,不退出,内存保留 munmap(p, 4096);//没问题,mmap按页管理,munmap传4096合法 } // root@VM-0-7-ubuntu:~/cpp_projects_2# ps aux | grep ./haha // root 934816 0.0 0.0 6060 1904 pts/4 S+ 16:21 0:00 ./haha 这行才是 // root 935091 0.0 0.1 9700 2240 pts/0 S+ 16:21 0:00 grep --color=auto ./haha 而这行表示grep 命令本身产生的进程命令 ====================read========================= #include <iostream> #include <fcntl.h> #include <unistd.h> using namespace std; int main(){ int fd = open("data.txt", O_RDONLY); if(fd < 0){perror("open");return 1;} char buf[4096]; ssize_t ret; // 循环read读取文件 while( (ret = read(fd, buf, sizeof(buf))) > 0 ) cout<<buf<<endl; close(fd); }匿名mmap是Linux系统调用,申请一块无对应磁盘文件的虚拟内存,执行无反应,那咋验证你开了?
先看个东西
,蓝色箭头是进程堆,由libc启动时调用brk创建,我代码没调用malloc,不会使用这片内存,但条目仍然存在,属于链接glibc,进程启动时libc自动调用brk创建[heap],供libc内部以及你后续malloc使用,不调用malloc也会预先建立该VMA条目。
蓝色下一行是匿名mmap,glibc初始化时创建的匿名VMA,供glibc自身内部使用。
搜索显示高亮的那行才是实际分配的。
Linux mmap分配粒度是页(4KB),不足一页向上对齐到一页,一页是4096,计速器验证:
,
而 C++ 代码运行输出
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o haha && ./haha 0x7f1b6d010000也验证了是从这里开始,嘎嘎吻合。
物理内存:仅分配虚拟地址,第一次读写该内存才触发的叫缺页,分配物理页。
前置流程 & 知识:
运行程序哪怕一个空,只输出hello world也会自带一个[heap]和匿名区域。
栈地址一般处于较高虚拟地址,malloc出来的堆地址通常偏低,但不能100%确定,不能当作判定依据。
面试和编程语境:malloc 分配的内存,不管来源,统称堆内存。
Linux 内核 maps 标记里:内核的
[heap]只是 brk 搞出的 VMA 的名字,只是堆内存来源之一,不等于堆,堆还包括匿名 mmap 那块 VMA不叫 [heap],但也是堆。内核定义的堆,特指
[heap]VMA。 分配器 (glibc malloc) 定义的堆,包含 mmap 申请的大块内存。狗逼豆包服了,总是把堆和[heap]绑定,因为内核
[heap]VMA,是最初设计用来给 malloc 拿内存的唯一区域,后来 glibc 优化使得大块内存改用 mmap 单独 VMA 申请。 这块 mmap 来的内存,属于 malloc 对外提供的堆内存,但不属于内核标记的[heap]VMA。
此文搜“哈哈,至此可以看出”,可知,执行 malloc 时:
小于阈值走 brk,扩展自带的
[heap]区域大于阈值走 匿名mmap,不在 [heap] 里,但可能会和自带的匿名区域合并。
而这个例子手动mmap的是真正的在其他地方开。
注意:
局部
int a:栈 VMA
MAP_ANONYMOUS匿名mmap:独立匿名 VMA,此时不属于堆 VMA,可以理解为啥也不是,只有用了malloc 才属于堆,用malloc的底层逻辑:
小的走
brk/sbrk系统调用扩展得到的虚拟内存区域大的也走mmap,但注意这个匿名mmap和单独mmap不同,单独mmap搞出来是个非堆也非栈独立的VMA虚拟内存空间,而套一层malloc底层走的mmap,操作系统编译器等大佬们就给设计为另外的含义,不再是独立VMA而是glibc 库层面把这块匿名内存纳入自己的堆内存管理,
只是内核
/proc/pid/maps看 VMA 属性只标注匿名区分不了是 brk 还是 malloc 的 mmap。glibc 层面 malloc 用 mmap 申请的匿名 VMA,会被 glibc 纳入它的堆内存管理,这是库内部逻辑,maps 不体现这个库层面归属,验证方式是看 malloc 源码,或者用
malloc_info、mtrace这类 glibc 工具查看库管理的内存块,能看到 mmap 分配出来的块归 glibc 堆管理
题外话:
此文搜“用失败,但代码没有”、“t不存在任何其他可能”得知:
cout << p传入空char*属于 C++ 未定义行为,表现为无报错、无段错误,程序直接终止,后面代码不再运行。
open返回 - 1 传给 sendfile,sendfile 调用失败,函数返回失败值,进程不会终止,后续代码继续执行。现在增加个实践:
int fd = open("data.txt", O_RDONLY);打开失败也不会有任何错误,下一行写个cout直接无输出显示,程序直接结束。再增加个:此文搜“访问空地址,程序崩溃,后面文本不再打印”。
文件映射,有名 mmap,绑定磁盘文件(不考):
。
关于 C++ malloc、new,free、delete 有什么区别?
其实之前早就提前追问过豆包。
查看代码
int *ptr = (int *)malloc(sizeof(int));// 使用 malloc 分配内存 free(ptr); int *ptr = new int;// 使用 new 分配内存并调用构造函数 delete ptr;
语法不同:
malloc/free是一个C语言的函数,而new/delete是C++的运算符。分配内存的方式不同:
malloc只分配内存,而new会分配内存并且调用对象的构造函数来初始化对象。返回值不同:
malloc返回一个 void 指针,需要自己强制类型转换,而new返回一个指向对象类型的指针。malloc 需要传入需要分配的大小,而 new 编译器会自动计算所构造对象的大小
重载 (overload):同一作用域,函数名相同,参数列表不同;返回值不参与区分,不改变原有函数本体,新增同名函数。
注意:函数签名:函数名 + 参数类型列表,返回值不属于函数签名。
查看代码
// 重载 overload,同作用域,同名不同参数 void foo(int){} void foo(double){} //例子 #include <iostream> struct A { int val; A(int v) : val(v) {} A operator+(const A& other) { return A(this->val + other.val); } }; int main() { A a1(1), a2(2); A res = a1 + a2; std::cout << res.val<<std::endl; }解读:
结构体 A 里重载
operator+,让a1+a2能执行自定义逻辑:
A operator+(const A& other):重载 + 运算符,成员函数,other 是加号右侧对象。返回新 A 实例,值等于两个对象 val 相加。
main 中
a1 + a2等价调用a1.operator+(a2),左操作数 a1:作为 this 指针隐式传入成员函数,右操作数 a2:作为参数 other 传入函数。重写 / 重定义(覆盖 override):类继承场景,子类写和父类虚函数签名完全一致的函数,替换虚函数行为。
查看代码
// 重写 override(虚函数) class A{virtual void foo(){}}; class B:A{void foo() override{}}; //也可以省略 class B:A{void foo(){}}; //例子: //父类有虚函数 foo,子类写一个签名一模一样的 foo。调用时,用父类指针指向子类对象,会跑子类的 foo #include<iostream> using namespace std; class A{ public: virtual void foo(){ cout << "A"; } }; // class B:A{见解释 class B:public A{ public: void foo(){ cout << "B"<<endl;; } }; int main(){ A* p = new B; p->foo(); //输出B }注释掉的那一行会报错,
class B:A,默认继承方式是 private 私有继承,继承后父类 A 的 public 成员,到子类 B 里变成 private。外面 main 函数拿 A * 指向 B,这个转换直接不让做,编译报错。
public 继承:描述is-a,B 是 A 的一种。比如动物 (父类),狗 (子类),狗是一种动物。外部可以用动物指针指向狗,外部允许 A指向 B 对象。
private 继承(极少用):不是 is-a,只是 B 内部拿 A 的代码来复用,B 对外不承认自己是 A。外部不能用 A * 指向 B。
A* p = new B;这里会尝试把新建出来的 B 对象的指针,隐式转换成 A*,public 继承:允许这个隐式转换。 private 继承:编译器禁止这个隐式转换,编译报错名字隐藏(口语叫重新定义):子类写和父类同名函数,不是虚函数,直接隐藏父类版本,不属于 override。
查看代码
// 重新定义(名字隐藏,非虚) class A{void foo(){}}; class B:A{void foo(){}}; /* 例子: 父类普通非虚函数 foo,子类写同名同签名 foo 父类指针指向子类对象,调用 foo,执行父类版本 */ #include<iostream> using namespace std; class A{ public: void foo(){ std::cout << "A"<<endl; } }; class B:public A{ public: void foo(){ std::cout << "B"<<endl; } }; int main(){ A* p = new B; p->foo(); //输出A }对比最后两个:
父类
virtual,子类同名同签名函数 → 多态,指针看对象真实类型父类无 virtual,子类同名同签名函数 → 名字隐藏,指针是什么类型就调用哪个版本。
设计目的: C++ 多态就是靠虚函数实现。父类指针可以统一管理一堆不同子类对象(比如容器存父类指针),调用虚函数自动跑子类逻辑。普通函数不需要这套动态查找,直接编译期绑定,速度更快。
细节:
名字隐藏之外还有几个会编译报错报错的东西:
多次 int a 叫变量重定义。
多次定义haha函数叫函数重定义
- 而我写个和库里已经有的东西也叫重定义
但有个没问题但也没人这么干的操作,你在自己命名空间里写和库同名的实体,只是同名不同域,没问题。
我手写内存池里:
申请内存:
void* newBlock = operator new(BlockSize_);构造对象:
new(p) T(std::forward<Args>(args)...);剥离出重点:
new T是语法糖,编译器自动拆成两步:
调用
operator new(sizeof(T))分配内存,函数本身抽象调用流程:传入参数 → 分配 → 返回指针,具体实现内部底层函数调用链:operator new(size_t) → malloc → __libc_malloc → _int_malloc → brk/mmap。用 placement new 表达式在拿到的内存上调用构造函数,即
new(addr) T这个写法。编程指北原文:
new 操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。
自由存储区是 C++ 基于new操作符的一个抽象概念,凡是通过 new 操作符进行内存申请,该内存即为自由存储区。
而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。
那么自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。
自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。
很朦胧,自己的追问思考:
new 表达式是两层:调用
operator new拿原始内存,再调用构造函数,默认分配的内存位置由operator new实现决定,C++ 标准允许替换operator new。- malloc 仅申请原始内存,它能被覆盖是链接器特性,不属于 C++ 语言标准赋予的能力,说人话就是,若替换 malloc 实现,也可分配到其他存储区域。
这里有个东西:
operator new(size_t):负责申请内存,默认从堆(自由存储区)拿内存。
operator new(size_t, void*):标准内置的placement new重载,不分配内存,只原样返回传入的指针,仅执行构造。这里单参其实一直是知道的,但他其实是库函数的一个重载,另一个同名不同参数的就是双参的,只不过双参的那个根本不会出现在 C++ 代码里,他是
new(addr) T的底层原理, 即new(addr) T→ 编译器调用operator new(size_t, addr)→ 原样返回addr→ 在addr调用 T 构造函数。所以此时有个结论!
operator new他其实叫重载:
一个是类里自定义重载(只不过这个写法傻逼才这么写或者C++级别的大佬一般没人用)(你可以更改使得 new 不放堆,放其他地方,静态啥的)(只要不自定义更改,无论是用new语法糖打包两步骤那个,还是用operator new单参,都是默认搞到堆)
一个是库里给你写好的重载:
一个是常用的
operator new单参,用来分内存(唯一常用的)一个就是极不常用的双参,这个是
new(addr) T的底层调用,不会出现在 C++ 代码里。
operator new单参和new(addr) T就是new语法糖的内部两个步骤。而 malloc 没任何重载。
Q: 为啥此文搜“重载 (overload):同一作用域”那里调用的时候可以去掉 operator 这个单词,而我 operator new 就要带着
A:语法糖而已:
a1 + a2是运算符语法糖,编译器自动转成a1.operator+(a2),源码不用写operator。
operator new不是运算符表达式,它是函数名本身,直接调用该函数时,函数名就叫operator new,写代码必须完整写这个名字。
傻逼玩意!!!:
operator new:默认在堆分配;自定义单参operator new,可改到别的内存区域。
new语法糖:内存位置由它调用的operator new决定,也就是可以改动operator new。
这里有个贼鸡巴气人的术语:
众所周知,全局的xxx没说的,局部的xxx只是局部生效,可狗逼 C++ 却特意为了让我混淆困惑,起了个贼狗逼的名字:
类内:属于这个类的成员函数,编译器在解析
new 类对象时,在类作用域找到它,属于成员函数重载。类外全局:C++ 标准单独定义的替换规则,不是重载,直接替换库原版,链接阶段优先使用用户定义版本。
说人话就是全局重定义的叫替换,类里重定义的叫重载。
普通库函数(如
malloc、operator new(size_t))用户重新定义,同样会发生符号覆盖,链接一般不报错。但
malloc:是链接器弱符号机制,能覆盖,不是 C++ 语言标准规定的特性,operator new(size_t):C++ 标准允许替换它。malloc和operator new这些特例是开个口子,允许签名一致的重定义,然后运行先使用用户定义 ,但其他大部分都不行的。
分配失败时:
new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;
malloc分配内存失败时返回NULL
在使用C语言时,我们习惯在malloc分配内存后判断分配是否成功:
int *a = (int *)malloc ( sizeof (int )); if(NULL == a) { ... } else { ... }从 C 语言走入 C++ 阵营的新手可能会把这个习惯带入C++:
int * a = new int(); if(NULL == a) { ... } else { ... }
关于 C++ 内存泄漏定位【C/C++ 内存泄露如何定位、检测以及避免】:
内存泄露是什么?
简单来说就是:在程序中申请了动态内存,却没有释放,如果程序长期运行下去,最终会导致没有内存可供分配。
所以不少大厂的服务有个特点,就是会定期重启服务进程,重启的目的就是让操作系统回收整个进程的资源包括内存,这样一点点的内存泄露问题即使无法定位,也不是什么大问题哈哈哈。
检测内存泄露的方法:
手动检查代码:仔细检查代码中的内存分配和释放,确保每次分配内存后都有相应的释放操作。比如 malloc和free、new和delete是否配对使用了。
使用调试器和工具:
Valgrind(仅限于Linux和macOS):Valgrind是一个功能强大的内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用Valgrind分析程序时,只需在命令行中输入
valgrind --leak-check=yes your_program即可(会ASAN 就行)Visual Studio中的CRT(C Runtime)调试功能:Visual Studio提供了一些用于检测内存泄露的C Runtime库调试功能。例如,
_CrtDumpMemoryLeaks函数可以在程序结束时报告内存泄露(不想学,且是 windows 的)AddressSanitizer:AddressSanitizer是一个用于检测内存错误的编译器插件,适用于GCC和Clang。要启用AddressSanitizer,只需在编译时添加
-fsanitize=address选项(就是 ASAN,学过了,也是最重要的)
如何避免内存泄露:
Q:直接说 RAII 就完事了,我就可以略过不用再看了,我精通所有东西,可他妈一看到他说一会说智能指针,一会说 RAII,又说什么捕获,我就反倒就需要停下来浪费时间去思考这个东西,到底要咋去理解?或者怎么去让我释怀呢?
智能指针整成一条,然后再来一个更大范围的 RAII 又整第二条,本身 RAII 就包括智能指针,结果它俩还写成了平级的两条,又来异常安全捕获,感觉有点匪夷所思的弱智呢,像拉一裤兜子稀屎一样!
A:教程面向零基础读者,按实操方案→底层原理→进阶要求分段罗列,所以写为平级条目。
1、第一条智能指针:直接给拿来就能写的代码工具。
2、第二条 RAII:解释智能指针能生效的底层通用思想,不止内存。
3、异常安全:讲最终要达成的目标,即代码抛出异常后,程序依然满足承诺的不变量,资源不会泄露、对象不会处于非法状态。分 3 个等级:
① 基础保证:抛异常,资源不泄漏,程序状态合法,能继续运行。
② 强保证:抛异常,函数所有修改全部回滚,状态和调用前一模一样(事务化:要么全部成功,要么等于啥都没做,就像数据库事务核心就是提交全部生效,出错回滚,恢复原样)
这里是函数抛异常时,程序状态等于调用函数之前
// 给a、b交换内容,满足强保证 void safe_swap(std::string& a, std::string& b){ std::string temp = a;//仅在这一行会抛异常,是拷贝构造临时字符串 temp,可能抛出 std::bad_alloc 内存分配失败,若这一行抛异常后面 a = b;、b = temp; 不会执行,a、b 的值完全没变,达到强保证, a = b; b = temp; }如果
std::string temp = a;这一步抛出异常,a 和 b 完全没有改动,如同函数没有执行。也可以先在临时副本完成全部修改,确认无异常后,再把修改提交到真实变量:
void modify(std::string& s){ std::string temp = s; temp += "test"; // 所有可能抛异常的操作全部在临时变量上 s.swap(temp); // swap不抛异常,一次性提交修改,s.swap(temp)是 s 和 temp 交换,std::string 存在该成员函数写法 }如果
temp += "test"抛异常,原始s没有改动。 不是异常发生后撤销已修改内容,是修改全部先在副本做,确认没问题才改动原数据。③ 无抛保证(noexcept):函数绝对不会抛出异常。
和你这个场景的关系:
裸指针手动
new+delete:如果new之后、delete之前抛异常,delete不会执行 → 资源泄露,不满足异常安全。因为代码执行中途抛出异常,会直接跳出当前代码块,异常点之后的普通释放代码不会运行,可在 catch 内手动执行资源清理。
智能指针 / RAII:局部对象在栈展开时自动析构释放内存,哪怕抛异常,资源也能清理 → 满足异常安全。
函数抛出异常时,函数栈上局部对象会触发栈展开,栈上 RAII 对象会自动执行析构,和函数正常 return 退出时逻辑一致。
try-catch:是手动实现异常安全的旧方式,你要在 catch 里挨个清理所有资源,极易漏写,远不如 RAII 可靠
关于捕获的东西:
查看代码
#include <iostream> #include <stdexcept> int main() { try { // 可能抛出异常的代码 int a = 10; int b = 0; if (b == 0) { throw std::runtime_error("除数不能为0"); // 主动抛出异常 } int res = a / b; std::cout << res << std::endl; } catch (const std::runtime_error& e) {std::cout << "捕获异常:" << e.what() << std::endl;}//e.what()就是throw传入的字符串 } //try 包裹有可能抛出异常的代码块;一旦代码内执行 throw 抛出异常,立刻跳出 try 块,匹配对应类型的 catch 分支执行;catch 执行完毕后,继续执行 catch 之后的代码。再看个例子:
查看代码
#include <iostream> #include <stdexcept> void demo(){ char* p1 = new char[100]; char* p2 = new char[200]; throw std::runtime_error("出错了"); delete[] p2; delete[] p1; } int main() { demo(); }
throw之后的代码永远无法执行,两个delete是死代码,内存必然泄漏。异常会离开 demo 函数,向上传播,不会执行后面语句,但这里内存会泄漏,但内存泄漏不会触发编译错误,也不会直接导致程序崩溃、段错误,程序正常跑,表面看不出异常,只是进程占用的堆内存持续上涨,直到内存耗尽,后续new/malloc分配内存失败,才会出现分配失败。- 这个代码语法符合 C++ 标准,编译器能成功编译,所以叫语法合法,但编译合法 ≠ 运行没问题,编译阶段只检查语法,不检查业务逻辑、代码能不能走到,这里会运行报错,因为只要调用
demo(),没有在外层写 try/catch 包裹 demo,就会出现terminate called,应该增加int main(){ try{ demo(); }catch(...){ cout<<"哈哈"<<endl; } }
catch(...):捕获所有任意类型的异常,拿不到异常信息。
catch(const std::runtime_error& e):只捕获std::runtime_error类型异常,可以通过e.what()拿到异常描述字符串;
再看个例子:
查看代码
#include <stdexcept> #include<iostream> using namespace std; void demo2(){ char* p1 = new char[100]; try{ char* p2 = new char[200]; throw std::runtime_error("出错"); delete[] p2; } catch(...){ cout<<"哈哈"<<endl; } delete[] p1; } int main(){ demo2(); }demo2:try 内部抛异常,
delete[] p2跳过;但 p1 的 delete 在 try 外部,catch 结束后会执行,p1 正常释放,仅 p2 泄漏。
个数必须匹配:
查看代码
#include <stdexcept> int main(){ try{ char* p1 = new char[100]; char* p2 = new char[200]; throw std::runtime_error("出错了"); delete[] p2; delete[] p1; }catch(...){ // 想要不泄露:这里必须写两个delete[] delete[] p2; delete[] p1; } }
两个堆内存,catch 里必须写 2 条 delete,两个都要释放。
为什么不能写在 try 外面:
p1、p2是try 块内部的局部变量,作用域仅限{}内,catch 块访问不到 p1、p2,上面这个代码编译报错! 这就是核心痛点。
需要咋写?
查看代码
#include <stdexcept> int main(){ char* p1 = nullptr; char* p2 = nullptr; try{ p1 = new char[100]; p2 = new char[200]; throw std::runtime_error("出错了"); }catch(...){ // delete[] p2;//保留一个就行 // delete[] p1; } delete[] p2; delete[] p1; }catch 内如果抛异常:当前 catch 剩余代码不再执行,catch 外面代码直接跳过。
这就是裸指针手动 try-catch 很难写对的根源。
RAII对象离开作用域,无论正常退出还是异常抛出,析构函数都会执行,资源自动释放。
文章面向新手,是用来对比两种方案:手动 malloc / free 的代码,一旦中途抛异常,后面 free 直接跳过,造成泄漏;而 RAII / 智能指针,抛异常发生栈展开,自动析构释放,规避该泄漏风险,所以在内存泄露章节提起异常,是用来展示异常会打断执行流,是手动管理内存产生泄漏的重要诱因。
读者是循序渐进看的,先学工具,再懂原理,最后明白要保证的目标,所以分开写。不是内容冲突,是讲解分层。 对于精通的人,这种并列写法会显得重复冗余,是面向新手的科普写作习惯。
作者原话:
使用智能指针(C++):在C++中,可以使用智能指针(如
std::unique_ptr和std::shared_ptr)来自动管理内存。这些智能指针在作用域结束时会自动释放所指向的内存,从而降低忘记释放内存或者程序异常导致内存泄露的风险。异常安全:在C++中,如果程序抛出异常,需要确保在异常处理过程中正确释放已分配的内存。使用
或者使用RAII(Resource Acquisition Is Initialization)技术,早啃过了(RAII 常用例子:std::lock_guard、智能指针)。try-catch块来捕获异常并在适当的位置释放内存。
关于 C/C++ 野指针和空悬指针(Dangling pointer and wild pointer):
野指针(Wild Pointer)和空悬指针(Dangling Pointer)都是指向无效内存的指针,但它们的成因和表现有所不同,区别如下:
野指针是一个未被初始化或者值随机的指针,它的值是不确定的,可能指向任意内存地址, 访问野指针可能导致未定义行为,如程序崩溃、数据损坏等:
#include <iostream> int main() { int *wild_ptr; // 未初始化的指针,值不确定 std::cout << *wild_ptr << std::endl; // 访问野指针,可能导致未定义行为 return 0; }


,
,重要的摘抄如下:
可以看到这个就是我的服务器IP。
。
增加8888,继续测试
,发现云里三条命令都通了,而本地cmd的,不仅 127,公网也有了,只有
。
,蓝色箭头是进程堆,由libc启动时调用brk创建,我代码没调用malloc,不会使用这片内存,但条目仍然存在,属于链接glibc,进程启动时libc自动调用brk创建[heap],供libc内部以及你后续malloc使用,不调用malloc也会预先建立该VMA条目。
,
,先梳理下:
,提取关键输出:
,说真的特意这么记真的太傻逼了。
浙公网安备 33010602011771号