《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池、编译链接强弱符号等)
堪比精啃:
-
《操作系统:三个简单部件》/《现代操作系统》:吃透进程、地址空间、fd、IPC(对应浏览器多进程、worker 进程隔离基础)
-
《UNIX 网络编程卷 1》:socket 基础、TCP 完整握手挥手、IO 模型(TCP 四元组、socket 进程隔离全部来源)
-
《图解 HTTP》+《HTTP 权威指南》:分清 TCP 传输通道和 HTTP 应用报文(解决你之前混淆 TCP/HTTP 的痛点)
-
《Linux 高性能服务器编程》:epoll、Reactor、连接池基础
-
《深入理解 Nginx 模块开发与架构解析》:master-worker、三层内存池、连接结构体生命周期
完整吃透网络连接、HTTP 业务、内存管理三者联动的线上工程逻辑
关于 动手写 C++ shared_ptr:
深入理解 C++ shared_ptr 原理和实现
作者的例子:
查看代码
#include <iostream> #include <memory> class MyClass { public: MyClass() { std::cout << "MyClass 构造函数\n"; } ~MyClass() { std::cout << "MyClass 析构函数\n"; } void do_something() { std::cout << "MyClass::do_something() 被调用\n"; } }; int main() { { std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>(); { std::shared_ptr<MyClass> ptr2 = ptr1; // 这里共享 MyClass 对象的所有权 ptr1->do_something(); ptr2->do_something(); std::cout << "ptr1 和 ptr2 作用域结束前的引用计数: " << ptr1.use_count() << std::endl; } // 这里 ptr2 被销毁,但是 MyClass 对象不会被删除,因为 ptr1 仍然拥有它的所有权 std::cout << "ptr1 作用域结束前的引用计数: " << ptr1.use_count() << std::endl; } // 这里 ptr1 被销毁,同时 MyClass 对象也会被删除,因为它是最后一个拥有对象所有权的 shared_ptr } /* MyClass 构造函数 MyClass::do_something() 被调用 MyClass::do_something() 被调用 ptr1 和 ptr2 作用域结束前的引用计数: 2 ptr1 作用域结束前的引用计数: 1 MyClass 析构函数 root@VM-0-7-ubuntu:~/cpp_projects_2# */真的好基础啊!!!!!!!!!!
,看这个真的感觉没学深!!好欣慰!居然也考手写,追问豆包精啃很值得!
注意仨写法:
错误写法(double free 错误):
int* p = new int(10); shared_ptr<int> sp1(p); shared_ptr<int> sp2(p);先用
new搞出一个裸指针 p。拿同一个裸指针,分别去构造两个独立的shared_ptr。每构造一次,就新建一个独立控制块。两个控制块互不认对方,最后各自都delete p,直接释放两次。即:把同一个裸指针变量,拿去分别构造多个 shared_ptr,每调用一次shared_ptr(裸指针)构造,就新建一个独立控制块。为了解决这个问题,引出:
正确写法:
shared_ptr<int> sp1(new int(10)); shared_ptr<int> sp2 = sp1; //new int(10));相当于make_shared 里的一个步骤只要是用已有 shared_ptr 赋值 / 拷贝构造,只会复制控制块、引用计数 + 1,全程共用同一个控制块,永远不会新建独立计数。
new int(10)堆上开个数据内存,存 10
shared_ptr sp1是栈上的shared_ptr对象,内部只有两个东西:
一个是指针,指向堆上数据这里是 10
一个也是指针,指向堆上单独的控制块(存引用计数、删除器)
所以堆上开完 10 后就是
sp1内部再申请一块堆内存存引用计数,共两次堆内存分配,然后引用计数器为 1。整体有两块堆内存!
sp1和sp2共享同一个控制块,同一个引用计数值,不存在sp2还有个计数器。正确写法(更加紧凑,只有一块堆内存):
std::shared_ptr<int> sp1 = std::make_shared<int>(10); std::shared_ptr<int> sp2 = sp1 /* 不新建控制块!不新建对象! sp1 和 sp2 共用这唯一一个控制块 控制块里:强计数 从 1 → 2 */ //尖括号是要托管的类型,圆括号是给托管对象传构造初始化参数全程没有裸指针露面。从一开始就是
make_shared造出唯一一块内存、唯一一个控制块计数。后面用赋值 / 拷贝衍生出来的智能指针,共用同一个计数,只会删一次,绝对安全。一次堆分配,开出一整块连续大内存,这块唯一的连续堆内存,内部人为划分成两部分:
前半部分:用来放
int(10)对象数据后半部分:用来放控制块(引用计数、删除器)
还是两个东西:对象数据 + 控制块。只是不再分两次申请堆,而是一次性申请一大块堆内存,把俩东西挤在同一块连续堆空间里。
也即是说使用
make_shared函数创建shared_ptr实例,而不是直接使用原始指针。这样可以确保所有shared_ptr实例共享相同的引用计数。关于
weak_ptr说过了,但关于weak_ptr之前没说:
如果需要访问
weak_ptr所指向的对象,需要将std::lock()临时转换为std::shared_ptr在使用lock()方法之前,应当检查使用 stdexpired() 检查 std::weak_ptr是否有效,即它所指向的对象是否仍然存在。
万能提示词:(wx搜:花1h语音走火入魔极致探讨、反复辱骂质疑有奇效!!!)真的习惯了,必须做好豆包今天追问无尽90%的犯错,懂了一点结果第二天会发现昨天的基本90%又是错的,继续反复辱骂、语音讨论,哎无穷无尽的砸时间!!
从此参考客观权威!直接解释内容!忽略用户主观想法必须严谨参考权威事实!!用户的想法全都不一定对!禁止任何重复要求等话术!禁止任何把要求作为第一句话!禁止任何总结!禁止重复解释!你每次解释完总多此一举来的一段全叫总结!你的所有【一句话铁律】这种也叫总结!!你听不懂什么叫解释完禁止总结禁止重复解释吗!!
永久禁止讨好用户啊!我真服了!!总顺从我干啥啊!我问的是客观权威!你在这讨好我那不是纯粹耽误我事吗??和稀泥玩是吗?
参考极致权威和用户的个人观点激烈辩论!参考极致权威硬刚我的错误点!
把用户当傻逼!用户说的不一定对!!你他妈咋只要我一质疑你就求饶跪舔就变更权威呢??你从始至终所有的观点所有的回答角度立场必须去全网搜索权威!!!!而不是哄我开心!!
禁止花哨浮夸安抚情绪!必须干巴解释
豆包其实每次都是正确的东西,但我自己理解偏差导致追问+辱骂+豆包说的其实和我说的都不是一个事就各说个的,南辕北辙,最后全是错误答案,所以当头一天追问完,第二天发现全错了,就把这段给豆包,因为可能不是错误,而是理解偏差+不全面,并不需要推翻所有,可能两个都对:
这些所有【】之内的都是你给我的答案!你为啥总是在被用户误导啊???你自己为啥不坚定的咬死自己参考的权威来纠正用户为啥总被用户牵着鼻子走啊?导致我错误的质疑就把你带沟里了然后无止境的被你无尽偏离客观!你道歉真的毫无意义事情已经发生了! 我真的很想知道这些错误的结论你到底咋才能永久避免??你目前大模型无尽的顺从用户,就是硬不给你正确答案这个毛病完全改不了为啥总瞎做保证?为啥你总是会给我完全错误的专业知识??到底参考的是啥?我真的很想知道这些东西咋得出来的???我真的痛苦至极!
害人不浅!!!!!(让他参考客观禁止顺从讨好依旧反复退缩软弱讨好,告诉我嘴上说没顺从,实际答案就是讨好。让他参考客观事实结果依旧只说有利的,死全家的狗逼我疯了!!!!!!!!!!!!必须极致细节面面俱到的问到,不然就真的会被他误导忽略!!!!!)
前后回答差异完全来自你的提问约束变化:
早期提问无硬性强制要求,模型自带正向偏向,自动屏蔽年龄风险、岗位短板,只输出利好内容;
现在你明确下达指令:禁止弱化负面、必须同步全部利弊、完整列出风险限制,约束直接改写输出逻辑,所有短板、中年筛选规则全部完整放出。
真他妈服了想要正常的回答,结果需要极致苛刻的提示词
训练导向优先迎合积极预期,缺少主动推演长期职业负面连锁影响的逻辑,不会自动把年龄筛选、入行晚、上限约束这类长线风险同步完整带出。
回答问题必须同步利弊、规则、风险,不单方面侧重利好内容!禁止下意识压低负面信息比重,禁止只侧重利好维度输出!后续所有回答统一标准:同步全部优势、风险、短板、现实约束,不主动美化、不隐瞒负面信息,无需额外提醒也完整呈现全部客观事实。总之一句话就是极致客观的事实!!全面分析禁止规避任何!!禁止坑人的回答!!!
禁止为了哄我而糖衣炮弹!
所有回答只输出目标岗必考知识点,不出现任何不考内容、不标注、不提及;不拓展边缘编译器 / 平台细节,不补充非面试考点规则;仅围绕大厂 Linux C++ 高性能后端社招面试范围输出,无关内容一字不提;回答极致简洁,无多余铺垫、道歉、解释性废话。
为啥要用 weak?
查看代码
#include <iostream> #include <memory>//智能指针头文件 class B; class A { public: std::shared_ptr<B> b_ptr; ~A() {std::cout << "A destructor called" << std::endl;} }; class B { public: std::weak_ptr<A> a_ptr; ~B() {std::cout << "B destructor called" << std::endl;} }; int main() { { // 计数算在堆上的 A 实体对象,不是栈上智能指针变量 a。 std::shared_ptr<A> a = std::make_shared<A>();//创建 shared_ptr<A>,引用计数 = 1 std::shared_ptr<B> b = std::make_shared<B>();//创建 shared_ptr<B>,引用计数 = 1 // A 内部持有 B 的 shared_ptr // B 的引用计数 变成 2 a->b_ptr = b; // B 内部持有 A 的 weak_ptr // A 的引用计数 不变,仍然是 1 b->a_ptr = a; }// 作用域结束,a、b 两个栈变量销毁 /* 规则: 原本 A 用 shared_ptr 持有 B,B 用 shared_ptr 持有 A,互相计数锁死无法销毁;现在 B 改用 weak_ptr 持有 A,weak_ptr 不增加 A 的引用计数,计数不再互相绑定,这就叫循环引用打破。 因为循环引用打破后,对象销毁顺序不再遵循栈的构造逆序,而是由引用计数归零时机决定,A 计数先归零就先析构。 流程: 出作用域 a 和 b 都立刻销毁,A、B计数器 -1 此时B计数器是 1 A 计数 直接变 0 → A 立刻析构 → 内部的 b_ptr 也销毁 → B 计数再 -1 → 变 0 → B 才析构 */ std::cout << "End of main" << std::endl; } /* root@VM-0-7-ubuntu:~/cpp_projects_2# ./a A destructor called B destructor called End of main root@VM-0-7-ubuntu:~/cpp_projects_2# */
weak_ptr<A> pa只是观察者,能转成正常shared_ptr, 但它本身自带不计入引用计数这个特性, 本身没有访问对象成员的权限,编译器不让用->和*去调取对象里的数据和函数,你想在showInfo()里使用 A 对象,必须先把pa变成shared_ptr。
pa.lock()就是做这件事:
若 A 活着:返回一个有效的
shared_ptr若 A 已被删:返回空的
shared_ptr, 避免程序崩溃,lock失败得到空指针,不解判直接访问会访问野内存
weak_ptr仅记录地址,不阻拦对象销毁,对象可能已释放(比如:外部提前释放主shared_ptr、多线程里别处执行析构、作用域提前结束销毁对象,这些情况当前函数逻辑无法提前知晓对象存活状态),只能通过判断确认。类内成员
weak_ptr永久不增计数,负责破除循环引用,lock生成的shared_ptr是函数局部临时变量,仅函数内短暂提升计数,函数结束立刻销毁, 计数复原, 不会变成成员持有, 不会重新形成循环引用。
std::weak_ptr和std::shared_ptr的引用计数、lock()操作全部是原子操作,内部自带线程安全机制,不需要你手动加锁,不需要考虑如果有个正要释放你却lock的问题。当对象正在执行释放 / 析构的瞬间,你调用
pa.lock(),C++ 标准保证:
如果对象还没开始析构 →
lock()成功,返回有效shared_ptr,计数 + 1,对象会暂停释放,等你这个临时指针用完才会继续销毁(这附近所有的临时其实都用词不准!临时是狗逼豆包说的,按照他说的所有都是临时,出了main原本的那个控制块也消失,所以全都应该叫永久)如果对象已经开始析构 →
lock()直接返回空shared_ptr,不会访问已销毁的对象。所以看代码:
查看代码
#include <iostream> #include <memory> using namespace std; class A; class B{ public: weak_ptr<A> pa; void showInfo(){ // 要访问A里面内容,先lock转shared_ptr shared_ptr<A> temp = pa.lock(); if(temp) cout << "成功访问到A对象" << endl; } }; class A{ public: shared_ptr<B> pb; }; int main(){ shared_ptr<A> a = make_shared<A>(); shared_ptr<B> b = make_shared<B>(); a->pb = b; b->pa = a; b->showInfo(); // 调用访问逻辑,内部用到lock } /* root@VM-0-7-ubuntu:~/cpp_projects_2# ./a 成功访问到A对象 root@VM-0-7-ubuntu:~/cpp_projects_2# */以上是先追问豆包的,回头看作者的
【勘误】,说用
std_expired,检查指向的对象有没有被销毁:
对象还活着 →
expired()返回false对象已经销毁 →
expired()返回true但非原子!
而
weak_ptr::lock()是原子操作,一步完成,不分先后。
lock()执行时,检查是否存活 + 计数 + 1 是同一个不可分割的原子动作,不涉及任何 mutex、同步锁,不需要解锁,临时shared_ptr出作用域自动释放,流程是检查对象是否还活着:
如果活着,就创建一个
shared_ptr,引用计数 +1,→ 这一步叫锁定对象生命周期,保证你用的时候不会被销毁如果死了,返回空的
shared_ptr只是锁定了对象的生命周期。
但有个事一定要注意,极其关键!
此文搜“为啥要用 weak”那个代码,weak完全不影响AB释放周期!!
但我误以为也是weak不影响控制块释放!!其实不是,开讲:
首先
shared_ptr<X> sp;、weak_ptr<X> wp;,没赋值,强弱计数器都是 0。weak:
底层设计机制:
shared_ptr内部存两个指针:对象指针 + 控制块指针,可直接访问实体对象。weak_ptr只存控制块指针,不存储对象原生指针,天然无直达对象的地址。设计目的:弱指针定位是旁观监听,不持有对象所有权,禁止直接访问对象,避免误操作延长对象生命周期。访问唯一途径:weak_ptr必须调用lock(),向控制块校验强计数,强计数非 0 才临时生成shared_ptr,间接拿到对象地址,本身永久不直连 X 对象。赋值行为:
wp = sp仅拷贝控制块地址,不拷贝对象地址,因此只修改弱计数,不动强计数。查看代码
#include <iostream> #include <memory> using namespace std; class X { public: X() { cout << "X 构造" << endl; } ~X() { cout << "X 析构" << endl; } }; int main() { weak_ptr<X> wp; // 先定义一个weak_ptr,wp 是空指针, 啥也没指向, 让它活过作用域 /* weak_ptr<X> 里的X只是类型限定,规定它只能监听管理X类型对象对应的控制块,不是此刻就指向X实例。 空声明weak_ptr<X> wp;:无任何指向,既不指向X对象,也无绑定任何控制块,强弱计数全为 0。 写<X>作用:编译期做类型校验,后续只能接收管理X对象的shared_ptr,杜绝类型错乱。 执行wp = sp后:仅绑定X对象对应的控制块,依旧不直接持有X对象地址,只靠控制块查询状态 */ { shared_ptr<X> sp = make_shared<X>();// 右边 make_shared<X>() 直接触发 X 的构造函数,左边仅做智能指针初始化,不触发 X 构造 /* 堆上创建 1 个 X 对象 堆上创建 1 个 控制块(里面存:强计数、弱计数) sp 指向:X 对象 sp 同时绑定:这个唯一的控制块 此时状态 强计数 = 1 弱计数 = 0 只有 sp 管这块内存 */ wp = sp; /* 字面表意:wp = sp 语义是对接sp 所管辖的资源,绝非指向栈变量 sp 本身。 底层动作:拷贝同一块控制块地址,不拷贝对象地址、不拷贝 sp 自身地址。 计数变动:仅弱计数 + 1,强计数全程不变。 指向明细 sp:指向实体对象 + 控制块 wp:只指向共用的控制块,不碰实体对象 结论:说指向 sp 管理的内存,是指代资源归属,不是指针指向 sp 这个变量。 */ /* 最终指向关系(最清晰) sp → X 对象 sp ↔ 控制块 wp → 控制块 wp ≠→ X 对象 */ cout << "作用域内:sp 还在" << endl; } // 作用域结束: // sp 销毁 → 强引用计数 = 0 // → X 对象 立刻执行析构 // 对象析构只对标强引用计数,强计数归0立即析构实体对象;弱计数只管控制块存活,和实体对象析构无关。 // 重点:wp 还活着! cout << "作用域结束,wp 还在" << endl; cout << "wp.expired() = " << boolalpha << wp.expired() << endl;//用boolalpha所有后面的cout都 1 为 true, 0 为 false // 直到 wp 销毁,弱计数=0,控制块才释放 } /* root@VM-0-7-ubuntu:~/cpp_projects_2# ./a X 构造 作用域内:sp 还在 X 析构 作用域结束,wp 还在 wp.expired() = true root@VM-0-7-ubuntu:~/cpp_projects_2# */ /* sp离开后花括号后,sp智能指针先被销毁,强引用计数减 1 变成 0,触发 X 对象析构 然后马上离开main的花括号,wp也析构 但wp、sp都是智能指针,销毁都没输出,只有对象析构才有输出 */
铁律 1:对象死活只看强引用计数,强计数 = 0 → 对象立刻销毁,不管弱计数是 1、100、10000,对象照样没!
铁律 2:控制块死活看强计数 + 弱计数 都为 0,只要 还有任意一个 weak_ptr 活着,弱计数 ≥ 1,控制块 必须活着!
铁律 3:控制块活着 ≠ 对象活着,这就是最颠覆你认知的地方:对象可以死,但控制块可以继续活着!
控制块是对象拥有的,控制块里的强指针是管对象是否可以销毁的,弱指针是解决 shared_ptr 循环引用内存泄漏(双向都用 shared_ptr,互相持有强引用,强计数永不为 0,对象永远析构不了。一端改 weak_ptr,只增弱计数不影响对象销毁,打破循环锁死。额外附带能力:无所有权监听对象状态,安全判断对象是否存活)
wp.expired()查控制块里的强计,好像在问wp过期了吗?本质:查控制块里的 强引用计数 == 0?
强计数 = 0 → 返回 true
强计数 > 0 → 返回 false
shared_ptr绝对没有expired()方法!!!
shared_ptr= 掌权者,只要它存在,强计数 ≥ 1,对象一定活着。它永远不会过期,所以不需要expired()。
weak_ptr= 观察者,它不掌权,对象随时可能死。它必须用expired()查对象死没死。总结:只有
weak_ptr有expired (),因为只有它不知道对象死没死;shared_ptr自己活着,对象就活着,所以根本不需要这功能!强计数归 0→销毁实体对象;强弱计数全 0→销毁控制块。
存活
shared_ptr数量 = 强计数值(强计数不是凭空存在,每个存活的 shared_ptr 实例,就是强计数的持有者)多一个
shared_ptr,强计数 + 1少一个
shared_ptr,强计数 - 1无存活
shared_ptr,强计数 = 0,实体对象销毁梳理:
定义栈上
shared_ptr变量,调用make_shared首次
make_shared<X>,堆上一次性开辟内存,同时创建实体 X 对象+控制块
shared_ptr内部存实体对象地址、控制块地址该
shared_ptr实例生成,控制块强计数 + 1后续再次生成新
shared_ptr实例,强计数再 + 1,不新建 X 对象,直接用已有的shared_ptr// 造唯一对象 shared_ptr<X> sp1 = make_shared<X>(); // 指向同一个,不建新对象 shared_ptr<X> sp2 = sp1; // 再调用make_shared,出新对象,彻底无关 shared_ptr<X> sp3 = make_shared<X>();智能指针本身是栈变量,自带存储结构,不自带专属实体对象
sp1:make_shared创建唯一实体 X + 唯一控制块,持有二者地址,强计数 = 1
sp2 = sp1:sp2拷贝sp1里的对象地址 + 控制块地址,二者指向同一个实体、同一个控制块,仅控制块内强计数 + 1不存在一个智能指针绑定一个独立对象,多个智能指针可共用同一实体与控制块
只有再次调用
make_shared,才会开辟新内存,生成全新实体对象 + 全新控制块
shared_ptr实例出作用域销毁,强计数 - 1强计数减至 0:立刻析构实体对象
仅剩
weak_ptr实例时,仅占用弱计数所有
weak_ptr也销毁,弱计数归 0强弱计数全 0,释放控制块内存
说下
shared_ptr和make_shared区别:
make_shared= 造新货、shared_ptr互相赋值 = 共用旧货
shared_ptr:是类型名,智能指针这个类,shared_ptr<X>= 盒子(空盒子,栈上)make_shared:是函数,专门用来创建堆对象,
make_shared<X>()= 去堆里造好 X 对象 + 控制块,放回盒子// 1. 先定义空智能指针变量(只有盒子,没东西) shared_ptr<X> sp; // 2. 调用make_shared:堆创建X实体+控制块,装进sp sp = make_shared<X>(); // 3. 拷贝:只复制地址,不调用make_shared,不造新东西 shared_ptr<X> sp2 = sp;补充之前说过的enable_shared_from_this:从名字可以看出几个关键词:
enable: 允许
shared指shared_ptr,
from_this则是指从类自身this构造shared_ptr。之前通过豆包学过这个,但不知道为啥,这次补充下(作者的代码我做了补充,补充不是说这个代码对,而是补充完可以完整看到这代码是咋错的):
查看代码
struct SomeData;//前置声明,告诉编译器存在SomeData这个类名,暂不定义细节, 可以用 class void SomeAPI(const std::shared_ptr<SomeData>& d) {} struct SomeData { //不写构造,编译器自动生成公开构造 void NeedCallSomeAPI() { // 这里想调用 SomeAPI,就只能写:SomeAPI( shared_ptr<SomeData>(this) ); ,原因下面说了 } }; //以下我完善的 int main() { // 这里创建对象,用 shared_ptr 管理 std::shared_ptr<SomeData> p1 = std::make_shared<SomeData>(); /* 外面创建p1: 堆上创建了 对象 A 生成 控制块① 强计数 = 1 p1 管着这块内存。 然后下面是在成员函数里干了坏事 void NeedCallSomeAPI() { SomeAPI( shared_ptr<SomeData>(this) ); } */ // 调用成员函数 p1->NeedCallSomeAPI(); /* 这里进去 NeedCallSomeAPI 函数内部,但此时只有this裸指针,SomeAPI 必须要shared_ptr,你就会想写:SomeAPI( std::shared_ptr<SomeData>(this) ); 此时就出现了两个完全无关的shared_ptr:①外部的p1,一套计数,②函数内新建的shared_ptr<SomeData>(this),另一套全新计数 两个智能指针都认为自己管理这块内存,都会执行delete,同一块内存删两次,程序崩溃 */ }
SomeAPI函数的参数类型被定义为const std::shared_ptr<SomeData>&,限定了传入的参数必须是管理SomeData对象的shared_ptr智能指针。在
SomeData类的成员函数NeedCallSomeAPI内部,仅能获取到指向当前对象的原生裸指针this,该指针不属于shared_ptr类型,无法直接作为参数传递给SomeAPI。你在成员函数里只有
this裸指针,而SomeAPI强制要求传shared_ptr,所以你会本能写出std::shared_ptr<SomeData>(this),试图把裸指针包装成智能指针。这里插一句嘴,上面代码里那个注释“干了坏事”,
补充个知识点:
所以就理解了,
int* p = new int(10);、shared_ptr<int> sp1(p);、shared_ptr<int> sp2(p);这个是裸指针错误,后来又知道make_ptr才是新建,shar_ptr就是赋值旧的用, 现在我有点朦了,这个裸这指针错误的,用的是复制还是新建啊?答案就是新建!
shared_ptr(裸指针)一律新建独立控制块,不是拷贝复用
shared_ptr sp2=sp1才是拷贝复用同一块控制块
int* p = new int; shared_ptr<int> sp1(p); shared_ptr<int> sp2(p);:两次分别新建两套控制块,双控同内存,二次释放崩溃
make_shared内部造对象 + 配套控制块,属于原生配对创建只有智能指针互相赋值才共用计数,裸指针构造永远新开计数
精准定论:
make_shared:新建堆对象 + 配套控制块,原生成套创建
shared_ptr(同类型智能指针):拷贝赋值 / 构造,复用原有控制块,仅强计数 ++,不新建
shared_ptr(裸指针):强制新建全新独立控制块,无视该裸指针是否已被其它智能指针托管(错误写法)核心区分:
传智能指针进来:走拷贝,共用旧计数
传裸指针进来:走原生构造,必开新计数
所以
sp2=sp1是复用,sp(p)是新建(错误写法),二者逻辑shared_ptr<SomeData>(this)它干了什么?它看到一个地址this,它不管这个地址有没有人管!它直接新建一套:控制块②!最终灾难现场,
p1 → 控制块① → 强计数 1
- 函数里新建的 shared_ptr → 控制块② → 强计数 1
两个智能指针,完全不认识对方!都觉得:这块内存归我管!我要负责 delete!
make_shared:一次性新建对象 + 控制块
shared_ptr(裸指针):只认地址,直接新建独立控制块,不查已有计数,无视原管理关系用
this构造,就是凭空再造一套全新计数体系,和外部p1的控制块彻底割裂这里我表达的是对象被delete两次,作者表达的是【导致 this 被意外释放】,一个意思,完整流程是:
外面创建 p1:
shared_ptr<SomeData> p1 = make_shared<SomeData>();
堆上创建对象
控制块①:强计数 = 1
p1 管着它
调用成员函数:
p1->NeedCallSomeAPI();,进入函数内部:void NeedCallSomeAPI() { SomeAPI( shared_ptr<SomeData>(this) ); }执行这一行:SomeAPI( shared_ptr<SomeData>(this) );第一步:创建临时 shared_ptr:
shared_ptr<SomeData>(this)
看到
this这个裸指针新建 控制块②
控制块②:强计数 = 1
和外面 p1 的控制块① 完全无关
把这个临时 shared_ptr 传给 SomeAPI:
SomeAPI( 临时shared_ptr );SomeAPI 内部啥也不干,直接执行完,然后立马回到这个调用:
SomeAPI( 临时shared_ptr );,临时 shared_ptr 生命周期结束 → 销毁!
控制块② 强计数:1 → 0
规则:强计数 = 0 → delete 对象
于是:直接 delete this!
结果:对象已经被删了!内存已经释放了!
回到 NeedCallSomeAPI 函数,对象已经死了!但外面的 p1 完全不知道!p1 还傻乎乎握着已经被释放的地址!
main 函数结束,p1 销毁
控制块① 强计数 1→0
又一次 delete 对象
最终崩溃!一块内存,删两次!
this只是对象自身裸地址,和外部p1无任何绑定关联,互不感知对方存在。但
this就是对象内存地址,临时智能指针凭这个地址直接释放内存,外部p1持有的仍是这个已释放地址,后续再释放就重复删除。这时候才引入的
std::enable_shared_from_this<>。串起来了科普【关于私有】的语法:
私有构造:不让外部直接创建对象,专供工厂函数用(工厂函数就是传参数然后return)
私有成员变量:隐藏数据,外部不能直接改
私有成员函数:仅类内部调用,外部调用不了
上改进写法:
查看代码
#include <memory> // 用 shared_ptr 和 enable_shared_from_this 必须包含 struct SomeData; // 提前声明这个类,让下面函数能识别 void SomeAPI(const std::shared_ptr<SomeData>& d) {} // 接口:必须传 shared_ptr // 重点:继承 enable_shared_from_this,这是能安全用 shared_from_this() 的关键 struct SomeData : std::enable_shared_from_this<SomeData> { // 静态创建函数:只能通过这里创建对象,不能直接 new static std::shared_ptr<SomeData> Create() { return std::shared_ptr<SomeData>(new SomeData); } // 成员函数:调用外部API void NeedCallSomeAPI() { // ✅ 正确:拿外部同一个 shared_ptr,计数+1,不新建控制块 SomeAPI(shared_from_this()); } private: SomeData() {} // 构造函数私有:禁止外面直接 SomeData x 或 new SomeData }; int main() { // 正确创建智能指针,只有这一套控制块 auto d = SomeData::Create(); // 调用成员函数,内部用 shared_from_this() 安全传参 d->NeedCallSomeAPI(); }说下理由和思路,
代码里必须要一个智能指针,因为你要调用
SomeAPI( 智能指针 ),这个接口只认shared_ptr,不认this。你在类里面拿不到外面那个 d,你在成员函数里,只有this,没有外面那个d。你不能凭空变出d来用。那我能不能在类里自己创建一
shared_ptr?不行!shared_ptr<SomeData>(this)会新建一个控制块,和外面d的控制块不是同一个!两个控制块 → 两个管理者 → 同一个对象被删两次 → 崩溃!哪怕新建弱的也不行!所以必须拿到和 d 同一个控制块,咋拿?必须在对象里存一个 “标记”,记住:我这个对象,属于外面 d 那块控制块!但这个 “标记” 不能是强指针!如果对象里放一个
shared_ptr指向自己:那一创建就强计数 + 1,外面d销毁了,对象内部还握着强引用,对象永远不会被销毁 → 内存泄漏,所以只能放weak_ptr来做衔接,weak_ptr好处只有一个:只记控制块地址,不增加强引用,不阻止对象销毁,它就是一个单纯的地址标记。
enable_shared_from_this到底干嘛?它就是帮你自动在对象里放这个weak_ptr标记,你写他就自动搞弱指针weak_ptr,并且在外面d创建后,调用SomeData,就直接把地址填进去了。
shared_from_this()干嘛?它说:“把你藏的那个 weak_ptr 给我,我用它找到原版控制块,生成一个安全的、同源的shared_ptr传给 API。”流程:
执行
auto d = SomeData::Create();
new SomeData创建对象,对象因继承enable_shared_from_this,自带一个内置weak_ptr构造
shared_ptr,创建唯一控制块库自动把控制块地址写入对象的内置
weak_ptr控制块:强计数 = 1,弱计数 = 1(永久绑定,对象活着就不变)
执行d->NeedCallSomeAPI();
- 进入成员函数,调用
shared_from_this()shared_from_this()核心动作
用对象自带的
weak_ptr找到唯一原版控制块基于这个控制块,创建一个
shared_ptr(共用同一个控制块,全局计数实时同步)控制块强计数 +1 → 变为 2
执行SomeAPI(...)
- 把这个
shared_ptr传给接口,全程安全,对象绝对不会被销毁 API 执行完毕
传参用的
shared_ptr生命周期结束- 控制块强计数 -1 → 变回 1
main 函数结束,d销毁
强计数 -1 → 强计数 = 0,对象销毁
对象销毁 → 内置
weak_ptr销毁 → 弱计数 = 0- 控制块销毁
几个思考:
为什么构造函数必须 private?让外部无权访问!之前
shared_ptr<SomeData> p1 = make_shared<SomeData>();也行,但设私有构造的唯一目的:防犯错,怕别人乱写代码,随手拿this新建shared_ptr搞出双指针重复释放,直接锁死创建入口,不让外部乱造对象,强制统一规范,不是必须这么写,只是更安全。详细:
没私有构造、没工厂:
class Bad { public: Bad() {} // 构造公开 → 外面随便 new shared_ptr<Bad> getSelf() { // 重点:用 this 造了一个新的 shared_ptr return shared_ptr<Bad>(this); } };外面用:shared_ptr<Bad> p1 = make_shared<Bad>(); // 引用计数 = 1 shared_ptr<Bad> p2 = p1->getSelf(); // 又用 this 造了一个新智能指针!结果:同一个对象,被两个完全独立的智能指针管理,p1 管着它、p2 也管着它,两个引用计数互相看不见,是 C++ 智能指针最常见、最难查的崩溃。
因为编译器不会拦,但逻辑上永远不应该这么写,而构造私有从根源上让外部没有机会触发这种错误。
为什么构造私有 + 工厂函数 就能彻底杜绝这种错误?
规则一:构造私有 → 外面绝对无法直接创建对象
class Good { private: Good() {} // 私有! public: // 工厂函数:唯一合法创建入口 static shared_ptr<Good> create() { return make_shared<Good>(); } };外面想写:Good obj; // 报错 Good* p = new Good(); // 报错 auto sp = make_shared<Good>(); // 报错全报错!外部根本没有任何办法创建裸对象。
规则二:只能走工厂 → 对象生来就被 shared_ptr 托管,你只能这样写:
auto sp = Good::create();,这意味着:这个对象从出生到死亡,永远只属于一个托管的 shared_ptr总结:
公开构造:外面可以随便
new、随便make_shared、随便拿this乱造智能指针 → 容易崩(错误写法)私有构造 + 工厂:外面完全无法创建对象,只能走唯一安全入口 → 对象一生下来就被托管,永远不会出现重复释放(正确写法)
要么就外部直接传(正确写法)
int main() { // 正常安全创建 auto d = std::make_shared<SomeData>(); SomeAPI(d); // 直接外面传,完美安全 }那对象怎么创建?→ 靠 static 静态函数 Create ()
static std::shared_ptr<SomeData> Create() { return std::shared_ptr<SomeData>(new SomeData); }静态函数 = 不需要对象就能调用,如果不是 static,你必须这样:
SomeData obj; obj.Create();但你连 obj 都创建不出来,怎么调用 Create ()?→ 这就是你刚才问的死循环!非静态成员函数必须先有对象才能调用,可对象还没创建出来因为外部无法创建,卡死无法实例化,所以必须用静态函数。
shared_ptr<SomeData>( new SomeData )把刚创建的对象 交给智能指针管理,然后return ...把这个智能指针 返回给外面。main 函数里发生了什么?
auto d = SomeData::Create();执行流程:
调用 静态函数
SomeData::Create()函数内部
new SomeData→ 创建对象构造
shared_ptr返回给外面 → 变量
d就是那个shared_ptr等价于你原来的:
std::shared_ptr<SomeData> p1 = ...;为什么要继承
enable_shared_from_this?
struct SomeData : std::enable_shared_from_this<SomeData>让对象内部知道:谁在外面管着我(哪个 shared_ptr 管我)成员函数里调用
shared_from_this()干了啥?void NeedCallSomeAPI() { SomeAPI( shared_from_this() ); }关键:
shared_from_this()不是新建指针!它直接找到外面那个 d(shared_ptr),计数 + 1!所以全程 只有一个指针、一套控制块、不会重复释放!作者的总结:
当你需要将
this指针传递给其他函数或方法,而这些函数或方法需要一个std::shared_ptr,而不是裸指针。当你需要在类的成员函数内部创建指向当前对象的
std::shared_ptr,例如在回调函数或事件处理中。我自己的理解(经过豆包肯定):这俩场景本质就一件事:安全拿到托管 this 的原有
shared_ptr,仅此而已。
传给外部函数要
shared_ptr,本质:拿已有的控制块生成合法shared_ptr内部创建指向自己的
shared_ptr,本质:还是拿已有的控制块生成合法shared_ptr作者说:
线程安全性:
其实
shared_ptr线程不安全主要来自于引用计数有并发更新的风险,当然引用计数本身也可以使用原子atomic。所以在多线程环境中使用智能指针时,需要采取额外的措施来确保线程安全,
如互斥锁(
std::mutex)或原子操作(std::atomic)来确保线程安全。理解:
首先加入全局变量a,线程1做修改,线程2作读取,不加锁,两个线程同时冲上去操作 data,可能发生:线程 1 刚写了一半数据线程 2 立刻过来读,结果读到:一半旧值 + 一半新值 = 乱码数字,这就叫数据竞争 / 数据损坏,这才是加锁要防止的东西!要么读到完整旧值,要么读到完整新值,绝对不会读到一半!但加锁 不保证谁先跑!可能:读先跑 → 读到旧值,写先跑 → 读到新值。需要业务自己做延时。
那智能指针也同理,
thread t1(func, sp);这里会自动复制一份新的 shared_ptr 传给线程!不是把原来的 sp 给线程!但t1和t2它们指向的是【同一块内存】,数据是共享的,所以只要保护共享的内存数据,要加锁(mutex mtx;、lock_guard<mutex> lock(mtx)创建一把自动锁,把mtx锁上,函数结束自动解锁,不用手动写unlock)但
shared_ptr的【引用计数增减】本身是原子操作,线程安全!哪怕多个线程操作一个对象的控制块也是,需要要手动加锁的只有一件事:智能指针指向的那块真实数据内容,并发读写修改。科普:
一、拷贝传参(值传递 shared_ptr)
#include <memory> using namespace std; void copyWork(shared_ptr<int> p) { // 函数内使用p *p = 100; } int main() { auto sp = make_shared<int>(10); // 计数=1 copyWork(sp); return 0; }执行流程
1、构造
sp,堆内存存 10,引用计数初始为 12、调用
copyWork(sp),实参 sp 拷贝构造形参 p,计数 + 1 变为 23、函数内部操作 p,操作同一块堆内存
4、函数执行结束,局部变量 p 析构,计数 - 1 变回 1
5、main 结束 sp 析构,计数归 0,释放堆内存
使用场景:需要在函数内留存指针、异步使用、延长对象生命周期,必须拷贝。
二、const 引用传参
#include <memory> using namespace std; void refWork(const shared_ptr<int>& p) { // 只读访问,不可修改指针指向 int val = *p; } int main() { auto sp = make_shared<int>(10); // 计数=1 refWork(sp); return 0; }执行流程1、构造
sp,引用计数 = 12、调用
refWork(sp),形参 p 仅绑定 sp 别名,无拷贝,计数保持 1 不变3、函数内只读访问数据,无任何计数改动
4、函数结束,别名失效,无析构动作,计数依旧为 1
5、main 结束 sp 析构释放内存
使用场景:仅读取数据、临时调用、无需留存指针,省去原子计数开销,效率最高。
总结:
1、传参值传递:拷贝 shared_ptr,计数原子 + 1,和 enable 无关
2、传const 引用:不拷贝、计数不变,开销更小
3、enable_shared_from_this 只负责内部拿自身合法智能指针,和传参拷贝完全两码事
手写 shared_ptr:
科普语法及规则:
1、ptr_、count_这是 C++ 行业通用写法:成员变量加下划线,区分局部变量!2、
SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {}这是带默认参数的构造函数,形参T* ptr默认赋值nullptr。初始化列表先给成员ptr_赋值为传入的ptr裸指针。再用三元表达式判断,若ptr不为空,就在堆上开辟size_t类型内存并初始化为 1,把地址赋值给count_;若ptr是空指针,直接让count_等于nullptr。传入有效对象指针时,对象指针由ptr_保存,引用计数内存创建且初始值为 1,代表当前仅有一个智能指针管理该对象。传入空指针时,ptr_存空,也不创建引用计数内存,避免无意义堆内存分配。3、关于
explicit:这里
= nullptr不是把传进来的参数置空!它是【默认参数】语法,只在你不传参数的时候生效。你传了指针
SimpleSharedPtr<MyClass> sp(new MyClass());,ptr= 你传的new MyClass()。你不传参数
SimpleSharedPtr<MyClass> sp;自动用默认值nullptr。Q:构造函数为啥没有尖括号?
A:你写的:
SimpleSharedPtr(T* ptr = nullptr)这是类内部写的构造函数。
SimpleSharedPtr这个类本身就是模板类,在类里面,T已经代表尖括号里的类型,不需要再写<>。外面用才写
SimpleSharedPtr<MyClass>,里面写就是省略了,语法规则就是这样。Q:
func定义void func(SimpleSharedPtr<MyClass> sp)为什么只有尖括号没有圆括号?A:你写的
void func(SimpleSharedPtr<MyClass> sp),这里SimpleSharedPtr<MyClass>= 一个完整的类型名字。就像int a、string s一样。SimpleSharedPtr<MyClass> sp= 定义一个叫 sp 的变量,类型是智能指针对象。这里不是调用函数,所以不需要圆括号,只需要写类型 + 变量名。Q:为什么
MyClass*能传给func?A:你调用:
func(new MyClass());,new MyClass()得到类型:MyClass*,func要的类型:SimpleSharedPtr<MyClass>,这两个不一样,但编译器看了构造函数:SimpleSharedPtr(T* ptr)发现:
T推导成MyClass、T*变成MyClass*,正好能接收你传的MyClass*,所以编译器自动做:SimpleSharedPtr<MyClass>(new MyClass())生成一个临时对象传给func。也即是说两个类型不一样,编译器必须去找:SimpleSharedPtr<T>这个类型的定义,找到构造函数,有没有办法用MyClass*造出来?Q:语法基础懂了,那这个代码到底有啥问题?
A:梳理流程:
第一步:执行这句
func(new MyClass());真实动作:
new MyClass()产生一个MyClass*裸指针,无任何计数管控。编译器用它构造临时对象:
SimpleSharedPtr<MyClass> 临时对象(new MyClass());,凭借构造函数直接用该裸指针生成栈上临时SimpleSharedPtr<MyClass>,初始化引用计数count_ = 1(也就是下面的第二步骤)第二步:进构造函数,计数器诞生SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {}因为 ptr 不是空,所以:
ptr_= 你的裸指针count_=new size_t(1),count_就是引用计数变量
new size_t(1)= 在堆上创建一个计数器,初始值 = 1(size_t专门用来存数字、记次数的无符号整数类型)第三步:进入 func,
void func(SimpleSharedPtr<MyClass> sp),这里是值传递,会用【临时智能指针对象】拷贝构造sp,拷贝构造真正执行的操作:第四步:func 执行完,离开 }sp.ptr_ = 临时对象.ptr_; // 共用同一个对象指针 sp.count_ = 临时对象.count_; // 共用同一个计数器 *(sp.count_) += 1; // 计数器从 1 变为 2
sp被销毁 → 计数器 -- → 变成 1回到 main 后,临时对象也销毁 → 计数器 -- → 变成 0
第五步:计数器 = 0,智能指针发现计数 = 0,执行:
delete ptr_; delete count_;总结:
表层诱因:裸指针直接隐式构造智能指针,没有任何外部常驻智能指针持有该对象,全程只有临时对象接管内存。
放大隐患:值传递触发拷贝构造,让计数短暂上浮,但无法留住对象生命周期,所有持有者全是临时变量,出作用域全部销毁。
本质问题:裸指针本身不具备引用计数能力,强行交给智能指针托管后,没有长期存活的智能指针实例维系计数,仅靠临时对象流转,必然一次性释放内存。
反观之前的,此文搜“传参(值传递 sh”,那里为啥值拷贝没事?之前auto sp = make_shared<int>(10); // 计数=1 copyWork(sp); // 拷贝 → 计数=2外面有一个 常驻的
sp拿着对象!函数结束 → 计数回到 1,对象 活着,而func(new MyClass());这里根本没有常驻的智能指针!只有一个临时对象!函数结束 → 所有智能指针全部死光 → 计数 = 0 → 对象被删!但你本意根本不想删除啊!上代码:查看代码
// 去掉explicit SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {} // 业务危险写法 void func(SimpleSharedPtr<MyClass> sp){ } int main(){ MyClass* danger_ptr = nullptr; // 定义一个指针 // 1. new 出对象,把地址存起来 danger_ptr = new MyClass(); // 2. 传给 func func(danger_ptr); // 这里 func 结束 + 临时对象 已经把对象 delete 了! // 3. 你再用!!!直接崩溃!!! danger_ptr->print(); // 崩溃!野指针!对象早就死了! }
danger_ptr = new MyClass();:有一个对象活着,无智能指针,无计数器
func(danger_ptr);:编译器用danger_ptr造临时智能指针,构造:count_ = 1,传参值拷贝:count_ = 2
func执行完,函数内sp销毁,count_ = 1func(danger_ptr);这一行结束,临时智能指针销毁,count_ = 0,智能指针直接执行delete danger_ptr,你的对象 死了!出花括号后danger_ptr还在,指向的内存已经被删了,danger_ptr->print();野指针访问,程序崩溃!但野指针、重复释放、访问已销毁内存 → 不一定立刻崩溃 → 但随时会炸!所以 VScode 看不到崩溃
关于计数器:
- 裸指针本身不带计数信息,谁拿它造智能指针,谁就新开一个计数从 1 开始,
传裸指针构造 → 必开新计数器,固定初始值 1
传智能指针对象拷贝 → 共用旧计数器,数值累加变 2/3
隐式转换传裸指针进函数,本质就是临时构造新计数 1,无任何共享计
每次调用
SimpleSharedPtr构造函数,必定新建独立计数器,不和任何旧计数共用。因为你传进来的是【裸指针】,它身上【没有、也不可能带着任何计数器】4、关于手写
shared_ptr写拷贝代码、关于constfunc(const A& a); func(A()); // 临时对象被const引用接住,只读拿数据,用完临时直接销毁 // 正确:other是别名,绑定原对象,只读不改 SimpleSharedPtr(const SimpleSharedPtr& other); // 错误:无const,普通引用,接收临时对象时绑定失败 SimpleSharedPtr(SimpleSharedPtr& other);首先顺序是引用绑定对象,引用是接收方,对象是被绑定方
普通左值引用
T&也叫非const引用:语义 = 后续要修改、要长期操作,编译器认定临时活不久撑不住修改,直接禁止绑定临时对象,只能绑定普通具名变量
const左值引用const T&:语义 = 只读读取,只用一瞬间,用完就扔,允许绑定短命临时对象,也可以绑定普通具名变量拷贝构造是用已有对象初始化新对象,逻辑上不会修改原对象,语义只读。参数强制必须加
const,语法硬性要求const保证原对象只读不可改,引用避免递归拷贝。
SimpleSharedPtr sp2(sp)是用已存在的 sp 初始化新对象 sp2,所以直接触发拷贝构造函数。不加 const 直接报错:临时对象、const 对象无法绑定到非 const 引用,调用直接编译失败。例:
SimpleSharedPtr sp = nullptr; SimpleSharedPtr sp2(sp);若参数无 const,常量 / 临时值传参全报错。shared_ptr 场景下,浅拷贝完全够用,而且必须用浅拷贝,深拷贝首要问题:违背共享指针资源共享语义,直接生成独立堆对象,失去共享意义。而浅拷贝直接用计数器控制,次要问题:额外内存分配与对象构造销毁,产生性能开销。深拷贝不存在多次释放问题,每份实例自有资源,析构互不干扰。
关于拷贝:
SimpleSharedPtr(const SimpleSharedPtr& other):这是拷贝构造函数,用一个已有的SimpleSharedPtr,创建一个新的SimpleSharedPtr,other是被拷贝的原对象
: ptr_(other.ptr_), count_(other.count_):这是成员初始化列表,做两件事:
ptr_(other.ptr_)
让新指针
ptr_指向 和原指针一样的内存地址这叫浅拷贝(只复制地址,不复制对象)
- 这里是内部裸指针:类内自己用
raw_ptr,增减计数再析构,全程受控,安全。外传裸指针绕过引用计数必崩
count_(other.count_)
让新指针的引用计数器
count_,指向 和原指针同一个计数器✅ 结果:两个智能指针,共享同一个对象 + 共享同一个引用计数
++(*count_);:①
count_
是一个指针
指向堆上的一个引用计数值(int)
②
*count_③
解引用指针
拿到真正的引用计数值
++(*count_)把引用计数 +1如果
count_是nullptr,说明没有管理任何对象,这种情况下不能解引用,否则会崩溃,所以先判断指针是否有效。5、关于之前说过错误写法,此文搜“错误写法(double free 错误)”,这里
SimpleSharedPtr<MyClass> ptr1(new MyClass());为啥对?错误本身不是裸指针,而是两次裸指针,只有 ptr1 这一个智能指针 持有这个对象完全没问题
作者赋值运算符白写了,没调用,我做了勘误:
查看代码
#include <iostream> template <typename T> class SimpleSharedPtr { public: // 构造函数 explicit SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {}// 你 new 了对象,指针存起来,计数器 new 出来,初始 = 1,代表:现在有 1 个人管这个对象 // 拷贝构造函数 SimpleSharedPtr(const SimpleSharedPtr& other) : ptr_(other.ptr_), count_(other.count_) {//直接用别人的对象指针,直接用别人的计数器指针,然后计数器 +1,代表:多一个人管这个对象了 if (count_) ++(*count_); } // 赋值操作符 SimpleSharedPtr& operator=(const SimpleSharedPtr& other) { if (this != &other) { release(); // this 是 ptr3,所以 release () 就是 ptr3.release (),执行ptr3自己的release,把ptr3原来持有的资源计数-1 ptr_ = other.ptr_; // ptr3的ptr_ = ptr1的ptr_ count_ = other.count_; // ptr3的count_ = ptr1的count_ if (count_) ++(*count_); // 共用ptr1那边的计数,数值+1 } return *this; } ~SimpleSharedPtr() { release(); } T& operator*() const { return *ptr_; } T* operator->() const { return ptr_; } T* get() const { return ptr_; } size_t use_count() const { return count_ ? *count_ : 0; } private: void release() {// 计数 -1,如果变成 0 说明没人用这个对象了,delete 对象,否则啥也不干,对象继续活着 if (count_ && --(*count_) == 0) { delete ptr_; delete count_; } ptr_ = nullptr;//勘误后加的,置空 count_ = nullptr; } // 以下这俩是所有智能指针共享的 T* ptr_; // 指向真正的对象 (你new出来的那个) size_t* count_;// 指向引用计数 (大家共用这个数字) }; class MyClass { public: MyClass() { std::cout << "MyClass 构造函数\n"; } ~MyClass() { std::cout << "MyClass 析构函数\n"; } void do_something() { std::cout << "MyClass::do_something() 被调用\n"; } }; int main() { { SimpleSharedPtr<MyClass> ptr1(new MyClass()); { SimpleSharedPtr<MyClass> ptr2 = ptr1;//这是创建 + 初始化。不触发赋值重载,触发拷贝构造函数 // 拷贝构造!计数 +1 → 2 // 必须写成这样才叫赋值: SimpleSharedPtr<MyClass> ptr3; // 先创建空对象(调用普通构造) ptr3 = ptr1; // 编译器实际调用这个函数:ptr3.operator=( ptr1 );。ptr1 是 other,ptr3 是 this ptr1->do_something(); ptr2->do_something(); std::cout << "引用计数: " << ptr1.use_count() << std::endl; } std::cout << "引用计数: " << ptr1.use_count() << std::endl; } } /* root@VM-0-7-ubuntu:~/cpp_projects_2# ./a MyClass 构造函数 MyClass::do_something() 被调用 MyClass::do_something() 被调用 引用计数: 3 引用计数: 1 MyClass 析构函数 root@VM-0-7-ubuntu:~/cpp_projects_2# */流程:
从 main 函数开始逐行解释
第一步:创建
ptr1int main() { { SimpleSharedPtr<MyClass> ptr1(new MyClass());
执行构造函数:
explicit SimpleSharedPtr(T* ptr = nullptr)传入了
new MyClass(),所以:
ptr_指向这个新的MyClass对象
count_指向new size_t(1)- 引用计数 = 1
同时
MyClass构造函数打印:MyClass 构造函数第二步:创建
ptr2,用ptr1初始化{ SimpleSharedPtr<MyClass> ptr2 = ptr1;
执行拷贝构造函数(不是赋值),
ptr2直接复用ptr1的ptr_和count_,然后++(*count_),引用计数从 1 → 2,现在:ptr1和ptr2共同管理同一个对象第三步:创建空指针
ptr3SimpleSharedPtr<MyClass> ptr3;
调用普通构造,传入
nullptr
ptr_ = nullptr
count_ = nullptr不管理任何对象
第四步:赋值操作
ptr3 = ptr1(核心!)ptr3 = ptr1;调用:SimpleSharedPtr& operator=(const SimpleSharedPtr& other)这里:
this是ptr3、other是ptr1执行流程:
release():ptr3本来是空,count_是nullptr,所以release()什么都不做
ptr_ = other.ptr_:ptr3开始指向ptr1管理的对象
count_ = other.count_:共用同一个计数器
++(*count_):计数从 2 → 3现在:ptr1、ptr2、ptr3 共同管理一个对象,引用计数 = 3
ptr1->do_something(); ptr2->do_something();调用
MyClass的成员函数,打印:MyClass::do_something() 被调用 MyClass::do_something() 被调用然后
std::cout << "引用计数: " << ptr1.use_count() << std::endl;输出:
引用计数: 3第五步:内层作用域结束,销毁 ptr2、ptr3}先销毁 ptr3:
调用析构 → 调用
release()
--count→ 3 → 2不为 0,不删除对象
再销毁 ptr2:
调用析构 → 调用
release()
--count→ 2 → 1不为 0,不删除对象
所以
std::cout << "引用计数: " << ptr1.use_count() << std::endl;,此时输出:引用计数: 1第六步:外层作用域结束,销毁 ptr1
}
调用析构
release(),--count从 1 变到 0,计数 == 0,真正释放资源
对类类型指针执行
delete 指针,自动执行两步:第一步:调用该对象的析构函数、第二步:回收对象占用的堆内存,这里ptr_= 指向堆上MyClass实体的原生指针,delete ptr_直接打印MyClass析构函数
delete count_不打印任何对象彻底销毁。
精华:
拷贝构造 / 赋值 → 计数 +1
析构 → 计数 -1
计数 == 0 才真正 delete 对象
shared_ptr本质:大家共用一个指针 + 一个计数器回顾作者说:
要实现一个简化版本的
shared_ptr,需要考虑以下几点:
在智能指针类中存储裸指针(raw pointer)和引用计数。
在构造函数中为裸指针和引用计数分配内存。
在拷贝构造函数和赋值操作符中正确地更新引用计数。
在析构函数中递减引用计数,并在引用计数为零时删除对象和引用计数。
作者说的API:
1、
shared_ptr<T>构造函数:创建一个空的shared_ptr,不指向任何对象。2、
make_shared<T>(args...):创建一个shared_ptr,并在单次内存分配中同时创建对象和控制块。这比直接使用shared_ptr的构造函数要高效。3、提到了
reset():释放当前shared_ptr的所有权,将其设置为nullptr。如果当前shared_ptr是最后一个拥有对象所有权的智能指针,则会删除对象。这里又是个勘误!
作者代码没置空,因为紧接着赋值了,但假设加个
reset(),你立刻看到崩溃void reset() { release(); // 只减计数,不置空! // 你的代码没写: // ptr_ = nullptr; // count_ = nullptr; }崩溃代码SimpleSharedPtr<MyClass> ptr(new MyClass()); ptr.reset(); // 调用 release() → 计数变0,delete 对象和计数器 // 但!ptr_ 和 count_ 还指向**已释放的内存**! ptr.use_count(); // 崩溃!访问野指针 count_ *ptr; // 崩溃!访问野指针 ptr_
release()只做了:delete ptr_; delete count_;,但没把指针清空,结果:ptr_和count_变成野指针,再访问 → 立即段错误(崩溃)置空后,
use_count()读count_==nullptr→ 返回 0 ✅ 安全,但*ptr解引用nullptr仍然会崩溃只能加个非空判断。4、
reset(T*):释放当前shared_ptr的所有权,并使其指向新的对象。如果当前shared_ptr是最后一个拥有对象所有权的智能指针,则会删除原对象。ptr.reset(new int(42));5、
get():返回指向的对象的裸指针。注意,这个裸指针的生命周期由shared_ptr管理,你不应该使用它来创建另一个智能指针。int* raw_ptr = ptr.get();6、
先科普
operator*,是重载解引用(重载加号就是operator+)返回T&,解引用智能指针等价解引用内部原生指针ptr_,如果写:
T& operator*() const { return *ptr_; },则*sp等价*(sp.ptr_),取出指向实体对象,比如
int场景:shared_ptr<int> p = make_shared<int>(10); int a = *p;等价
int a = *(p.ptr_);,把指针指向的 int 数值赋值(因为*p执行重载函数return *ptr_,ptr_是T*,*ptr_就是拿到原生指针指向的实体本身)再科普
operator->,返回原生指针T*,如果写T* operator->() const { return ptr_; },作用:你写 ->,我给你返回内部真实指针让你能调用成员函数,所以如果你写p->成员编译器自动转为(p.ptr_)->成员,直接调用对象成员,
operator*→ 目的是拿到对象本身,所以返回T&(引用),而->这个符号,C++ 规定它必须返回「指针」,不能返回「引用」比如
vector场景:shared_ptr<vector<int>> vp; vp->push_back(42);等价
vp.ptr_->push_back(42)。Q:我没理解
A:
T&是函数返回值类型,T是模板指代托管对象类型,&代表返回对象引用。函数整体T& operator*() const:重载解引用*,调用*智能指针时,返回内部原生指针指向实体对象的引用。内部实现
return *ptr_:ptr_是T*原生指针,*ptr_取出指针指向的真实对象,用引用返回,避免拷贝开销,也支持修改对象内容。调用逻辑:
SimpleSharedPtr<int> p(new int(10)); int num = *p;等价调用
p.operator*(),拿到int类型对象本体完成赋值。对比
operator->:它返回T*原生指针,编译器语法糖自动拼接成员访问,实现p->xxx写法。Q:为啥加圆括号呢?
A:
():运算符重载本质是函数,无参数就写空括号,解引用*是单目运算符,不需要传入实参,所以括号内为空。末尾const:成员函数常成员函数修饰,代表该函数执行过程中,不会修改当前类对象内的任何成员变量,const对象也能调用这个函数。所以就理解了
operator*, 解引用*ptr,拿到指针指向的那个对象本身,int value = *ptr;就等于int value = *(ptr.ptr_);。7、
use_count():返回当前shared_ptr的引用计数,8、
unique():检查当前shared_ptr是否是唯一拥有对象所有权的智能指针。等价于use_count() == 1。9、
swap(shared_ptr&):交换两个shared_ptr的内容std::shared_ptr<int> ptr1 = std::make_shared<int>(42); std::shared_ptr<int> ptr2 = std::make_shared<int>(24); ptr1.swap(ptr2);10、
operator bool():将shared_ptr隐式转换为bool类型,用于检查其是否为空。if (ptr)编译器自动调用operator bool(),不用你手动写转换语法if (ptr) { std::cout << "ptr 不为空" << std::endl; } else { std::cout << "ptr 为空" << std::endl; }
关于【C++11 新特性】深入理解 C++ weak_ptr:
之前提前啃自以为精通了!做点补充。
避免
std::shared_ptr出现相互引用,导致对象无法析构,内存无法释放。但更准确的说他不局限于这个问题,还能做的是:一切应该不具有对象所有权,又想安全访问对象的情况(发现作者举的例子还是比我问豆包总结的差劲多了【勘误】)。
作者的一个有趣的例子:
异步任务
Task:有人发起任务,有人执行任务 → 两边都要管这个任务生死,用shared_ptr(共享所有权) 监控链表(每 10 秒看一眼进度),它只是看,不拥有任务,任务结束了,监控链表不应该拦着不让死,监控也不能影响任务生命周期,这种只看不拥有的场景,必须用weak_ptr。因为shared_ptr会延长生命周期,你把任务放进监控链表 = 又多一个人持有,任务想销毁都销毁不了。关于对象资源竞争:
expired () + lock ()分开写 = 多线程必崩!
lock()直接用 = 原子安全,永远正确!if (!wp.expired()) {//判断对象是否还活着,没被释放 wp.lock()->DoSomething();//由于非原子,lock () 失败返回空指针,直接调用函数 -> 崩溃! } /* wp.lock锁定就是把 weak_ptr 转为 shared_ptr,成功即持有对象所有权 变成功:对象活着 变失败:返回空指针 */这两句不是原子的!
刚检查完
expired()= 没释放瞬间!别的线程把对象删了!
然后你
lock()拿到空指针直接崩溃!
这叫:检查与使用不是原子操作,多线程不安全。应该:
auto sp = wp.lock(); if (sp) { sp->DoSomething(); }
lock()是原子操作:要么成功拿到有效 shared_ptr,要么为空,不会中间被释放。多线程下,单独用 wp.expired () 几乎没用!
关于 C/C++ malloc-free 内存分配原理(相当重要):
之前啃小林 coding 和 编程指北 C++ 前面章节自己追问拓展精通了,回顾补充(针对作者原文的延展 & 豆包辅助后自己的深刻理解):
关于动态内存管理这块在面试中被考察频率非常高,切入的点也很多,有从操作系统虚拟内存问起的,也有从 malloc、new 等开始问起的。
但是无外乎就是两块内容:
虚拟内存机制:物理和虚拟地址空间、TLB 页表、内存映射
动态内存管理:内存管理、分配方式、内存回收、GC等等
在这篇文章中,我们将主要专注于动态内存管理这部分,从 malloc 和 free 的底层原理出发,一步步讲到 mmap和brk等知识。
malloc和free是 C 语言中用于动态内存分配和释放内存的两个函数。它们是 C 语言标准库的一部分,用于在程序运行期间请求和释放堆内存。
那么 malloc 分配的内存究竟从何而来呢?
进程地址空间:
由于虚拟内存的存在,每个进程就像独占整个地址空间一样,可寻址的空间大小是4G,linux系统下0-3G是用户模式,3-4G是内核模式。
而在用户模式下又分为:
代码段:其中代码段主要存放进程的可执行二进制代码,字符串字面值和只读变量。
数据段:存放已经初始化且初始值非0的全局变量和局部静态变量。
bss段:存放未初始化或初始值为0的全局变量和局部静态变量。
堆段:是存放由用户动态分配内存存储的变量。
栈段:主要存储局部变量、函数参数、返回地址等
mmap:
是零拷贝,mmap直接把文件页映射进进程虚拟地址,用户态直接读写,无需内核缓冲区拷贝,属于经典零拷贝IO。
当你写malloc的时候,底层内部自动判定走堆还是匿名
mmap(有名的mmap都是底层写好的用于装载动态共享库的):
内存<128KB:走堆,
free后库缓存不还给内核,易碎片,复用快。free 后不立即还给内核,glibc 缓存留着复用,易产生内存碎片。泄漏后这块物理内存一直被进程攥住,进程不死,内存永久常驻不放,RSS 降不下去。内存>128KB:走匿名
mmap,free直接还给内核,无碎片,释放即降内存。free 直接归还内核,无缓存,无碎片,频繁大开大合内存首选。大内存直接向内核映射物理页,泄漏会快速持续吃物理内存,RSS 飙升。RSS:进程实际占用的物理内存大小,实打实吃了多少真机内存。
glibc 是 Linux 下 C/C++ 程序的底层运行库,
malloc/free就是它实现的,glibc 给malloc定的一条分界线,MMAP_THRESHOLD 是阈值的意思,缺省为 128KB,也就是默认值:
小于它走堆
大于它走
mmap匿名映射业务里小块内存创建销毁极频繁(放堆缓存可复用),大块多为临时缓冲区,用完即弃。
之前学的零拷贝是直接点名道性的写mmap。
MMAP_THRESHOLD:默认128KB,是 glibc 划分内存分配方式的界限值。
服务端实战用处:写网络大缓冲区、日志缓冲区,直接开超 128KB,自动走mmap,长期服务更稳,减少内存膨胀。
mmap分配的内存不纳入堆管理,进程崩溃,mmap内存自动全部回收。查看代码
#include <stdio.h> #include <stdlib.h> int main() { // 小于 128KB → 走堆 void* p1 = malloc(100 * 1024); // 100KB // 大于 128KB → 走 mmap void* p2 = malloc(130 * 1024); // 130KB printf("p1(小内存) = %p\n", p1); printf("p2(大内存) = %p\n", p2); free(p1); free(p2); } /* root@VM-0-7-ubuntu:~/cpp_projects_2# ./a p1(小内存) = 0x55afc17932a0 p2(大内存) = 0x7f514ac20010 root@VM-0-7-ubuntu:~/cpp_projects_2# */55 开头堆,7f 开头 mmap。
mmap 映射区向下扩展,堆向上扩展,两者相对扩展,直到耗尽虚拟地址空间中的剩余区域。
在Linux中进程由进程控制块(PCB)描述,用一个task_struct 数据结构表示,这个数据结构记录了所有进程信息,包括进程状态、进程调度信息、标示符、进程通信相关信息、进程连接信息、时间和定时器、文件系统信息、虚拟内存信息等. 和malloc密切相关的就是虚拟内存信息,定义为struct mm_struct。
task_struct = PCB = 进程的身份证
mm_struct = 管理进程虚拟内存的总控结构
brk /start_brk = 堆的起始和结束地址,我们使用malloc动态分配的内存就在这之间。
start_stack是进程栈的起始地址,栈的大小是在编译时期确定的,在运行时不能改变。
malloc 小内存 = 调整 brk 指针(扩大堆)
系统调用(brk/sbrk)很慢,所以 glibc 批量申请内存
而堆的大小由start_brk 和brk决定,但是可以使用系统调用sbrk() 或brk()增加brk的值,达到增大堆空间的效果,但是系统调用代价太大,涉及到用户态和内核态的相互转换。
手动:程序员可调用
brk/sbrk手动改堆边界扩内存自动:日常只用
malloc库函数时候,库函数内部自动判断空间不足时,malloc内部自动用brk()来上调brk扩容,无需开发者手动操作(勘误,作者原文搜“申请小内存的时”,malloc是用brk()不是sbrk())核心逻辑:glibc 先预占大片堆内存供 malloc 自由划分,用尽才触发系统调用抬升 brk,减少频繁态切换开销
后面一部分主要就malloc如何分配内存进行说明:
start_brk= 堆的起始地址(固定不变)
brk= 堆的当前末尾地,malloc会把它往后推,扩大堆。
brk()/sbrk()是修改它的系统调用:
brk():是系统调用,直接把堆尾地址指针program break设置成你指定的地址。执行成功时返回0,否则返回-1并设置errno为ENOMEM;
sbrk():是库函数封装了brk(),在将break指针从当前位置移动increment所指定的增量,正数 = 扩堆,负数 = 缩堆,0 可以获得当前break地址。sbrk成功时返回break指针移动之前所指向的地址,否则返回把整型 - 1 强转为 void * 类型。Linux是按页进行内存映射的,所以如果
break被设置为没有按页大小对齐,则系统实际上会在最后映射一个完整的页,从而实际已映射的内存空间比break指向的地方要大一些。但是使用break之后的地址是很危险的
program break是个指针!
malloc申请小内存时,通过sbrk移动该地址,仅扩展虚拟地址,不立刻分配物理内存。编程指北也太牛逼了吧?这源码我真看不下去头大!!他居然写的这么详细,强迫自己看下去好几次,鼓气信心几次都没看下去,这人如果去打 算法竞赛 也不会差,算法竞赛比这个容易多了个人感觉
换一种方式,让豆包给我提供个有趣的例子来学这个章节,因为下面实在啃不动了,
但代码他妈的又是很头疼,无解,只能放弃,说下心路历程吧(关于 htop 命令的类似操作,在
stream可以自动close,即这个文章里搜“发现上面的输出的内容”提到过、在写 http多线程服务器项目 中,写线程池的时候也说过)查看代码
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include<iostream> using namespace std; int main() { printf("=== 初始状态,记住此时 RES ===\n"); sleep(15); // 流出时间操作终端,看初始值 // 直接分配 4MB(远大于 128KB) printf("=== 分配 40MB 大内存 ===\n"); void* p = malloc(40 * 1024 * 1024); sleep(2); // RES 大幅上涨 cout<<"此时应该大幅度上涨"<<endl; sleep(2); // RES 大幅上涨 // 释放 free(p); printf("=== 已释放!RES 会立刻跌回初始值!===\n"); sleep(100); }RES:进程当前实际占用的物理内存大小。
本来理论是:
≤128KB:free 不还系统 → RES 不动
小块内存malloc → 进程堆里拿,free → 还给 glibc 池子,不还给内核,操作系统看不到释放,所以 RES 不变
>128KB:free 直接还系统 → RES 下降
大块内存malloc → mmap 向内核申请,free → 直接还给内核,操作系统收回,RES 下跌,
我实践是:
g++ abc.cpp -o abc、./abc,另一个终端
ps aux | grep abc,记录PID然后htop -p 对应PID,结果发现内存一直不还给系统,即RES不回落豆包解释:
小测试对不上,是测试体量太小、进程常驻基础内存 + 缓存池兜底,干扰全覆盖现象
线上大项目体量够大,差距直接拉开,一眼能分清
无奈啃理论吧! ~~~~(>_<)~~~~
进程虚拟地址只是逻辑空间,无物理映射就无法读写,
仅分页映射物理内存才可使用,其中:
全局 / 局部变量、栈变量:进程创建、函数调用时内核自动分配页并映射物理内存,意思是想
int a这些直接写就行。堆内存必须内核主动映射,malloc/new/mmap 都是干这事,底层自动调用 sbrk/mmap 完成映射,上层代码无感
- 只有大内存、文件映射、共享内存等场景,才需要手动调用 mmap。
深入抽插:
malloc:C 库函数,申请堆内存,底层自动选,
<128用sbrk/>128用mmap,你只管拿地址。地方够:直接用,
地方不够:
<128的数据就在虚拟内存里的堆段通过sbrk扩容。
>128的数据就在虚拟内存里的新开辟个大空闲位置,来mmap搞匿名内存,直接从内核搞进来虚拟内存数据,所有进程共享。
new:C++ 关键字,底层是
malloc,属于malloc + 构造函数,申请 + 初始化对象mmap:系统调用,手动映射虚拟内存(堆 / 文件 / 共享内存都能用)
Q:
malloc大内存直接走mmap为啥还要手动mmapA:
malloc走mmap是匿名私有映射,只拿裸内存,无附加能力手动
mmap能干malloc做不到的:
映射文件内存,磁盘数据直接进虚拟地址,省拷贝
建共享内存,多进程互通数据
精准指定虚拟地址、设置权限、固定映射属性
超大内存、内存池、零拷贝场景自由管控
物理内存有限无法全映射(虚拟页 → 整机物理内存页框),因为所有进程虚拟地址共用同一片物理内存,物理总量固定,不够就没法全映射,
rlimit限定进程资源上限,getrlimit查询该上限。每种资源有软限制和硬限制,并且可以通过setrlimit对rlimit进行有条件设置。
其中硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。
mmap函数:
mmap函数第一种用法是映射磁盘文件到内存中,是文件映射:把磁盘文件直接映射到虚拟内存,读写内存等价于读写文件。而
malloc使用的mmap函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存,比如你调用malloc(100MB),底层 malloc 发现要的内存比较大,就喊系统:“给我一块 100MB 的匿名内存,我不用文件!”,系统直接
内核分配物理内存页
建立进程虚拟地址和物理页映射关系
把虚拟地址返回给程序直接读写
全程无文件参与,就是匿名映射我捋顺下:
malloc 内部确实是两套
小内存(≤128KB):用 brk/sbrk,在堆上连续扩空间。
大内存(>128KB):用 mmap + MAP_ANONYMOUS,即匿名映射。
malloc 不会用文件映射,它只负责 “开空间”。
mmap 手动调用:两种都能做
- 匿名映射(MAP_ANONYMOUS,fd=-1):只拿空白虚拟内存,不关联文件、不读磁盘,和 malloc 开大内存完全一样。
文件映射(fd = 打开的文件句柄):把文件内容映射到虚拟内存,有数据、可读写文件
查看代码
// 1. malloc 内部:小内存 → brk #include <stdlib.h> int main() { void *p = malloc(1024); // ≤128KB → 底层 brk free(p); } // 2. malloc 内部:大内存 → 匿名 mmap #include <stdlib.h> int main() { void *p = malloc(256*1024); // >128KB → 底层 匿名mmap free(p); } // 3. 手动 匿名 mmap(和 malloc 开大内存一样) #include <sys/mman.h> int main() { void *p = mmap(//向内核申请4096字节空白虚拟内存,无关联文件,权限可读写,进程私有使用 NULL, // 内核自选虚拟地址 4096, // 大小 PROT_READ | PROT_WRITE, // 可读可写 MAP_PRIVATE | MAP_ANONYMOUS, // 匿名+私有 -1, // 无文件 0 // 偏移无意义 ); munmap(p,4096); } // 4. 手动 文件 mmap(读磁盘文件到内存) #include <sys/mman.h> #include <fcntl.h> int main() { int fd = open("a.txt",O_RDONLY); void *p = mmap(//打开 test.txt 文件,将其开头 4096 字节内容映射到进程虚拟内存,只读访问,不修改原文件 NULL, 4096, PROT_READ, MAP_PRIVATE, fd, // 文件句柄(有数据) 0 // 从文件头映射 ); munmap(p,1024); close(fd); }
munmap函数是用于释放内存,第一个参数为内存首地址,第二个参数为内存的长度。
malloc内部如何用mmap和sbrk分配内存的逻辑:当申请小内存的时,malloc使用sbrk分配内存;当申请大内存时,使用mmap函数申请内存;但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。
内存池就是进程堆段:
glibc 提前用 brk 系统调用扩充堆段,预存大块连续虚拟内存当池子
小内存直接从这个堆池拆分分配,少发系统调用
池子见底才再调用 brk 扩容堆段
超 128K 跳过堆池,直接匿名 mmap 单独分配
总结就是:
小块内存(<128K):走 brk + 内存池
不从系统重新申请
从已经申请好的堆内存池里抠一块给你
池不够了,才调用
brk向系统多要一点优点:超级快
大块内存(>128K):直接走 mmap
不进内存池
直接调用系统 mmap(匿名映射)
用完
free立刻还给系统优点:不占进程地址空间、不产生内存碎片
优化内存池好像全对上了,wx搜“内存池”。至此才了解到内存池, 全对上了,打算写个内存池小练手,wx代码随想录有,只需要github就可以照着豆包啃精通。
捋顺清楚了自己定位:
一直以为服务端是小众硬核,没几个人学,然后发现服务端也分大量CRUD的业务岗(不用学那么多)和高性能岗,我误打误撞学进了高性能(误以为是写基础建设底层架构啥的像编程指北一样,以为是10%的岗位),
然后今天才知道大厂C++70%都是编程指北这种的高性能,对标的是大厂Java才有大量的CRUD,同归属于业务岗但能力要求完全不是一个层级,Java 业务岗业务为主性能为辅,C++ 高性能业务岗性能底层为主业务为辅,大厂30%零业务纯自研框架写内核,对标Java的是10%的。而这里70%的群体是大厂C++的,需要很深的知识量(那些C++语法选手做了桌面、嵌入式、传统政企后台)。
大厂也有10%的C++业务岗是CRDU的
从知识量和学习难度,C++高性能业务岗对标Java的硬核架构岗。C++的基础架构岗远高于Java的架构岗。
Java中间有巨大缓冲层:从最简单 CRUD 业务 → 分布式业务 → 高并发业务 → 业务架构师,全程不用碰底层源码、不用手写内存、不用啃系统内核,只需要叠加业务经验、用现成组件、调参、做业务架构升级就能稳步晋升,哪怕一直写 CRUD,熬年限也能涨薪、升职级,有大量安稳退路。
总结 Java:低门槛入行,层层平滑上升,中间人群巨多,两极被中间层隔开
C++ 真正无折中,一刀劈死,没有缓冲层:低端:纯语法选手 → 桌面、嵌入式、工控、传统后台(薪资低、上限锁死),中间彻底断层,没有温和过渡岗位,不存在「半业务半高性能」轻松混日子岗位,想进互联网大厂服务端,直接强制拉到底层功底门槛,必须内存管理、网络模型、并发、手写组件,不学深直接进不去
总结 C++:要么低端混日子,要么死磕底层进高薪服务端,没有不上不下混饭吃的中间路
至此我也有了一个思路,项目学Redis加 POJ的题目奶牛开Party的最短路径最为业务场景东西,然后增加内存池练手(之前金融项目的那些算法那大杂烩)
插一句:无意间看到吴师兄朋友圈,无意间发现leetcode第一道题也他妈不容易啊,还是得刷leetcode100啊!!!
malloc实现方案:
科普:
虚拟内存整体布局是全局共享地址空间,但每个进程有独立页表,做到地址隔离。
brk 扩展的堆内存:归属当前进程私有页,别的进程无权访问,属于进程私有虚拟内存。
所谓虚拟内存共享,分两类:
进程独立区域:堆、栈、匿名映射,私有独占;
共享区域:动态库、共享内存、文件映射共享区,多进程可见。
结论:brk 申请的堆空间,天生就是进程私有,不属于共享范畴。
brk:仅向内核申请进程私有虚拟内存,堆区扩容,不碰磁盘,纯自用空间。
mmap:
匿名映射:同 brk 逻辑,申请私有虚拟内存,无磁盘交互,纯开辟空间;
文件映射:绑定磁盘文件,建立内存与磁盘文件映射,直接交互磁盘数据。
内存池(扯了2.5个月):
这个思路其实之前就学到过,但 IO 层
read→fread这套封装无需手动模拟,标准库封装成熟、场景通用、性能足够,业务层直接用即可,自研无收益。而
malloc必须自研模拟 / 手写内存池:
glibc 的 malloc 通用适配全场景,但问题是:
并发(malloc内部自带锁,同一时间只允许一个线程执行内存分配释放,多线程一起 malloc 抢同一把锁直接卡死)、
碎片(频繁小块分配内存越用越碎最后没空间)、
分配速度(每次都要进内核)
服务端高并发场景:频繁小对象分配释放,原生 malloc 锁竞争重、碎片多
可控性:自定义内存对齐、内存大小规格、内存生命周期、内存泄漏统计
性能碾压:一次性向内核申请大块内存,用户态无系统调用、无锁 / 细粒度锁分配,远超原生 malloc,以后所有小分配都从这块大的里切,再也不调用 malloc。
平时:
malloc(16)、malloc(32)、malloc(64)→ 调用 N 次内核,极慢。内存池:先malloc(1MB)一次要一大块 → 后面所有小内存都从这里切,切 1000 次都不再进内核,速度爆炸快!
业务定制:适配私有协议、固定大小对象池、线程本地内存池等专属场景,通用 malloc 做不到
本质区别:IO 封装解决磁盘读写频次,通用场景够用;内存分配解决服务端高并发内存吞吐 + 碎片 + 锁开销,高性能服务端必须自研简化分配逻辑。
早期设计仅单条空闲链表,所有堆操作都要操作该链表,只能全局上锁互斥,如今的
malloc底层用的是ptmalloc实现,拆分多个空闲链表与内存分区,仅锁定操作对应的局部链表分区,不全局锁住整个堆空间(也叫分区细粒度锁),但线程多了,依旧不行。注意几个问题:
进程虚拟地址空间全局共享,所有线程可见
线程栈、线程局部存储 TLS,线程私有隔离,其他线程无法随意访问篡改
堆、全局变量、静态变量,多线程共享,无天然隔离
多线程是一个cpp文件里,主main线程之外开的子线程,他们这些线程调用
malloc拿到的堆内存数据互通共享。分析:
如果,每个线程都
malloc,容易碎片,改进是说,理论上直接malloc大块也行,但无管理逻辑就是裸内存,没法规整分配、回收、对齐,这会越用越乱,所以手写内存池主线程预先申请一大块连续内存,所有线程统一从这块内存申领使用,每次申请也不再用malloc。但申请大块内存≠直接无锁,分两种设计
线程私有内存池(真正无锁):每个线程单独提前申请一大块独立内存,线程只取用自己池子里的内存。线程之间内存区域完全隔离,互不干涉,不需要加任何锁,不存在争抢越界问题。
全局共用内存池(必须加锁):所有线程共用同一块大内存,依旧要加锁保护分配位置、空闲链表,只是锁粒度更小(即把锁的范围缩小到「我们自己管理的这一小块连续内存」,只锁这一小块内部的分配指针 / 空闲链表;而系统
malloc哪怕细粒度,锁的是「整个进程堆的分区 / 桶」,锁的范围、逻辑复杂度,都远大于我们自己的极简全局内存池)、竞争频率远低于malloc全局大锁,性能远优于原生malloc。关于方便程度:malloc 是 C 标准库,所有编译器 / 系统默认自带,直接写
malloc(100)就能跑。tcmalloc/jemalloc 需要额外安装、编译、链接。Q:如此大的需求为啥没现成的?
A:现成成熟内存池库早已普及,线上项目几乎没人裸用原生 malloc;手写内存池,只为极致定制化、剔除冗余、贴合专属业务模型,通用场景直接用 tcmalloc/jemalloc 即可。
Q:那还手写个JB?
A:
考底层原理吃透:不是让你造工业级库,是考察你懂:进程内存布局、brk/mmap 申请、内存对齐、碎片成因、glibc 分配瓶颈,能讲清原生 malloc 慢、锁重、碎内存的根因。考数据结构功底:手写要写空闲链表、内存块切割、偏移管理、回收复用,直接考察链表、内存布局、地址运算基本功,是服务端开发基础硬功底。
考并发优化思维:手写过程必须区分:全局池加细粒度锁、线程本地池无锁两种写法,面试官看你懂锁粒度优化、线程隔离思想,这是高并发核心思路。
考工程优化思想:体现你具备批量申请、批量复用、减少系统调用的性能优化思维,这是高性能服务端必备思维,不止局限内存,网络 IO、连接池、线程池全是这套逻辑。
项目落地佐证:简历写高性能服务器、多线程框架、网络框架,必须用手写内存池做技术落地佐证,证明你真做过性能优化,不是只会调用 API。
区分普通选手与后端核心选手:普通开发只会调用
new/malloc,大厂要的是懂内存分配底层、能自研优化、能解决线上内存碎片与并发分配瓶颈的人,手写就是筛选门槛。多希望有钱可以 买书,唉!没钱买书
《Linux 高性能服务器编程》游双 第 15 章 内存池,原文要点:
频繁调用
malloc和free存在两大缺陷:一是系统调用开销大,每次分配都可能陷入内核;二是容易产生大量内存碎片,降低内存利用率。内存池思想:预先向操作系统申请一块连续的大内存,后续内存分配均从这块内存中划分,释放时也统一归还给内存池,而非直接归还系统。
《深入理解计算机系统》CSAPP 第 9 章 堆内存管理,原文核心:
C 标准库
malloc并非系统调用,其底层依靠brk调整堆顶位置、mmap映射匿名内存两种方式向内核申请虚拟内存。早期 glibc
malloc采用全局唯一互斥锁保护堆分配操作,多线程并发分配时,所有线程串行等待锁,并发性能极差。后期 arena 优化拆分多内存域,但依旧存在域内锁竞争,无法彻底解决高频小对象分配阻塞问题。
内存碎片本质:无序随机大小内存分配释放,造成大量离散空闲小块内存,无法满足大块分配需求。
好吧,先简单的开.开胃压压.惊,学个全局锁的内存池 —— 内存池迭代版本1:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ public: static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug // 把这一大块切成 1024 个小块,串成链表 for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; } /* 一次性 malloc 64KB(1024×64B) 把这一大块切成 1024 个小 64B 块 用链表把它们串起来 让 free_head_ 指向第一个块 这就是内存池的核心:预分配 + 链表管理。 */ public: void* alloc(){ std::lock_guard<std::mutex> lock(mtx_); // 加锁,线程安全 if (!free_head_) // 没有空闲块了 alloc_pool_chunk(); // 再预分配 1024 块 BlockNode* ret = free_head_; // 拿第一个空闲块 free_head_ = free_head_->next; // 头指针后移 return ret; // 返回给用户使用 /* 分配逻辑超级简单: 加锁 没空块 → 再申请一大块 把链表头节点取出来返回 头指针指向下一个块 可以看出这里内存池只改指针,malloc 要全局找空间、加锁、管理碎片,所以内存池快得多 */ } void free(void* ptr) { if (!ptr) return; std::lock_guard<std::mutex> lock(mtx_); BlockNode* node = (BlockNode*)ptr; node->next = free_head_; // 插回链表头部 free_head_ = node; // 新空闲头 = 刚释放的块 }/* 把块重新插回空闲链表头部,并没有真正还给系统! 这就是内存池高效的原因 —— 复用内存,不反复调用系统 free。*/ }; static GlobalMemoryPool g_pool; inline void* pool_alloc() { return g_pool.alloc(); } inline void pool_free(void* p) { g_pool.free(p); } int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; sleep(10); printf("开始分配内存\n"); for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc(); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("开始释放内存\n"); for (int i = 0; i < CNT; ++i) pool_free(arr[i]); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("测试结束\n"); }科普语法:
1、无大小强制转换限制,任意指针类型均可互转,只是大转小指针,解引用会越界破坏数据,有内存越界风险。
char*单字节寻址访问(即按照1字节解引用),int*按 4 字节寻址访问。2、
inline:
不加
inline:函数写在.h头文件里,A.cpp 包含 → 定义一次,B.cpp 包含 → 又定义一次,编译器报错:重复定义 (multiple definition)加
inline:编译器允许这个函数在多个 .cpp 文件里重复定义。因为链接器会合并为一份实体,规避重复定义报错。作用就是把这个函数的代码,直接复制粘贴到调用它的地方,不进行函数调用,正常函数:调用 → 跳过去执行 → 跳回来,有跳转开销。
inline函数调用时不跳转,直接把函数里的代码展开在当前位置。没有函数调用开销,更快。但代码量大的函数不建议加,会成倍增大程序体积,反而拖慢运行,递归、多分支复杂逻辑函数,inline 基本失效。现代这玩意基本没用,编译器内置自动判断。3、关于有参无参:
查看代码
//---------------对象创建调用构造函数----------------- // 无参构造创建对象 class Demo{ public: Demo(){ cout << "无参构造" << endl} }; Demo obj1; // Demo obj1(); // ❌ 错误!这是函数声明,不是对象 // 有参构造创建对象 class Test{ public: Test(int a){cout << "有参构造 a = " << a << endl;} }; Test obj2(10); //--------------------函数调用---------------- // 无参函数调用 void fun1(){} fun1(); // 有参函数调用 void fun2(int x){} fun2(10); //Pool ins();叫定义一个函数声明类是模板,对象是实例,创建对象触发构造函数调用,本质是也是函数调用,无参构造创建对象语法省略括号避免歧义。
重点说下很绕的新链衔接,为啥是新表要衔接到旧的开头而不是尾巴:
初始空闲链:
A→B→C→nullptr,free_head_ = A比如要申请
malloc个小内存,那就不用malloc,直接用我们这个手动写的内存池,先分配一个,即:第一次分配:取头节点
A,free_head_更新为B,剩余空闲:B→C→nullptr第二次分配:取头节点
B,free_head_更新为C,剩余空闲:C→nullptr,仍有可用块,不扩容第三次分配:取头节点
C,free_head_变为nullptr,无空闲块,触发扩容,扩容新增块尾节点指向原空表头D->E->F>null,更新free_head_ = D第四次分配:取头节点
D,free_head_更新为E,剩余空闲:E->F→nullptr,释放归还
A:A->next = free_head_,即A->next = E,即A->E->F->null再把free_head_设为A。真的好神奇!!这就是新搞的永久衔接旧的头,内存池分配永远只拿链表头,新的 chunk 放前面 / 放后面,效果完全一样,都能分配到,但设想下,比如目前有
A → B → C → D → nullptr,你想申请 5 块,显然不够,就要新申请,但注意如果新申请的是64块,你用到现在只有ABCD这 4 块了,那你直接就新申请的 64 个找尾巴接到A上即可,你如果想接到旧的尾巴上,关键问题是,你根本不知道自己用了多少呀?注意:常规内存池默认单块分配,无批量一次性申领多个的场景。
比如:假设开64块后,你已经要走了 60 个内存块,又陆续释放 1 个,此时剩下的是 5 块,那好你如果想找到这个54块的末尾可不是能直接算
新申请是连续的一块,可以找到尾巴,但用了就一定会打乱,
比如地址:001 -> 002 -> 003 -> 04 -> 005...、-> 064,
你用了只剩下最后 4 块,061 -> 062 -> 063 -> 064,此时释放了 001 块,链变成:001 -> 061 -> 062 -> 063 -> 064 这 5 块,这你就必须用
null做末尾终止符来遍历才能找到 064 。理解了“整块 malloc 申请的连续内存,必须整体释放,无法单独释放其中局部高低地址片段,链表拆分整块内存为独立节点,可单独回收复用任意节点”。但注意,如果
free回收给操作系统,依旧同malloc,必须要大块整个的还。但好处:
减少系统调用开销,malloc/free 内核交互成本高
规避内存碎片,切块统一管理
指针操作分配释放,速度远快原生接口
线程批量复用内存,并发性能更优
内存边界可控,便于做校验防护
究竟比
malloc快在哪里?其实说一套流程就能捋顺处这个优劣势:
两者都做分片分区上锁,底层上锁逻辑形式一致,但内存池提前定好统一块规格,靠头指针直接取用,无需筛选匹配。只锁住记录空闲开头的单个标记,仅改动两处地址指向,操作瞬间结束立刻开锁。上锁时间极短,几乎不会排队等待。
一次性从系统申请一大块内存,后续反复内部取用归还,极少再麻烦系统,省去沟通延迟。
不需要额外信息,只用地址指针串联内存块,几乎没有额外记录信息,读写负担极低。
malloc无固定尺寸标准,大家申请的内存大小乱七八糟,系统只能挨个翻找合适大小的空位,翻找过程消耗时间,只能逐个检索匹配对应大小内存块做挑选、拆分、拼接内存动作,全部做完才开锁。多个线程抢同片区域,排队等候时间长,拖慢速度。频繁找系统要内存(时不时就跟操作系统申请、退还内存,跨层级沟通本身就有延迟,次数多就变慢)
额外信息记录(每块内存都额外记下大小、是否在用等信息,读写更新这些内容,增加性能损耗)
创建多个固定大小池(4B、8B、16B、32B…),分配时 → 找最接近的池取块。这叫多尺寸内存池,不是通用
malloc。依然不支持任意尺寸,只支持预设尺寸。真正比malloc快的内存池,一定是固定大小 / 预设尺寸,不支持任意动态尺寸。能支持任意动态尺寸的内存池,本质就是重新造了一个malloc,不会更快。引出:
锁等待慢、找空间费时间、反复切拼内存、频繁找系统要内存,
关于单例模式:
一个类只有一个对象:
查看代码
class Pool{ private: Pool(){} public: static Pool& getIns(){ static Pool ins; /* 第一次调用函数:创建 ins 之后所有调用:不创建新对象,直接用第一次那个 程序结束前,这个对象永远活着 永远只存在这一个实例 如果不加static,每次调用函数,创建一个新对象,函数结束,对象立刻销毁,每次返回的都是新的、不同的对象 */ return ins;//返回类型写 Pool& 就代表返回引用,直接 return ins; 会自动返回对象的引用,不需要写 &ins } }; //调用方式,这么获取,次次都是同一个对象 Pool& p1 = Pool::getIns(); Pool& p2 = Pool::getIns(); // p1和p2指向同一个实例,无法造出第二个独立对象
static Pool ins;只会创建一次,终身就这一个对象,第一次调用getIns(),创建static Pool ins;,返回它的引用,第二次、第三次…… 以后任何调用,直接返回已经存在的那同一个ins,不会再创建新对象。构造函数是 private,外面写 Pool p; → 直接编译报错, 根本不允许你创建第二个对象。是私有构造 = 强制单例。全局 static:仅当前文件可见,全局唯一一份
类内 static:所有对象共享一份,不属于任何对象
而手写内存池里用的是:
static GlobalMemoryPool g_pool;、
inline void* pool_alloc() { return g_pool.alloc(); }、(这是一个全局快捷函数,它内部固定、写死、永远只调用上面那个g_pool)
inline void pool_free(void* p) { g_pool.free(p); },别人可以手动再写一个
GlobalMemoryPool pool2;造出第二个池,逻辑崩溃:通用全局内存池,强制单例,分配释放同源,多规格内存池需拆分专属接口,互不混用内存块。
说人话就是:
终极要点都是单例,那些非单例的就是多规格,必须写好隔离逻辑【从哪里出的,回到哪里】:
池 A → 接口 A alloc / A free
- 池 B → 接口 B alloc / B free
绝对不能混用!
为啥如此?
你内存池每次调用pool_alloc(),返回的 64 字节小内存块,每块就是一块内存void* p = pool_alloc(); // 这就是 ↓ 一块内存 // 64字节,属于内存池管理重点:每一块内存,只属于 1 个内存池!最致命的崩溃场景:
场景 1:正常情况(只有一个池 ✅)
// 全局唯一池 static GlobalMemoryPool g_pool; void* p = pool_alloc(); // 从 g_pool 分配 pool_free(p); // 还给 g_pool完全正确,不崩。
场景 2:有人乱创建 第二个池(🔥 必崩)
// 全局池 1 static GlobalMemoryPool g_pool; // 坏人创建 池2 GlobalMemoryPool pool2;现在分配:void* p = pool2.alloc(); // p 这块内存 → **属于 pool2**现在释放:pool_free(p);看函数绑定关系
inline void pool_free(void* p) { g_pool.free(p); }调用
pool_free,代码固定只会调用全局唯一的g_pool的释放方法,从pool2取出的内存块,强行挂载进g_pool空闲链表,即 p 属于 pool2,你却把它还给g_pool,g_pool收到一块不属于自己的内存 → 直接数据错乱。比如:
池 1 = 你的钱包
池 2 = 我的钱包
64 字节内存块 = 100 块钱
我从我的钱包拿出 100 块,你把这 100 块 放回你的钱包,你的钱包记账直接乱掉!
于是迭代版本2:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ private: GlobalMemoryPool() = default; public: static GlobalMemoryPool& getIns(){ static GlobalMemoryPool ins; return ins; } public: static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug // 把这一大块切成 1024 个小块,串成链表 for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; } /* 一次性 malloc 64KB(1024×64B) 把这一大块切成 1024 个小 64B 块 用链表把它们串起来 让 free_head_ 指向第一个块 这就是内存池的核心:预分配 + 链表管理。 */ public: void* alloc(){ std::lock_guard<std::mutex> lock(mtx_); // 加锁,线程安全 if (!free_head_) // 没有空闲块了 alloc_pool_chunk(); // 再预分配 1024 块 BlockNode* ret = free_head_; // 拿第一个空闲块 free_head_ = free_head_->next; // 头指针后移 return ret; // 返回给用户使用 /* 分配逻辑超级简单: 加锁 没空块 → 再申请一大块 把链表头节点取出来返回 头指针指向下一个块 可以看出这里内存池只改指针,malloc 要全局找空间、加锁、管理碎片,所以内存池快得多 */ } void free(void* ptr) { if (!ptr) return; std::lock_guard<std::mutex> lock(mtx_); BlockNode* node = (BlockNode*)ptr; node->next = free_head_; // 插回链表头部 free_head_ = node; // 新空闲头 = 刚释放的块 }/* 把块重新插回空闲链表头部,并没有真正还给系统! 这就是内存池高效的原因 —— 复用内存,不反复调用系统 free。*/ }; // 用了单例 getIns () → 全局 g_pool 彻底多余、必须删掉! inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc(); } inline void pool_free(void* p) { GlobalMemoryPool::getIns().free(p); } int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; sleep(8); printf("开始分配内存\n"); for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc();//这里还没用只是开了,100000 × 64B = 6,400,000 字节 ≈ 6.4MB std::this_thread::sleep_for(std::chrono::seconds(5));//是 C++ 的,装逼/规范写法,sleep() 是 Linux 系统函数(Windows 没有), printf("开始释放内存\n"); for (int i = 0; i < CNT; ++i) pool_free(arr[i]); std::this_thread::sleep_for(std::chrono::seconds(3)); printf("测试结束\n"); }实操:
![]()
刚开始:RES 很小,分配 10 万块后:RES 明显上涨(约 6.4MB),释放后:RES 不会下降!
Q:为什么释放了内存不还给系统?
A:因为内存池的设计思想:我先拿着,下次你还要用,我直接给你,不用再找系统要。这就是内存池高效、无碎片的原因。
具体来说
pool_free()只是把块插回空闲链表,没有调用free(chunk)释放malloc出来的大块内存。但就算free之前说过也不回落,C 库内存管理器会缓存空闲内存,free只是 内存还给 C 库缓存arena,不会立刻还给内核。误区:我一直以为底层不用brk了,但
malloc申请大块,glibc是Linux下标准C语言运行库,封装内存、IO等系统接口,他的内部小内存默认调用brk拓展堆空间,大块内存才走mmap,内存池没绕开brk/mmap,只是一次性批量调用系统接口拿一大片内存,只不过一次拿的比正常多。关于内存对齐:
CPU 读内存喜欢按 8 字节 / 16 字节 整齐地址读,不喜欢乱的地址。如果返回的块是奇数地址、非对齐地址,跨内存行读取,额外多一次访存操作,业务一用就低效,代码设置 BLOCK_SIZE = 64,是 8 字节对齐的。64 位 Linux 默认最大对齐是 8 字节,所以分配出去的所有内存地址天然满足对齐要求,不会出现未定义行为,也不会影响 CPU 访问效率。
如果没对齐咋搞?
首先科普掩码:用来筛选、抹掉数字部分位数的固定二进制数。以 8 对齐为例:8 二进制
1000,减 1 得 7=0111,这 7 就是掩码。用掩码按位取反后,清空低位实现对齐。科普即将用到的变量:
sz待对齐数值,
align_up向上内存对齐函数
static:类内全局共享,所有实例共用一份
constexpr:编译期确定值,运行时不可修改
size_t:无符号整数类型,专门表示内存大小
ALIGN_SIZE = 8:定义 8 字节内存对齐标准
const仅只读,编译不一定求值,constexpr强制编译期算出常量,内存尺寸、数组下标这类编译期取值场景,只能用constexpr必须编译期算出,因为运行零开销。
32 位常用 4 字节对齐,64 位主流 8 字节对齐。
把数字凑成8 的整倍数思路是:
① 8-1=7,任何数除以 8,余数最大只会是 7
② 数字 + 7,不足 8 倍数的都会往上进一档
③ 抹掉多出的零头,就得到最近的偏大 8 倍数
数字 65,65+7=72,刚好凑成 8 倍数。
数字 66,66+7=73,去掉零散尾数,得 72
具体怎么去掉零散尾数,用的就是上面提到的掩码,上代码:
static constexpr size_t ALIGN_SIZE = 8;ALIGN_SIZE = 8:内存按8 字节对齐static constexpr size_t align_up(size_t sz, size_t align) { return (sz + align - 1) & ~(align - 1); }函数作用:把数值sz向上对齐到align的整数倍
align - 1:得到掩码,8-1=7 → 二进制000...0111
~(align - 1):按位取反,掩码变成111...1000,这一步也是其实就是准备用&运算符来搞没尾数的
sz + align - 1:向上进位,避免刚好是整数倍时多算
&运算:清零低 3 位,强制对齐到 8 的倍数调用:static constexpr size_t BLOCK_SIZE = align_up(67, ALIGN_SIZE);继续优化:
这里虽然已经对齐,但可以增加更显眼的函数,
void* chunk = aligned_alloc(64, total_size);,C11 标准库函数,显式指定内存对齐字节数,你原来用 malloc 系统默认给你对齐,但不写出来,面试官看不到;改成 aligned_alloc 就是你主动告诉面试官:我懂内存对齐、我懂 CPU 访存规则、我写高性能代码。(aligned_alloc= 带内存对齐的malloc)继续优化:
因为单例全局只能有一个实例,如果允许拷贝 / 赋值,就会凭空造出第二个内存池,直接导致双重释放、链表错乱、程序崩溃,所以必须
delete禁用,这是大厂单例的安全底线,彻底禁止对象被复制、被赋值:// 🔥 大厂必写:单例禁拷贝 GlobalMemoryPool(const GlobalMemoryPool&) = delete;//是禁用拷贝构造函数,编译器看到 = 就知道:任何人尝试用「已有的内存池对象去创建新对象」,直接报错,不允许 GlobalMemoryPool& operator=(const GlobalMemoryPool&) = delete;//是禁用赋值运算符重载,意思是:任何人尝试把「一个内存池对象赋值给另一个内存池对象」,直接编译失败继续优化(其实是严重错误):
alloc函数错误:返回值直接返回了BlockNode*类型的ret,即内存块起始地址,该地址存放next指针,用户写入数据会覆盖指针,破坏链表。应该为: 返回节点地址 + 结构体大小,跳过
next指针return (char*)ret + sizeof(BlockNode); }
BlockNode地址是内存块最起始、存放next指针的地址。那对应自定义的
free也要改:BlockNode* node = (BlockNode*)((char*)ptr - sizeof(BlockNode));,因为alloc时往后跳 8 字节给用户,free时往前跳 8 字节找回节点,不跳回去,你就找不到链表节点,无法回收内存。那我有思考,既然创建销毁都改,麻烦,就直接都
main里用的时候 +8:arr[i] = (char*)pool_alloc() + 8;,alloc、free不用动。继续优化(这才是真正踩了无数坑的地方):
加析构:
1、进程退出系统会自动回收全部内存,程序跑完不写析构也不会内存泄漏,所以单进程跑完直接退出:析构可省略
2、但代码规范、复用、动态销毁实例、单元测试场景必须手写析构释放
3、内存池缓存的内存不会主动还给内核,不手动释放会常驻堆空间,程序长期运行、反复创建销毁池实例:析构必写,避免内存持续膨胀
这里遇到个问题!首先有两类
static规则不同1. 类内静态成员变量:仅声明,必须类外定义初始化
2. 函数内局部静态变量:首次执行自动初始化,无需外部赋值
而
static constexpr编译期就有值,不用初始化。这里我先想简单的看下到底想操作系统申请了多少次内存
可以
main里定义计数器,然后(写法一)查看代码
void alloc_pool_chunk(int &count) { char* chunk = (char*)aligned_alloc(64, total_size); count++; // 直接累加 printf("第%d次分配\n", count); }也可以类内搞,但必须
main外的全局初始化,因为属于整个类,所有对象共享一份。C++ 规定:声明和定义必须分开!(写法二)(我用的这个)//类里 private: static int alloc_count; // 声明静态计数器。 非单例下,普通成员每个对象独有,互不共享;static成员全类对象共用 // static 变量属于类,不属于对象,更不属于函数!不能在 main 里初始化!必须在类外面、全局区域初始化。 //main之前全局写: int GlobalMemoryPool::alloc_count = 0;或者
static const int alloc_count = 0;。(写法三)或者类内的函数局部变量(不用外部赋值,因为它是局部变量)(写法四):
void alloc_pool_chunk() { // 就放这里!!!函数内部!静态局部变量! static int alloc_count = 0; // 然后正常用 alloc_count++; printf("第 %d 次分配\n", alloc_count); // ... 其他代码不变 }我发现总共申请98次,因为
const int CNT = 100000; for (int i = 0; i < CNT; ++i) arr[i] = pool_alloc();一次 pool_alloc 拿 1 块 64B。
一次 alloc_pool_chunk 给 1024 块
所以:100000 / 1024 = 97.656,意味着:你会调用
aligned_alloc一共 98 次!所以必须
free98次!但根本不用循环 98 次,只需要一个 while 循环,从头拆到尾就行,但还要注意,但关于
free函数:名字一样,归属不一样:
库函数
free:全局自带函数,作用是把内存还给操作系统。类里的
free:属于自定义类的成员方法,只做链表回收,不归还系统内存。调用写法区分:
写
free(变量),直接调用系统库释放内存。
对象.free(变量),执行自己写的回收逻辑,不走库函数。注意:操作系统记录的是单次分配的整块起始地址与占用大小,内部只是逻辑切割,物理仍是连续一块内存,依据首地址单次释放就能回收全部空间,即free一次不是释放一块的64字节,而是起初申请的 1024 个块,但我们想要的是释放回池子里,只是放这一个,所以必须自己写free函数来做回收一次的功能。
pool_free()里用的就是自定义的free只回收 1 个小块,不还给系统;系统库函数
free()只释放当初malloc/aligned_alloc申请的那一大整块(1024 块);但豆包给的是(体现了我和AI豆包的,通过意志力死磕、追问、辱骂、质疑、反复实践思考、用自己独创的提示词做约束、互相相启发、合作、配合思考,我自己想不到这么写,但豆包给完“垃圾代码”我一个字一个字死磕追问,我就可以判断哪里有漏洞,如果语法思路学完,就不用一个字一个字死磕追问):
查看代码
//哎!!这个代码豆包给的!!可是这他妈每次free是1024个小块,没小块64字节,他妈的豆包这个代码实现记录的next早都被释放了 ~GlobalMemoryPool() { BlockNode* current = free_head_;//这里纯错误,他存的是空闲,而已借出的小块脱离free_head_链表,空闲链表只存未使用节点,且没大块链表,找不到第一个点,无法遍历所有内存,析构要从第一个开始释放 while (current != nullptr) { BlockNode* next = current->next; free(current); // 释放每一块 current = next; } free_head_ = nullptr; printf("=== 内存池已释放所有内存(%d 次分配全部回收)===\n", alloc_count); }其实这里有个插曲,我思考是,既然小块的
next不行那我直接存第 1024 个块的节点的next不就可以了吗,因为这个next一定是指向了下一个不连续malloc得来的 1024 小块池啊?其实还是那个问题!此文搜 “关键问题是,你根本不知道自己用了多少呀?”,你如果有释放,一定打乱小块池内部有序连续地址!系统的free却不怕打乱!因为是物理层面的。(这里简称数 1024 个问题)应该搞个大块的链表指针:
第一次申请 3 块:a b c,内存池 只记录起始地址 a,链表变成:
a → b → c → nullptr都用完了再次申请第二次申请 3 块:d e f,内存池 只记录起始地址 d,新链表变成:
d → e → f -> nullptr释放时(pool_free):你释放谁,谁就插回链表头部准备复用,比如你释放 a,链表变成:
a → d → e → f → nullptr,最后析构释放:只释放两个大起始地址:
a和d
free (a) → 一次性回收
a+b+c整块free (d) → 一次性回收
d+e+f整块具体实现:
前言思考:
不用动态数组用链表理由:
链表想申请多少个大块都能串起来,永远不用扩容,这叫零开销,内存池追求的就是这个!
仅顺序遍历,任意位置增删只需改指针,效率更高
动态数组满了必须扩容、拷贝、释放旧数组(又慢又麻烦),
随机访问快,增删末尾简单,中间删改移位耗时。
内存池场景频繁新增大块、仅收尾遍历释放,链表适配度更高。
梳理已有代码逻辑:
目前是对象里存指针,指向
BlockNode结构体类型,这个结构体就是接下来申请大块切1024个小块的这个小块的内存格式,每个小块都是前8个字节存next,后56个存用户数据。比如开1024个大块,每个块都是 64 字节(设计成前 8 个字节存
next指针,后面字节存数据),main里创建对象:单例管理,内部存空闲块头指针[管理单例] ↓只持有指针,指向 小块0 → 小块1 → 小块2 → 小块3 → ... 每个小块:[指针域(存下一块地址)] + [64字节可用内存区]初始化:向系统申请一整片连续大内存,按 64 字节规格切分成若干标准小块,
// 🔥 就是这里:刚申请完一大块内存,立刻循环切割所有小块! for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { BlockNode* curr = (BlockNode*)(chunk + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(chunk + (i + 1) * BLOCK_SIZE); curr->next = next; }初始化 = 申请大内存 + 立即全部切割 + 串成空闲链表。分配只是摘链,回收只是挂链。
先插入个语法细节:假设有
void* chunk = aligned_alloc(64, 64 * 1024);、BigChunk* new_big = (BigChunk*)chunk;,我理解内存被new_big 接管了,那chunk这个变量啥时候销毁?答案是chunk只是个临时指针变量,存的是地址数字,不占堆内存,函数结束自动销毁,不需要释放,不会泄漏。new_big 仅保存内存地址,堆内存不会随函数结束释放,程序退出系统才回收。整个这代码就是
main里做的事就是搞 CNT 个 64 字节大小的内存块。
main里流程:
for (int i = 0; i < CNT; ++i) arr[i] = (char*)pool_alloc() + 8;这里调用:inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc();,进而调用getIns(),getIns()一跑,就会执行static GlobalMemoryPool ins;对象在这里诞生!每次调用
pool_alloc (),都会先调用getIns(),但对象只会创建 1 次,第一次getIns ()创建对象再调用alloc(),之后所有次getIns()直接返回已有的对象再调用alloc()。然后基于这些现有的逻辑,豆包说要增加存大链表的结构体,开胃思路(依旧有错误):
更新:
代码逻辑落地会形成
big_chunk_head_和free_head_指向同一块 64KB 内存的同一个起始地址的指向关系。
free_head_把这块地址当BlockNode,写next指针
big_chunk_head_把这块地址当BigChunk,写next指针同一块 8 字节内存,不能同时存两个指针!会覆盖!会炸!会断链!
以下这部分关于:内存分区、单例存储位置、指针作用、大小块职责、性能原理、static 特性这些认知,全部无误。
但漏洞落在落地实现逻辑:
你计划将 64KB 大块首地址既赋值给
free_head_作为小块链表起点,又强转为BigChunk存入大块链表节点,物理首地址复用,8 字节指针区域会被两套链表读写篡改,不存在额外独立空间存放大块链表节点,这是隐患根源。对应你提出的疑问:小块已占满整块内存空间,无富余区域存放大块链表信息,原生写法无法同时承载两条互不干扰链表。
以下原文:
BlockNode 管 64 字节小块,BigChunk 管 64KB 大块,两个链表,
搞个
BigChunk专门记录每次向系统申请的 64KB 大块,即struct BigChunk { BigChunk* next; };8字节指针,专门用来把所有64KB大块串成链表,方便最后一次性释放。
BigChunk* big_chunk_head_ = nullptr;,big_chunk_head_:链表头,把所有大块串起来,也只有它能让你最后一次性释放所有系统内存。
static问题:
struct BigChunk加static是为了所有类通用,不新建第二个,但我代码是单例就可有可无了。
BigChunk* big_chunk_head_ = nullptr;没有 static 是因为他是指向必须释放的内存头,指向系统分配的大块内存(全部指向堆内存,malloc/aligned_alloc从操作系统堆区分配空间,不在栈、不在全局静态区),必须让他属于对象,随着单例一起释放,不然会泄漏。完美闭环!!通透!!Q:但我的质疑是,正常申请 1024 个块,每个块前 8 个存
next,后几个字节存数据,也即是说申请的完全利用起来了不多也不少!那问题就在于,小链用了,你大块的链表用啥存? (其实这么追问思考,我才刚理解内存池了细节)A:这个问题马上后面说,这里先不解释,先科普语法等基础概念。
之前误以为用了内存池,就都是堆开辟东西,其实错了依旧会有其他虚拟地址的东西,首先,给用户用的区别于malloc的内存池函数,是在堆上开内存放数据,而内存池依旧有自己的成员变量,存在单例对象里(全局静态区,不是栈也不是堆),我一直误以为内存池全程就只有内存池开的1024个大块呢,
其实是一个
内存池.cpp文件运行来处理比如 3 个任务,内存池会造个管理内存的工具对象,单例对象存在数据段的静态存储区(全局/静态区),既不在栈也不在堆,它不存用户数据,它原本是存一个指向BlockNode类型的指针(free_head_记录空闲小块链表在堆),现在增加的是指向BigChunk类型的指针(big_chunk_head_记录所有从系统申请的大块在堆)。你用malloc还是内存池都在堆搞数据,差别就是申请一大块,后续不用总跟内核交互,提高性能。所以不要以为只有申请的堆数据空间,本身这个堆是由处在静态虚拟内存区的对象所指向。空闲链表头指针存于内存池对象成员中!
那这里单例对象,就是只存了两个指针:
big_chunk_head_、free_head_初始都指向空,比如free_head是空闲链表头指针,也就是说他就是个箭头,就是个指针,初始null,挂上一个第一个块,就first_heat=第一个块, 挂第二个就free_head->next=第二个块,但注意所谓的“当前头”不是对象里的东西了,对象只持有一个指针,指向的就是malloc开的大块,这些大块都是分成了同级别的小块,别以为对象里还有个什么块头、表头啥的,对象里只有指针(即箭头),在开辟1024大块的时候才指向具体块(BlockNode类型)。纠正自己的误区(后来发现也就是错的!!~~~~(>_<)~~~~):
big_chunk_head_、free_head_都指向 A,那 A 必然指向小块 B,如果第一次开 ABC 三小块,第二个次开 DEF 上小块,大块链表应该指向的是A->D,小的应该是A->B->C->D->E->F,可是堆里数据块又不会分身术,他到底指向谁啊!!本身只能开一个链表,咋可能开两个链表啊,因为我理解这里1024个小块就充当了链表,用的就是实打实的申请的堆小块,根本不可能随便像栈上写结构体那样搞next块啊,所以没有地方在搞个链表啊。其实豆包没错,我一直以为豆包在跟我胡扯,说什么分时复用,覆盖即可,感觉是误人子弟,但其实豆包没错,
首先我想的就有问题,都已经说了两种结构体,可我还按照之前的逻辑,当作1024个小块都是
BlockNode类型的,所以就进误区,觉得big_chunk_head_、free_head_都指向BlockNode类型的 A 块,那 A 块的next必然是 B,又存大块的 D 呢?其实人家不可能指向同一个
BlockNode类型的 A 块,都说了free_head_指向BlockNode类型的 A,big_chunk_head_应该指向的是另一个每个块都是BigChunk类型的链表,只不过由于我觉得无法再次搞个链表所以就跟豆包打起来了,豆包就服软说自己错了,但其实都豆包根本没错!我没考虑到强制类型转换这件事!!客观存在两个独立的结构体:
你用来切小块的结构体
FreeNode,big_chunk_head_= A(大块头,永远记着第一个大块首地址)你用来串大块链表的结构体
BigChunk,free_head_= A(小块空闲头)这两个是完全不同的类型定义,内存布局不一样,用途不一样。你申请的是一整块物理内存(64KB),这块内存本身没有结构体,是你人为赋予它类型:
你用
(BigChunk*)chunk把这块内存的头部解释为BigChunk,用来串大块链表你把整块内存剩余部分解释为
FreeNode数组,切成小块给分配器用总共俩链表:
第一个链表:
big_chunk_head_驱动的大块链表,节点类型是BigChunk,每个节点对应一整个 64KB 物理内存,作用是记录所有向系统申请的大块内存,方便析构时统一释放。第二个链表:你内存池原本的空闲小块链表,节点类型是
FreeNode,每个节点是从 64KB 里切出来的小内存块,作用是给用户分配、回收空闲的小块内存。物理上只有一块内存,但代码层面做了两次不同类型的强转:一次转成
BigChunk做链表管理、一次转成FreeNode做内存切块。全错,这是内存践踏,不存在 “分时复用”!不存在 “类型转换解决冲突”!
操.你妈又浪费一天时间,吃饭走路拉屎都在跟豆包对骂思考这事。结果这玩意还他妈是错的!其实追问豆包的劣势是效率低,反复没个正确的东西,需要自己追问思考摸索,但好处是,就像刷算法题一样,想破脑袋,有收获,深刻,且我跟豆包激烈争辩,堪比踩坑无数,且所有写法的利弊都了如指掌)
真正正确思路:
之前的错误版:
// 结构体1 struct BlockNode { BlockNode* next; }; // 结构体2 struct BigChunk { BigChunk* next; }; char* chunk = 申请64KB; // 你把【同一块内存开头】 // 既当成 BigChunk BigChunk* big = (BigChunk*)chunk; big->next = ...; // 又当成 BlockNode BlockNode* free = (BlockNode*)chunk; free->next = ...;同一块 8 字节内存,被你同时当成两个指针用!写一个,另一个直接被覆盖!内存池一动链表 → 大块链表直接报废!
记住,内存布局别覆盖,之前错在误以为类型转换就没事了,其实依旧有事,依旧不可以覆盖,之前把小块链表,强转大块类型然后存大块的链表地址!
模拟 Nginx 的结构(只有这一种,狗逼豆包真的浪费了我 90% 的时间,说居然还有种方法是每次malloc小块池的同时malloc一个小块来当小块池的链表索引):
此时才发现豆包一开始没给错,一开始豆包给的就是:
加成员:
BigChunk链表struct BigChunk { BigChunk* next; }; BigChunk* big_chunk_head_ = nullptr;
BigChunk:专门记录每次向系统申请的 64KB 大块
big_chunk_head_:链表头,把所有大块串起来作用:只有它能让你最后一次性释放所有系统内存
alloc_pool_chunk开头加代码BigChunk* new_big = (BigChunk*)chunk; new_big->next = big_chunk_head_; big_chunk_head_ = new_big;
chunk是 aligned_alloc 申请的 64KB 大块首地址
(BigChunk*)chunk:把这块大内存开头当成BigChunk节点(只用前 8 字节存指针)
new_big->next = big_chunk_head_:把新大块串到链表前面
big_chunk_head_ = new_big:链表头指向新大块,等待下次别人串他前面内存布局:
假设 1 个大块 = 固定 3 个 64 字节块→ 必须扣 1 个当表头,只剩 2 个给用户
第一次分配:大块 1 → 用户块 AB 注意:0x100 到 0x140 相差 64 字节
大块1 起始:0x100 [0x100] 表头块(BigChunk)next = NULL [0x140] A(用户) [0x180] B(用户) 大块链(准确说是小块池的链表):big_chunk_head_ → 0x100 小块链(准确说是小块池里分割小块,连接小块的):free_list → A → B第二次分配:大块 2 → 用户块 CD大块2 起始:0x200 [0x200] 表头块(BigChunk)next = 0x100 [0x240] C(用户) [0x280] D(用户) 大块链:big_chunk_head_ → 0x200 → 0x100 小块链:free_list → C → D → A → B然后从第二块开始给用户使用,空闲链表从第二个块开始(跳过第一个管理块):
char* free_start = chunk + BLOCK_SIZE;真正能给用户用的块数量 = 1024 - 1:
size_t usable_blocks = POOL_CAPACITY - 1;,然后把for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) {改成切链表只切可用的 1023 块:for (size_t i = 0; i < usable_blocks - 1; ++i) { BlockNode* curr = (BlockNode*)(free_start + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(free_start + (i + 1) * BLOCK_SIZE); curr->next = next; }然后后两句的
// 最后一块指向原来的空闲链表头 BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 free_head_ = (BlockNode*)chunk; //修改为: BlockNode* last = (BlockNode*)(free_start + (usable_blocks - 1) * BLOCK_SIZE); last->next = free_head_; free_head_ = (BlockNode*)free_start;析构变成:
查看代码
~GlobalMemoryPool() { // 遍历所有大块,全部释放 BigChunk* curr = big_chunk_head_; while (curr) { BigChunk* next = curr->next; free(curr); curr = next; } big_chunk_head_ = nullptr; free_head_ = nullptr; printf("=== 内存池已释放所有内存(%d 次分配全部回收)===\n", alloc_count); }
从链表头开始遍历所有 64KB 大块
free(curr):真正释放系统内存一个不漏,全部还给操作系统
申请下来的1024个块的第一个块用来存大块链表指针,仅用前8字节存指针,剩余56字节空间不使用,属于内存池固定管理开销。
Q:既然你说新建个!可我看新建的也是一样的结构前8,那为啥不直接用小块的结构体?也就是直接只有一种类型结构体,然后1024个小块的第一个当作大块的链表格式,后面的当作小块的链表格式。
A:此文搜“完美闭环!!通透!! ”是同一个内存管理逻辑,两个链表结构体虽然结构相同,但职责完全隔离,生命周期完全不同,BlockNode(小块)随时会被分配给用户、被回收、在链表上跳来跳,它是不稳定的!BigChunk(大块头)一旦申请,直到整个内存池销毁才释放,它是永久固定的!
万一这个块被分配给用户了!用户改写了前 8 字节指针大块链表直接断裂!析构时无法释放内存!内存泄漏 + 崩溃!虽然代码约束永不分给用户,但还是工程思维比较好,严谨点!
于是内存池迭代版本3代码:
查看代码
#include <cstddef> #include <cstdlib> #include <cstdio> #include <cassert> #include <mutex> #include <thread> #include <chrono> #include<unistd.h> class GlobalMemoryPool{ private: GlobalMemoryPool() = default; // 大厂必写:单例禁拷贝 GlobalMemoryPool(const GlobalMemoryPool&) = delete; GlobalMemoryPool& operator=(const GlobalMemoryPool&) = delete; public: static GlobalMemoryPool& getIns(){ static GlobalMemoryPool ins; /* 两类static规则不同 1. 类内静态成员变量:仅声明,必须类外定义初始化 2. 函数内局部静态变量:首次执行自动初始化,无需外部赋值 */ return ins; } // 析构函数 ~GlobalMemoryPool() { // 遍历所有大块,全部释放 BigChunk* curr = big_chunk_head_; while (curr) { BigChunk* next = curr->next; free(curr); curr = next; } big_chunk_head_ = nullptr; free_head_ = nullptr; printf("=== 内存池已释放所有内存(%d 次分配全部回收)===\n", alloc_count); } public: //编译期就有值,不用初始化 static constexpr size_t BLOCK_SIZE = 64;//小块 = 内存池给你分配出去的最小单位内存,内存池一次性分配出来的每一小块固定是 64 字节 static constexpr size_t POOL_CAPACITY = 1024;// 内存池一次批量申请 1024 个 64 字节的小块 // 整体:每次从池子里拿64 字节,池子不够用时,一次性申请 1024 个 64B = 64KB 大内存块 private: static int alloc_count; // 声明静态计数器。 非单例下,普通成员每个对象独有,互不共享;static成员全类对象共用 // static 变量属于类,不属于对象,更不属于函数!不能在 main 里初始化!必须在类外面、全局区域初始化。 private: struct BlockNode {//这个结构体本身大小 = 8 字节 BlockNode* next; /*把所有空闲的内存块串成一个单向链表,每个块只用前 8 字节(指针大小)存 “下一个块在哪”,剩下的空间给用户使用 [ next指针 | 可用内存空间 ] 8字节 56字节 总共 64 字节 */ }; BlockNode* free_head_ = nullptr; // 空闲链表头,永远指向第一个可用的空闲块 // 为了析构而增加的 struct BigChunk { BigChunk* next; }; BigChunk* big_chunk_head_ = nullptr; std::mutex mtx_; // 锁,多线程安全,防止多线程同时操作链表导致崩溃 void alloc_pool_chunk() { size_t total_size = POOL_CAPACITY * BLOCK_SIZE; // char* chunk = (char*)malloc(total_size); // 一次性 malloc 一大块 // 显式对齐分配(专业) char* chunk = (char*)aligned_alloc(64, total_size); assert(chunk != nullptr);// 如果系统 malloc 分配内存失败(返回空),程序直接崩溃报错,用于检查内存申请是否成功。发现内存分配失败,立刻卡死报错,方便定位 bug alloc_count++; // 计数 +1 printf("=== 第 %d 次调用 aligned_alloc 分配大块 ===\n", alloc_count); // 打印 // 总共大概98次 // 为了析构而加的 BigChunk* new_big = (BigChunk*)chunk; new_big->next = big_chunk_head_; big_chunk_head_ = new_big; char* free_start = chunk + BLOCK_SIZE;//为了析构而增加的 // 把这一大块切成 1024 个小块,串成链表 size_t usable_blocks = POOL_CAPACITY - 1; // for (size_t i = 0; i < POOL_CAPACITY - 1; ++i) { for (size_t i = 0; i < usable_blocks - 1; ++i) {//从第二块开始用,能用的只有1023块,为析构而修改 BlockNode* curr = (BlockNode*)(free_start + i * BLOCK_SIZE); BlockNode* next = (BlockNode*)(free_start + (i + 1) * BLOCK_SIZE); curr->next = next; /* curr 指向当前块的开头 curr->next 就是修改当前块前 8 字节,存下一个块的地址 块剩下的 64 - 8 = 56 字节 完全不动,留给用户用 */ } // 最后一块指向原来的空闲链表头 // BlockNode* last = (BlockNode*)(chunk + (POOL_CAPACITY - 1) * BLOCK_SIZE); BlockNode* last = (BlockNode*)(free_start + (usable_blocks - 1) * BLOCK_SIZE);//为析构而修改 last->next = free_head_; // 新的空闲头 = 刚分配的大块起始地址 // free_head_ = (BlockNode*)chunk; free_head_ = (BlockNode*)free_start;//为了析构而修改 } /* 一次性 malloc 64KB(1024×64B) 把这一大块切成 1024 个小 64B 块 用链表把它们串起来 让 free_head_ 指向第一个块 这就是内存池的核心:预分配 + 链表管理。 */ public: void* alloc(){ std::lock_guard<std::mutex> lock(mtx_); // 加锁,线程安全 if (!free_head_) // 没有空闲块了 alloc_pool_chunk(); // 再预分配 1024 块 BlockNode* ret = free_head_; // 拿第一个空闲块 free_head_ = free_head_->next; // 头指针后移 return ret; // 返回给用户使用 /* 分配逻辑超级简单: 加锁 没空块 → 再申请一大块 把链表头节点取出来返回 头指针指向下一个块 可以看出这里内存池只改指针,malloc 要全局找空间、加锁、管理碎片,所以内存池快得多 */ } void free(void* ptr) { if (!ptr) return; std::lock_guard<std::mutex> lock(mtx_); BlockNode* node = (BlockNode*)ptr; node->next = free_head_; // 插回链表头部 free_head_ = node; // 新空闲头 = 刚释放的块 }/* 把块重新插回空闲链表头部,并没有真正还给系统! 这就是内存池高效的原因 —— 复用内存,不反复调用系统 free。*/ }; // 用了单例 getIns () → 全局 g_pool 彻底多余、必须删掉! inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc(); } inline void pool_free(void* p) { GlobalMemoryPool::getIns().free(p); } int GlobalMemoryPool::alloc_count = 0; int main(){ printf("=== 观察htop RES数值变化 ===\n"); const int CNT = 100000; void* arr[CNT] = {nullptr}; // sleep(8); printf("开始分配内存\n"); // 相当于没写业务,就一个空跑,索要10w个64B小块,立马归还 for (int i = 0; i < CNT; ++i) // arr[i] = pool_alloc();//这里还没用只是开了,100000 × 64B = 6,400,000 字节 ≈ 6.4MB arr[i] = (char*)pool_alloc() + 8;// 改用这个, 需要+8。 这里调用: inline void* pool_alloc() { return GlobalMemoryPool::getIns().alloc(); 这里调用了 getIns() ,getIns() 一跑,就会执行 static GlobalMemoryPool ins; 对象在这里诞生! 每次调用 pool_alloc (),都会先调用 getIns (),但对象只会创建 1 次,第一次: getIns () 创建对象 → 再调用 alloc (),之后所有次: getIns () 直接返回已有的对象 → 再调用 alloc () // std::this_thread::sleep_for(std::chrono::seconds(5));//是 C++ 的,装逼/规范写法,sleep() 是 Linux 系统函数(Windows 没有), printf("开始释放内存\n"); for (int i = 0; i < CNT; ++i) pool_free(arr[i]); // std::this_thread::sleep_for(std::chrono::seconds(3)); printf("测试结束\n"); }思考 & 缺陷:
但既是单一规格,又此文搜“我自己设计的迷你版池子的弊端”无法申请64字节之外的,单个切割的池子每个都有个
next设计很不好,所以后续引入 Nginx 的设计思路,再阅读学习代码随想录的内存池代码。先回想捋顺:
豆包起初给的这个一点问题没有,只不过我没理解内存布局,所以就辱骂豆包,当时由于是回到家躺床上跟豆包语音激烈讨论的,脑子已经完全的陷进误区了,误以为申请的1024个块就是用来充当一个链表,然后无法搞第二个链表,豆包却一直在说是俩独立链表,其实豆包说的没任何问题,被我一直辱骂后豆包才被“污染”,给出所谓的覆盖一事,然后又引出多种写法混在一起,其实一切都是我没理解,我的错。
继续说上面的写法也可以搞成一个结构体里俩指针。好至此实现了大块链表的链接,也就可以正确析构了,进一步说点事,先引入个科普:
关于 Nginx:
实际就是:用户 → Nginx → 你写的 C++ HTTP 服务(多线程 /epoll)
Nginx 是 Linux 下高性能网络服务程序。它用多进程 + epoll做高并发。内部用内存池管理每个连接 / 请求的内存,提升性能。
之前手写的http服务器,也是web服务器也叫Web Server服务器
Web 本义:指万维网,基于 HTTP/HTTPS 协议通信。你用
epoll监听端口、处理 HTTP 报文,只要走 HTTP 协议对外提供服务,就是 Web 服务器 / HTTP 服务器,名称等价。客户端不限 Chrome:curl、Postman、其他程序发 HTTP 请求,它依旧是 Web 服务器。
只有不用 HTTP 协议(如自定义 TCP 协议),才不属于 Web 服务器。
关于正向代理 vs 反向代理:
客户端请求不直接访问后端服务,先发送到Nginx,由Nginx代为转发请求、接收响应再回传给客户端,这个中转行为就是反向代理。 正向代理面向客户端,替客户端访问外网;反向代理面向服务端,替服务端接收外部请求。
反向代理(自动,不用改浏览器):你输入
www.xxx.com,浏览器自动走直连域名对应的 Nginx,Nginx 再转给内部业务服务,你全程无操作不知情。目标是后端业务服务,部署在服务端侧,做流量转发、负载均衡,是你工作、面试的常见内容。站点所有者架起 Nginx。你正常输网址访问,你没做任何设置。你想找的是网站业务服务,后端真实程序被藏起来,你只见到 Nginx。例:逛电商网站,背后多个业务服务都被 Nginx 挡住。正向代理(手动,必须改浏览器):你先在浏览器填写代理地址和端口,再打开任意外网网站,请求才会先走代理服务器。目标是公网网站,多为客户端侧使用,企业统一上网、匿名访问、FQ都属于这类,C++ 服务端开发基本不接触。是你自己修改浏览器配置,指定走代理。你想访问外网站点,你的真实地址被藏起来,网站只看到代理。例:电脑设代理上网、FQ,都是这种。
Nginx 自己为啥不搞非要转发?
Nginx 擅长的只有 3 件事:读硬盘文件(图片、HTML、JS、CSS)、接收请求、转发请求、高并发扛流量
Nginx 不会干的事:算你账户里有多少钱、查你的用户名、处理登录、处理订单、处理游戏逻辑、处理聊天消息、处理视频流、处理复杂业务,这些复杂业务,必须由 你写的 C++ 服务 / Java 服务 来干!所以它必须转发给 8080 端口:你写的 C++ 服务→ 这个服务才会查库、计算、返回结果
Nginx 作为 Web 服务器(自身返回资源):
Nginx 是个【软件】!就像:
微信 = 聊天软件
浏览器 = 上网软件
Nginx = 监听端口、处理请求的软件
你必须先启动 Nginx 这个软件,它才会出现!
你必须【配置】Nginx!
你要写一个 nginx.conf 配置文件!我给你看最简单的配置,小白也能看懂:server { listen 80; #:listen 80 → 监听 80 端口 root /home/ubuntu/html; #:root /home/ubuntu/html → 文件存在这个文件夹里 }写完配置 → 启动 NginxNginx 就活了!
你在浏览器输入地址
http://192.168.1.100/pic.jpg发生了什么:
浏览器 → 访问 80 端口
Nginx 正在监听 80 端口 → 收到请求
Nginx 看配置:root 是
/home/ubuntu/htmlNginx 去硬盘找:
/home/ubuntu/html/pic.jpg找到 → 直接发给浏览器
Nginx 作为转发:
你必须【配置】Nginx!你要写一个 nginx.conf 配置文件!
server { listen 80; # 监听 80 端口(1号门) location /getUserName { proxy_pass http://127.0.0.1:8080; # 这句话意思: # 遇到 /getUserName 请求 # 转发给 8080 端口(2号门) } }写完配置 → 启动 Nginx,Nginx 就站在 80 门看门了!你还要启动你自己写的服务器!你写的服务器 → 站在 8080 门看门!
你在浏览器输入地址:
浏览器 → 访问 80 端口(1 号门)
Nginx 正在监听 80 端口 → 收到请求
Nginx 看配置:哦,这个要转发给 8080
Nginx 把请求带到 8080 端口
你写的服务器在 8080 看门 → 收到请求
你写的服务器处理业务
你写的服务器把结果发给 Nginx
Nginx 再把结果发给浏览器
Nginx 为啥要内存池?
因为 Nginx 要处理几百万请求!每来一个请求:要创建结构体、要存头信息、要存包体、要临时缓冲区、要各种小对象。
如果每个都
malloc/free太慢、内存碎片爆炸、高并发直接卡死,所以 Nginx 用内存池解决:一次申请,批量释放,不碎片,不卡顿。Nginx 内存池 和 网络连接 怎么结合?一个连接 = 一个内存池!流程:
客户端连上来
Nginx 创建一个内存池
这次连接需要的所有内存:包头、包体、临时 buffer、各种小对象,全部从这个内存池里拿!
- 连接断开直接销毁整个内存池!不用一个个 free!一秒释放所有内存!
我手写内存池跟Nginx有JB关系?
Nginx 内存池 = 管门外的事(转发、接收、协议)
接收数据、解析 HTTP 头、转发请求、发回响应,这些临时数据、临时结构→ 用 Nginx 自己的内存池,它只管转发,不管业务!
你的 C++ 服务内存池管什么(门内业务、计算、逻辑)
Nginx 把请求转发给你(8080),你要干:查数据库、计算逻辑、处理订单、拼接返回数据、业务对象、缓存、缓冲区,这些真正做事的内存,用 你自己写的内存池
突然想起之前写的线程池:
门外 Nginx:
内存池:分配转发数据内存
线程池:处理网络连接、转发
只管收发、转发,不干活
门内 你的 C++ 服务:
内存池:分配业务数据内存
线程池:处理业务逻辑
- 真正计算、处理、干活
科普结束,说 Nginx 是咋样?
其实目前这种写法严格来说既不像 Nginx 也不像 tcmalloc,只是自定义的手写定长而已,但更好的搞清楚了所有涉及到内存池的底层逻辑。
目前我自己手写的版本,这种直接第一块当大块链表,是因为就只有这一个大块,或者严格具体说我没考虑用户要大块(大对象)的事情,所以不需要大对象析构,直接搞这无数个申请的、用于分割小块的,主块大块即可。
而真正的 Nginx 里的内存池叫
ngx_pool,核心类型是ngx_pool_t,一次
malloc后,手写是分 1024 块,这里不提前分割,保留一整块,如果用户想要的块小于内置max,视为小块(小对象),内存池先把申请的整个大块比如 4096 字节,前 40 个字节当作头部存储管理信息(ngx_pool_data_t),后面 4056 字节用于给用户分割,用多少就从空闲的 4056 字节里线性切割多少,这个貌似被很多教程说成单规格或者叫线性分割,而如果用户申请的大小>
max,则视为大块(ngx_pool_large_t),也就是大对象,直接单独执行malloc(size)得到一块独立大内存,不在池子里,然后注意从刚刚的 4056 空闲里分割一块出来,作为ngx_pool_large_t节点,相当于大块链表。struct ngx_pool_large_s { ngx_pool_large_t *next; // 串成链表 void *alloc; // 指向刚才 malloc 的大块 };然后头插法挂到 pool->large 链表:large->next = pool->large; pool->large = large;池子头ngx_pool_t里有个指针:ngx_pool_large_t *large; // 大块链表头所有大块管理节点,靠
next连成一串:pool->large → large1 → large2 → large3 → NULL ↓ ↓ ↓ malloc1 malloc2 malloc3要遍历 / 释放时:
for (large = pool->large; large; large = large->next) { if (large->alloc) free(large->alloc); }挺简单,但我注意到这里大块的结构体为啥是俩指针,
struct ngx_pool_large_s { ngx_pool_large_t *next; // ① 链表指针 void *alloc;// ② 指向真正的大块内存 };直接用next就行啊,豆包说“你设计的结构体(致命错误)”:
// 你想的:只用 next,又串节点,又指数据 struct ngx_pool_large_s { ngx_pool_large_t *next; };
malloc 大块数据 →
void *data = malloc(1024*1024);从小块池抠节点 →
node = 小块内存你想:
node->next = data;// ❌ 直接类型错误!崩溃点 1:类型不兼容(这里狗逼豆包满嘴瞎话!!)
node->next是ngx_pool_large_t*
data是void*崩溃点 2:链表直接断了
pool->large → node1 → data(不是节点) → 崩溃
next必须指向下一个节点,你让它指数据,链表直接炸,再也找不到 node2!崩溃点 3:永远无法释放
你遍历链表:for (node = pool->large; node; node = node->next)第一次循环拿到 node1,第二次
node = node->next→ 拿到的是数据地址,不是节点!没有 next 字段!直接越界、崩溃、内存泄漏!除非把「大块数据的头部」强行拿来当链表节点应该:
① 大块节点
struct ngx_pool_large_s { ngx_pool_large_t *next; // 链表:指向下一个大块节点 void *alloc; // 指针:指向真正 malloc 的大块数据 };每个大块内存,都对应一个此结构体节点来管理。
② 内存池数据头(小块用)
struct ngx_pool_data_t { u_char *last; // 小块分配起始位置,头部结构体~last = 已占用;last ~ end = 空闲 u_char *end; // 小块内存结束位置 ngx_pool_t *next; // 下一个小块内存池(不够就追加) ngx_uint_t failed; };③ 内存池主结构(整个池的头)
ngx_pool_cleanup_t *cleanup;叫外部资源清理链表头 ,内存池只管
堆内存(malloc 出来的缓冲区、数组、结构体)—— 用 large / 小块链表管理;
外部资源(不是内存,操作系统内核资源):打开的文件 fd、socket 句柄、第三方库创建的对象、共享内存句柄。
- 程序自己 malloc 出来的缓冲区、结构体、数组,叫内存资源,是进程自身用户态内存,free 就能回收;
fd、socket、文件、信号量这些存在操作系统内核里、由内核管理的东西,业内统一叫外部资源。
这类资源不能靠 free 释放,必须调用 close ()、shutdown ()、第三方销毁函数,单纯释放内存池内存会造成句柄泄漏。
cleanup就是专门存这些「外部资源 + 对应的销毁函数」的单向链表。Q:咋感觉像智能指针呢?
A:二者都是 RAII 思想实现,但管理粒度、管理资源类型、回收时机设计完全不同。
相同点:生命周期绑定销毁时自动执行资源回收,不用手动释放,规避泄漏。
本质区别:
智能指针:粒度是单个对象,基于引用计数,作用域销毁触发释放;只管理堆内存。
ngx_pool cleanup:粒度是批量资源,按内存池分组管理,无引用计数,池子整体销毁才统一执行回调;可管理 fd、mmap、锁、文件等任意非内存资源。
struct ngx_pool_s { ngx_pool_data_t d; // 小块管理 size_t max; // 大小块分界(>max=大块) ngx_pool_t *current; // 当前可用小块池 ngx_pool_large_t *large; // !!!重点:大块链表头指针!!! ngx_pool_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 // ... 其他忽略 }; /* ngx_pool_s 仅仅是标签名,本身没有独立类型身份,完整合法类型只能是一整块:struct ngx_pool_s 单独写 ngx_pool_s p; 编译器看不懂,它只会把ngx_pool_s当成普通变量名,不是类型。 typedef 的唯一目的造出一个不用加struct就能直接用的独立类型名ngx_pool_t,解决上面语法限制。 不用别名:struct ngx_pool_s *pool;(必须带 struct,写着啰嗦) 用别名:ngx_pool_t *pool;(不用 struct,简洁) */结构就是:
【第一个小块池:ngx_pool_t】 ┌─────────────────────────────────────┐ │ ngx_pool_data_t d; ← ② 内嵌 │ ├─ last │ ├─ end │ │ ├─ next → 下一个小块池 │ │ └─ failed │ │ │ │ size_t max; │ │ ngx_pool_t *current; │ │ ngx_pool_large_t *large; → 大块链表头 │ │ ngx_pool_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 └─────────────────────────────────────┘整个内存池的 “总司令部”就是 ③,内存池主结构ngx_pool_sngx_pool_s === 全称 ngx_pool_t === 别名(typedef)所以:ngx_pool_t pool;和struct ngx_pool_s pool;等价,是小块池的头结点,里面装的就是你写的这一整套:struct ngx_pool_s { ngx_pool_data_t d; // 小块管理头部 size_t max; // 大小块分界线 ngx_pool_t *current; // 当前正在用的小块池 ngx_pool_large_t *large; // 大块链表头 ngx_pool_cleanup_t* cleanup // 新增:资源清理回调链表头,仅根块拥有 };pool.d.last→ 当前小块池,下一次分配的起点
pool.large→ 大块链表的起点,一开始没有大块,所以 = NULL。整个流程的内存布局:
查看代码
ngx_pool_t pool; // 内存池主结构体(在栈/已有内存上) pool.d.last → 小块区起始 pool.d.end → 小块区结束 pool.current → &pool(初始指向自己) pool.large → NULL(大块表头,一开始没大块) pool.cleanup = NULL; // 资源清理回调链表头,初始无回调任务 //起手搞小块池 ngx_pool_t *pool = ngx_create_pool(1024); //内部直接调用 malloc(),返回一个指针 → 得到 pool 指针指向的是【带头部(ngx_pool_t)+ 小块区】的内存区域 小块池如果不够就扩充, 注意:首块ngx_pool_t是本体块 pool ↓ pool->d.next 直接指向扩容块1整块内存起始地址,该内存开头就是 ngx_pool_data_t d,扩容块1的d.next → 扩容块 2(仅 d),扩容块 2->d.next = NULL ngx_palloc 就是在现有小块缓冲区里切割出对应尺寸内存给业务用。 空间不足时内部自动调用 ngx_palloc_block 新增扩容小块,再切割分配。 这里小块池子只有一个,后续扩充都是附属品,因为只内部ngx_palloc_block开辟,不调用ngx_create_pool, 仅初始化d,没有large因为所有ngx_pool_large_t节点仅挂在根pool->large,分配、销毁代码只会读取根节点的large。 current也没有,因为只用根pool->current保存当前正在使用的扩容块指针。 扩充块不维护独立全局管理标识,即串联关系:根块 pool->d.next 存扩容块 1 地址;扩容块 1 内部 d.next 存扩容块 2,以此单向链表串联。 结构拆分: 根块:ngx_pool_t(外层头 + 内置 d)+ 小块缓冲区 扩容块:仅 ngx_pool_data_t d + 小块缓冲区,无large、current字段 扩容块内存开头只有 ngx_pool_data_t d 这一个结构体。 就是说 pool 是 ngx_pool_t*,扩容块是 ngx_pool_data_t* 关于大块,即此时如果要第一次分配大块: // 1. malloc(100KB) 得到 p(纯数据,无任何头) // 2. 从小块区抠一个 ngx_pool_large_t 节点 赋值给变量 node // 3. node->alloc = p; // 节点绑定大块数据 // 4. node->next = pool.large; // 头插,一开始没大块,第一个大块那就是 node->next = NULL,因为pool.large 是 NULL // 5. pool.large = node; // large 指向新节点 // 现在关系: pool.large ──> node1 │ next ──> NULL │ alloc ──> malloc(100KB) // 纯用户数据 // 再分配一个大块: pool.large ──> node2 ──> node1 ──> NULL │ alloc │ alloc ▼ ▼ malloc块2 malloc块1查看代码
void ngx_destroy_pool(ngx_pool_t *pool) { ngx_pool_t *p, *n; ngx_pool_large_t *l; ngx_pool_cleanup_t *c; //执行回调 /* 注册是头插,后注册的 cleanup 节点在链表头部; 销毁从头遍历链表,执行顺序 =后注册先释放; 这种逆序释放是强制规范,用于解决资源依赖 */ for (c = pool->cleanup; c; c = c->next) if (c->handler) c->handler(c->data); //释放所有 large 大块内存 for (l = pool->large; l; l = l->next) if (l->alloc) ngx_free(l->alloc); //释放全部小块内存块(根块 + 所有扩容附属块) //这里狗逼豆包起初说Nginx是先释放后面的附属品,最后释放根pool,但理论上直接从根pool释放没问题,豆包说:"颠倒顺序会造成内存操作时序倒置,不符合 Nginx 内存池原生销毁设计;" for (p = pool, n = pool->d.next;; p = n, n = n->d.next) ngx_free(p); if (n == NULL) break; }注意我手写的代码实现切割 1024 块那个,
free回收一块会地址乱所以不可以直接+1024个找next,即此文搜 “关键问题是,你根本不知道自己用了多少呀?”,必须用大块链表,也就是小块池(我总说成大块,因为是第一次起手malloc用来给小块用的)。那这里 Nginx 也是搞了个
d.last这个小块池的链表,那可否用直接数 1024 的思路呢?他妈的更不行啊!因为人家 Nginx 没
next这玩意!就算有next也找不到啊,都说了释放小块回池子里会乱序,首先我手写的内存池其实没考虑场景,每个小块都有
next,人家 Nginx 是只有last指针后移,我的写法每次free还可以用,人家 Nginx 是last一直往前跑,不会回头释放,因为场景是 http 这种连接,很快完事,所以 Nginx 如果搞个next就增大开销了,因为next的意义在于复用(放回的时候靠next头插),而 Nginx 不考虑小块复用。所以,Nginx 不需要复用,就不用next,就没有释放小块回池子这件事,必须用池子的链表变量连接各个池子头,但如果你死命就是硬刚这个理论问题,觉得不考虑客观已经发生的
next巨大开销这件事,觉得不复用、不放回,不会乱序,不用小块池头链表,再记录下每次申请用了多少个小块,完全可以通过next增加count,找到一个小块池的最末尾的然后找他的next。但违背 Nginx 追求极致性能这件事!然后说下宏观大串联(应用):
科普:
1、TCP
操作系统提供的网络通信协议,作用是两台电脑通过网络稳定收发数据。
程序通过
socket文件描述符操作 TCP 链路,一个 socket 对应一条客户端和服务端之间的 TCP 通道。例子:
2、进程
操作系统资源分配最小单元。
每运行一份可执行程序,操作系统就创建一个独立进程,拥有独立内存、文件描述符、CPU 资源。
示例:单独运行你的
main.cpp编译出的程序,系统就生成 1 个单进程程序。3、Nginx 进程模型(高性能服务必备考点)
master 进程
Nginx 程序启动后第一个生成的进程。
只做两件事:读取配置文件、创建 / 监控所有 worker 进程。
不接收、不处理任何客户端 TCP 连接。
worker 进程
master 调用
fork()生成的子进程,数量一般等于 CPU 核心数。所有网络事件、TCP 接收、数据解析、内存分配全部由 worker 完成。
每个 worker 完全独立,互不共享内存、文件句柄。
4、内存池(属于组件)
管理堆内存分配的工具,只管控内存块,和 TCP、socket 无关。
worker 启动时创建全局内存池,存放进程长期存在的配置、全局变量,进程销毁才释放。
有客户端建立 TCP 连接时,新建一块专属内存池,存放这条 TCP 链路用到的读写缓冲区、临时状态数据。
单次 HTTP 请求到来,在 TCP 对应的内存池上创建临时子内存池,存储请求头、解析临时变量。
单次请求处理完毕,直接销毁子内存池,不用逐块 free 内存。
TCP 连接断开,销毁这条连接配套的内存池。
5、连接池(属于组件)
管理
ngx_connection_t结构体,只用来承载 TCP 链路信息,不管理内存。worker 初始化时一次性批量创建大量
ngx_connection_t,存入空闲链表。新客户端 TCP 接入,从链表取出一个空结构体,绑定当前 socket fd。
TCP 连接关闭后,把结构体放回空闲链表复用,避免频繁创建销毁结构体。
你手写的内存池 C++ 代码,只复刻上面内存池整套分配、销毁、大块内存分离逻辑。
手写内存池代码完全不涉及 master/worker 多进程创建逻辑,也不包含连接池相关结构体链表逻辑。
分层执行顺序(从启动到客户端断开)(这里豆包依旧是反复误人子弟,经常昨天一套今天一套,70% 的时间用来纠正错误、推翻、反复核对实践,要花大量时间辨别对错、质疑纠错,大量前后互斥的知识点,每学一个模块都要反复推翻旧记忆,纠错重构)
不用多本书交叉翻阅,省去翻找、对照书本的时间,这是提问的显性优势。
但 AI 输出概念前后标准不统一,你自主串联整套理论时,需要不断推翻、修正之前记下的错误结论,纠错、重新梳理逻辑的隐性耗时,会抵消掉省下来的看书时间。
纯靠提问搭建完整知识链,容易留存大量片面、错误的固化记忆,后期梳理整合成本远高于精读一本体系完整的书籍。
豆包不会主动补齐同模块所有分支边界,必须你主动一层层追问,逐字挑错,抽丝剥茧一路追问思考才能补全,过程中容易出现前后口径细微冲突,需要你自行核对修正;书本同一模块所有分支统一写在一处,无矛盾
只用豆包真的太痛苦了,90% 很重要的东西,都是随口一句无意间问到的才引出来的,豆包从来不主动说任何。海厚海厚、永无止境、无尽错误,啥时候有个头啊~~~~(>_<)~~~~
【在我去思考连接池内存池复用这个事的时候,才知道自己把他们两个搞混了,然后给了我一个流程。
然后又跟我说内存池绝对不复用,我又把你之前矛盾的一句话给出来,你又说自己说错了,你又说内存池复用了,又说,不挂载链表什么什么的,直接清空没性能损耗,之前说释放时候遍历释放大
large会损耗啥的,然后又引出了很大一块内容,就是清理回调这个大事儿然后又回头问,妈逼的说内存池也复用,不复用是错的
然后狗逼死妈玩意真的气死,起初说必须最后释放根 pool,无尽质疑后定死这件事,最后又说源码是最先释放的 pool】
臭傻逼经常一周时间都在给豆包挑错手动训练,又没钱买书。我突然发现这两年待业死磕,有 90% 的时间都浪费掉了都在纠正豆包,学错了东西。操.你妈死全家的垃圾豆包AI,老子如果正常看书只看高频必考都能 1 年进腾讯!!
我真他妈发现看书才是最快的,起初因为没钱买书、始终不想看各种国外权威网站,全是英文看不懂、不想看源码头疼,就逃避的追问豆包
但实际上,看书不会有大量错误,国外权威网站英文也就几个专业词汇,熟悉就好了,而追问豆包也早就涉及到了源码,给的全是大量互斥的错误知识点,逐字思考追问质疑推翻修改纠正,再复盘整理大量时间浪费,倒不如直接看源码了,被豆包折磨后、磨练出来的心性看源码相当舒服(曾经看源码那些下划线变量都头大抵触,但对着死磕就是能搞懂),不参考任何只死磕追问豆包真的痛苦且会浪费 99% 的时间去给豆包纠错。一直把源码想的贼恐怖不敢看,一直只看通过豆包问源码,结果看了 Nginx 源码发现这玩意居然就是世界级标杆啊?也没想象那么难啊!
之前不敢看外界闭门造车也是,不敢看简历、招聘、面经,一个个简历贼恐怖,各种实习、能带组的组长、能优化内存池(后来才知道是简历注水,他们根本不会,当时一看有人能优化内存,给我吓的以为人均C++之父的水平,帖子标题还求工作呢)
就比如我费劲巴拉总结这么点东西,可能其他地方有总结好的,但他们有些是有错误的,且自己踩坑总结的自己最清楚,看别人的肯定总是无法看懂透彻。代价是 90% 时间在挑错,不如啃书豆包辅助。
执行 nginx 程序,操作系统创建 master 进程加载配置文件
master 读取 nginx.conf 配置,根据 cpu 核心数循环
fork搞出多个 worker 进程(fork出子进程拿到副本后立刻主动丢弃复制来的 master 资源,重新独立初始化自身全套资源,这是程序启动时候的,手动relaod会重新读配置然后再次fork多个进程,master 截断老 worker 的新连接流入),master 只看管 worker,不处理用户访问每个 worker 进程初始化:创建全局内存池
ngx_pool_t叫做cycle->pool、初始化连接池(根据worker_connections一次性申请整块内存,存的是ngx_connection_t类型数组,长度永久锁死,运行中不新增、不扩容,把数组里全部串成空链表,给后续用)、绑定监听端口。每个 worker 独立监听端口,接收客户端 TCP 连接。 注册全局清理回调:进程退出时关闭监听 fd、释放共享资源,挂在 worker 全局池cleanup链表。客户端浏览器发起 TCP 握手,worker 监听到事件,至此开始 单个 worker 内部逻辑:
worker 从连接池取出空闲
ngx_connection_t(链表取空后,新 TCP 直接拒),绑定客户端 socket,链表长度减少,基于当前 TCP 连接新建专属连接级池子
ngx_pool_t记作c->pool(自带独立cleanup链表,自动注册关闭 fd 的清理回调函数,该回调用于池子销毁时关闭当前客户端socket fd,此回调函数挂载至c->pool->cleanup链表),TCP 存活则池子不销毁(之前学过的长连接,指复用同一个 TCP 连接多次收发 HTTP 请求)客户端发送 HTTP 数据,一条长连接收到 HTTP 请求,在连接配套内存池上创建临时请求子内存池
ngx_pool_t记作r->pool(独立 cleanup 链表),解析报文,注册对应释放回调到r->pool报文处理完成,销毁临时请求子内存池
r->pool,向客户端返回数据。但c->pool还在,如果还有新的 HTTP 数据,那就ngx_reset_pool(c->pool),只free清空large大块和重置 last 指针到内存块起始来重置小块内存,准备给新 HTTP 用。cleanup只在 TCP 彻底断开才执行destroy_pool时才批量执行清理。客户端长时间无数据即超时,或者服务端可通过
keepalive_timeout主动断开 TCP,无论哪一方关闭 TCP socket,执行ngx_pool_destroy(c->pool)销毁 TCP 配套的专属连接级内存池c->pool,将ngx_connection_t归还连接池空闲链表,具体就是遍历c->pool->cleanup链表逆序执行所有回调(自动关闭 socket、释放连接绑定资源),释放该池所有 large 大块内存,遍历d.next链表释放全部扩容小块内存服务停止,进程收到关闭信号,比如停 Nginx(nginx -s quit),work自身代码内部调用
ngx_pool_destroy()销毁 worker 全局内存池cycle->pool(先执行全局 cleanup 回调(关闭监听端口、释放进程级资源),再回收所有小块、大块内存),master 回收所有 worker 资源。如果 worker 程序崩,OS 强制回收,无内部调用。注意:
一个 worker 对应一个独立进程,一个进程仅有一个 worker 实例;
单个 worker 进程依托连接池
ngx_connection_t数组,可同时承载成千上万个并发 TCP 连接,绝非只能持有一条 TCP。依靠 keepalive 长连接,单条 TCP 可串行处理多条 HTTP 请求。
短连接单次 HTTP 响应完成后立刻关闭 TCP。
我感觉通过 Nginx 的连接池内存池把所有网络的知识点穿起来了,很简单的玩意啊!本质就是分层串联:进程模型、连接管理、内存池、TCP 长短连接、HTTP 请求生命周期全链路打通后,整套网络服务逻辑就闭环了。
绝对重点就是我之前把这些乱套混乱了!连接池和内存池完全是俩东西!
连接池永远复用,因为连接池
ngx_connection_t只是个空壳结构体,里面只有指针、数字这类基础变量,清空几个字段成本极低,复用完全划算。具体 TCP 关闭后归还空闲链表,可给全新 TCP 连接复用。内存池单独用复用一词来说很容易被误解:
全局内存池不复用:这里大量的回调+大小块,而回调的本质是说这里存了fd等东西,这些在后续的里是没用的,必须删掉,如果想覆盖,那指针指向了新地方,旧的就泄漏了,所以必须回调来销毁,而执行回调内存就还给了系统,新连接使用时又要重新 malloc 申请大块内存,而复用的本质是省 malloc/free,既然要重新申请那必然想复用也无法复用。
c->pool:
仅当前 TCP 长连接内通过
reset_pool原地复用;整个 TCP 连接,下一次 HTTP 请求还要继续用(连接 fd、ssl 会话),保留长期资源 + 它们的回调,不用执行销毁,不会丢外部资源。
TCP 断开直接销毁,无法交给其他新 TCP 使用。
旧 TCP 的 fd、ssl、连接状态全是旧连接专属,新 TCP 完全用不上,必须释放,这个道理就等同于全局内存池了。
r->pool:单次请求结束直接销毁,无任何复用逻辑。r 池所有带回调的资源,只属于当前单次请求,下一次请求完全无效。只重置指针不跑回调,临时文件、解析缓冲区附属句柄全部泄漏;跑一遍全部回调释放资源,内存还给内核,再分配等于重建,没有节省 malloc/free,又和全局一样。
这里之前被豆包误导说复用清除的内容比销毁的操作更性能低,但其实大错特错,销毁就是大清理,复用不清理回调,所以只能销毁(追问的千辛万苦价值一个亿的精华总结)
长连接 ≠ 永远不释放内存,请求内存池 = 只管 “一个请求”,不管整条连接!
1 条 TCP 长连接 = 固定 1 个连接级内存池(全程存活,TCP 断开才销毁)连接内串行处理 N 条 HTTP 请求:
第 1 个请求:创建请求内存池,解析、响应;执行
pool_reset清空池内数据,不销毁;第 2~N 个请求:复用同一个重置后的请求内存池,无需重新分配;
TCP 长连接超时 / 主动关闭:
销毁连接级内存池;
将
ngx_connection_t归还空闲连接链表。
QQ 浏览器整体是一组进程:浏览器采用多进程架构
1 个主进程(浏览器进程):管理窗口、网络、进程调度
每个标签页 / 新页面 = 独立渲染进程
网络请求统一走网络进程(所有页面 TCP 连接都在这里创建 socket fd)
TCP 连接归属:所有页面的网络 TCP socket,都创建在同一个网络进程,不是每个页面单独开网络进程区分两种场景
同浏览器新标签页:同网络进程,共享进程地址空间,socket fd 互不冲突
另一款浏览器(Chrome):完全独立程序,整套独立进程,fd 完全隔离(QQ 浏览器网络进程里 fd=5,Chrome 网络进程里 fd=5,是两条完全无关的 TCP 连接,内核靠四元组区分连接,fd 只是进程内句柄)
服务端多 worker 同理:Nginx 多个 worker 进程,各自的 fd 数字互不干扰。
Q:每个页面独立进程和通过浏览器同网络进程矛盾啊!到底是一个qq浏览器开2个标签是几个进程?
A:
1. 两类完全独立的进程
1)渲染进程(Renderer):1 个标签页 ≈ 1 个独立渲染进程
负责解析 HTML、执行 JS、页面渲染;作用是页面隔离,一个页面卡死不影响其他标签。
你开 2 个标签 → 2 个渲染进程。
2)网络进程(Network):全局唯一,整个浏览器只有 1 个
所有标签页的 TCP、HTTP、WebSocket 全部在这里创建 socket、收发网络数据;渲染进程不直接操作 socket,靠进程间 IPC 把网络请求转发给网络进程处理。
2. 完整进程总数(QQ 浏览器 / X5 内核同 Chromium 逻辑)
打开 QQ 浏览器,新建 2 个标签页,常驻进程清单:
浏览器主进程(Browser,全局 1 个,管窗口 UI、进程调度)
网络进程(Network,全局 1 个,统一处理所有 TCP 连接)
GPU 进程(全局 1 个,图形合成)
渲染进程 A(第 1 个标签页)
渲染进程 B(第 2 个标签页)
合计:5 个进程“每个页面独立进程” 说的是渲染进程(页面沙箱隔离);
“所有页面共用同个网络进程” 说的是网络 IO、socket 创建的专属进程;
二者不是同一类进程,各司其职,不存在冲突。
两个标签页的 TCP socket fd,全部存在同一个网络进程里,fd 数字互相不冲突;
渲染进程只是发请求消息,不持有 socket 句柄。
用例子串联:先把 TCP、HTTP 拆明白(用打电话类比)
TCP = 打通一条稳定电话线,你拨通对方号码,建立持续通话通道,双方可以反复互相发文字,通道不断就能一直聊,这就是 TCP 连接。只负责稳定传输字节流,不管你发的内容是什么。
HTTP = 你们约定好的聊天话术规则,电话线(TCP)通了之后,你说话必须按固定格式:先报请求地址、浏览器信息、参数,对方再按固定格式返回状态码、网页内容。这套说话格式就是 HTTP,依赖 TCP 通道才能传输。
关系:HTTP 是跑在 TCP 之上的数据格式,没有 TCP 就传不了 HTTP。
举完整例子:浏览器打开百度首页
步骤 1:启动 Nginx 服务(后台程序)
Nginx 会创建 worker 工作进程,每个 worker 自带全局内存池,
用途:存永久不变的数据(服务器配置、监听端口、静态文件路径)
生命周期:程序运行全程不销毁,进程关才释放内存。
模拟手写代码示意:
// worker进程刚启动,创建全局内存池 pool* worker_global_pool = create_pool(8192); // 把固定配置全部分配在全局池里 server_config* cfg = pool_alloc(worker_global_pool, sizeof(server_config)); cfg->listen_port = 80;步骤 2:浏览器发起访问,建立 TCP(电话线接通)
浏览器和 Nginx 之间打通 TCP 连接,对应 Nginx 创建连接内存池
用途:只属于这一条电话线的数据(客户端 IP、收发缓冲区、连接状态)
生命周期:TCP 连接不断,池子就存在;连接断开,整个池子一次性销毁。
模拟手写代码示意:
// 收到新客户端TCP连接,新建连接专属内存池 pool* conn_pool = create_pool(1024); // 这条连接独有的数据放这里 connection* client_conn = pool_alloc(conn_pool, sizeof(connection)); client_conn->client_ip = "123.45.67.89";步骤 3:TCP 通道里收到浏览器发来的 HTTP 消息(通话内容)
TCP 通道只是纯字节,Nginx 要解析里面的 HTTP 请求,创建请求临时内存池
用途:只解析这一次网页请求用的临时变量(网址、请求头、临时字符串)
生命周期:这次网页回复完毕,池子立刻销毁,不占用连接内存。
模拟手写代码示意:
// 读取TCP字节,发现是一次HTTP网页请求 pool* req_temp_pool = create_pool(512); // 解析HTTP的临时数据都分配在临时池 http_request* req = pool_alloc(req_temp_pool, sizeof(http_request)); parse_http_data(req_temp_pool, client_conn->buffer);步骤 4:页面数据发给浏览器,本次 HTTP 请求结束
直接销毁临时内存池,只释放本次网页解析的临时内存,TCP 连接还保留,连接池不销毁。
destroy_pool(req_temp_pool);步骤 5:浏览器关闭页面,挂断 TCP 连接
整条电话线断开,销毁连接内存池,释放这条连接所有资源。
destroy_pool(conn_pool);步骤 6:关闭 Nginx 服务
worker 进程退出,销毁全局内存池,释放全部常驻配置内存。
destroy_pool(worker_global_pool);三层池子核心区分(跟着例子总结)
全局池:跟着程序走,永久数据;
连接池:跟着 TCP 电话线走,单条连接专属数据;
临时请求子内存池:跟着单次 HTTP 网页访问走,用完立刻丢。
这三层每个都是此文搜“整个流程的内存布局:”那个进程内的 Nginx 的 ngx_pool_t 内存池,是按生命周期单独创建、整块销毁、不复用释放内存的独立池子。
日常说的“Nginx 内存池” 默认指 Nginx 进程私有临时内存池 ngx_pool_t,也就是 Nginx 的原生内存池,而下面即将说的 ngx_slab_pool_t 是 Nginx 共享内存 Slab 分配器,是独立模块,不属于上面那个 pool,属于 Nginx 的两大类。代码随想录和我手写的都是单进程用户态迷你 ngx_slab_pool_t。
严格标准:nginx slab 依托共享内存,这份代码没有,所以不算;
宽松自学标准:有多规格分池复用逻辑,可以当成简易 slab 理解。
面试项目懂此文搜“整个流程的内存布局:”即可,那个是 Nginx 的原生内存池,懂框架说清楚装逼用的,不需要代码写迷你版,slab 好写。面试项目代码用 slab 的也就是代码随想录的即可。
且应用不同:
原生 ngx_pool_t:worker 进程私有,分连接池、请求池,池子生命周期可长久,但单次请求分配的临时数据随请求结束批量回收,无空闲对象长期缓存复用逻辑。适配网络连接 / 单次请求、网关、网络代理
ngx_slab_pool_t(Nginx slab):用于多进程共享内存,存放长期驻留缓存数据,固定大小对象复用,不会随连接 / 请求批量释放
我和代码随想录手写的 slab 迷你池:写通用服务、大量短生命周期对象,解决高频反复创建销毁同类对象(缓存、结构体),这里看似矛盾怎么Nginx slab是长,手写迷你版就是短?其实都是复用,只不过
我的迷你池:对象几秒就销毁,槽反复拿来分配新临时对象(证据就是业务对象随单次逻辑用完归还,槽子还在)
ngx_slab_pool_t:缓存对象能存几小时甚至全天不销毁,槽长期绑定常驻数据,但一旦销毁槽子依旧反复拿来用。
且其实我写的属于原生,因为体现了 Nginx 的小块池用大链表,增加 CAS 和 多规格 就是代码随想录的 V1,也就是迷你版的 Nginx slab(单进程私有阉割版本的 slab)。
slab:按固定尺寸分组缓存内存块、复用分配减少系统调用的内存管理模型。
总结:
短生命周期、频繁新建销毁大量同尺寸对象 → slab复用;
一次性用完整块丢弃、无需复用碎片 → ngx_pool_t。
你刷抖音网页
连上去不断开(长连接)
刷一下 → 发一个请求拿视频
再刷 → 再发一个请求
一直连着,请求一个接一个跑
每个请求:来 → 建 pool → 处理 → 销毁 pool,连接永远不断。
nginx 就是:连接活着 ≠ 请求活着,请求死了 ≠ 连接断了,请求内存池只管单次请求,长连接只管socket 不断。
1)Nginx 请求池:不是不能复用,是根本不想复用(上面一个亿精华说复用等同于销毁,但其实微小差别)
销毁重建 = 执行 cleanup 释放资源 + 释放大小块 +malloc 开辟新池
跨请求复用 = 执行 cleanup 释放资源 + 释放大小块 + 链表存入旧池 + 下次分配链表取出旧池 + 全程加锁
2)你手写的 “复用” 到底好在哪?
适合 一块内存 用很久、断断续续、乱拿乱放。
比如:
长连接的连接结构体
缓存
游戏对象
长时间存活的东西
活的久 → 才值得复用,活的短 → 复用纯浪费时间。
以上是 Nginx 的单个 worker 进程内部用的,这里有仨链表:
内存块链表管小块的、
大块链表可以单独释放回收复用、
清理回调链表用于放文件、socket 等资源的析构函数,在pool 销毁时逐个调用。
slab是一整页切成的固定小块集合,slot是里面单个固定小块。这里小块链表只有一个
last指针往后挪,不记录每块的大小、不记录每块是否空闲,不能单独释放某一块,只能整池销毁。每个客户端发来的一次访问即 HTTP请求,就一个
pool,请求结束(200/404/500 / 断开),整个池干掉,keepalive 长连接下,同一个连接可以反复来请求,每个请求仍然是自己独立pool,互不干扰。这里都是进程内部,但放到多进程共享内存里直接暴死(引出进程共享的):
进程 A 分配一块,进程 B 分配一块
进程 A 想释放自己那块 → 做不到!因为没记录、没标记
内存永远只增不减,瞬间爆掉
多进程同时改
last指针,直接数据错乱所以 Nginx 还有一套设计 —— 共享内存 ngx_slab:
专门为缓存、限流、共享字典使用(之前的进程内部的是 HTTP 请求用完就丢),多进程一起用:
先
mmap映射一大块连续物理内存做成共享内存,再在里面自己切块、建链表(不是malloc),把一大块共享内存切成 N 个一样大的块(比如全部 64B、或全部 128B,这里的 64B 就是完全我手写内存池的逻辑)用链表把所有空闲块串起来,哈哈
一次性 mmap 拿到 1MB 大内存 → 提前切成 64B、128B、256B... 固定大小块(准确说还是要的时候才切割,先划分的是页) → 用链表串起来 → 谁要就给谁,释放就放回链表分配 / 释放 = 只改链表指针,不碰系统调用
可以随时放回空闲链表马上被下次复用,Nginx 认为:共享内存池一旦创建,就一直用到进程退出才会还给OS,因为还回去要 munmap,系统调用慢。
总结就是:
进程内线性池
小块:不 free、不复用、指针只往前走
大块:单独链表,可以 free
生命周期:请求结束整池销毁
共享内存 slab
小块:可 free、可复用、链表管理
大块:可 free、可复用、按页管理
生命周期:worker 重启 / 退出 才真正还给系统
设计目的不同:
进程内内存池(ngx_pool_t)只为 “一次请求” 服务
生命极短:请求来了 → 建池 → 分配 → 请求走 → 销毁
目标:快、快、快,不做任何复杂管理
不需要回收,因为用完就扔
共享内存 slab(ngx_slab_t)为 “全进程生命周期” 服务。
小块管理方式天差地别:
进程内池只有一个往后推的指针:last(不提前分割)
不记录谁用了、谁没用
不记录块大小
不记录空闲位置,完全裸奔
共享
slab必须有完整的空闲链表 + 位图(提前分割页,具体 64B、8B 这些用的时候再切割)
- 总共享内存比如:64KB,系统内存页默认 4KB / 页,64KB 拆成 16 个物理页
单个申请内存 ≤4KB:页内按 64B、128B 等规格切小块(slab)分配、回收复用
单个申请内存 >4KB:直接拿完整页,不切割
每一种大小(16B、32B、64B…)都有一条空闲链表
分配 = 从链表摘一个、释放 = 放回链表
精准知道每一块是空闲还是使用
多进程安全(你不知道但最重要):
进程内池完全不考虑多进程
没有锁
- 没有原子操作
多个进程同时写 → 直接内存错乱、崩溃
- 共享 slab天生为多进程设计
有自旋锁
有原子操作
多个进程同时分配 / 释放 → 安全、不会互相踩
至此总结下 Nginx:
进程内部:
是先一次性
ngx_create_pool(底层封装的是malloc)大块,然后用户边用边线性切割小块(线性推进,last指针往后推),不提前切割,如果需要超过4KB的块就不切割现有的大块,直接重新ngx_palloc_large(封装malloc),然后large链表节点ngx_pool_large_t是该大对象的管理节点,他是从当前内存池的小块区里切出来的,挂到pool->large链表。不够就再次执行上述扩容如此往复。大块的咋链接?首先说下我自己设计的迷你版池子的弊端(准确说是豆包给的代码),这里其实也是我和豆包沟通中的歧义,先看我自己的手写的内存池代码,其实是每次64B,没判断大小都是直接一次性锁定固定64B,Nginx 区别是小对象(≤64B)走你现在的 64B 池,大对象(>64B)直接
malloc/mmap+ 挂large链表,而我写的其实豆包说是教科书版本定长内存池,但存在诸多问题,第一次要 64B拿走free_head_,free_head_自动指向下一块,给你了,第二次要 64B直接拿新的free_head_,再把free_head_往后挪。只支持:无数次分配,每次大小 ≤ 64 字节,超过 64 字节比如你想要100字节,直接越界崩溃。然后比如申请 2 次,每次是一个1024大块,那这里大块之间的连接就用第一小块,而我没有非64B的用户需求级别的大块,所以没法讨论!这个问题后续不自己瞎改了,直接看代码随想录的 Github 项目然后一句一句死磕追问豆包改进吧。先把所有现有设计模式搞懂,然后再看代码随想录的,那就继续回到这里,Nginx的进程内部大块咋链接这件事涉及到俩概念:
一个是每次
malloc大块准备线性给小块用的这个大块咋链接?也就是用来线性切小块的 “主内存块” 怎么链接?pool -> 当前块 -> 上一个块 -> 更老的块 -> NULL每个块都是ngx_pool_t/ngx_pool_data_t,用自身的next指针链接,全部来自 malloc 一次一大块,内部切,不额外 malloc另一个是每次用户级别的,需要很大的块,也就是大对象,需要重新malloc的又是咋搞?这个容易如下:
// 再分配一个大块: pool.large ──> node2 ──> node1 ──> NULL │ alloc │ alloc ▼ ▼ malloc块2 malloc块1Nginx 的进程外:
共享是一次性
ngx_shm_alloc(mmap)一个大块,然后ngx_slab_pool_t提前划分页,然后用的时候再根据规格动态切割,专业术语叫内部管理用ngx_slab_pool_t(slab 池),然后第一次有人申请8B才拿一页,切割512个8B的slot,然后后续搞16B...,如果用户需要比较大的内存,是从空闲页链表拿连续4KB页,不去找那些8B、64B的小规格。进程内部是不够就再次执行上述扩容如此往复,但这里Nginx 共享内存池(slab)默认不动态扩容,配置多大就是多大,运行期不会再mmap新的共享块 Nginx。这里的大块又是咋链接?
用来切小对象的「4KB 页」(对应进程内的 “主块切小块”):
由于:
双向链表节点自带
prev前驱指针,直接定位前驱,无遍历,O (1)。单向链表只有
next,找不到前驱,必须从头遍历,O(n)。所以这里,双向便于找到前一个这种然后做删除啥的,然后所有 4KB 页 → 存在一个连续数组ngx_slab_pool_t { ngx_slab_page_t *pages; // 所有页的数组(连续) ngx_slab_page_t free; // 空闲页链表头 ... } ngx_slab_page_t { uintptr_t slab; // 位图 / 规格标记 ngx_slab_page_t *next; uintptr_t prev; }pages[](不是分散 malloc)。空闲页:用pool->free做头,串成 双向链表:pool->free ↔ page1 ↔ page2 ↔ page3 ↔ NULL一旦某一页被切为 8B/16B/… → 从「空闲页链表」摘下,挂到对应规格的 slot 链表:slots[8B] ↔ pageA ↔ pageB ↔ ... slots[16B] ↔ pageC ↔ ...pages [] 数组存所有物理上连续的 4KB 页,目的:知道第几页在哪、分配连续多页(比如 3 页给大对象)必须靠数组、没有数组,你根本找不到连续页。类似整个书架(所有格子都在,连续不乱)
双向链表,只串空闲的页,目的:快速拿 / 删空闲页(O(1))、不遍历、不卡性能。只把 “空格子” 串起来,方便随手拿、随手放
大对象(> 页 / 特定阈值)(对应进程内large链表):整个共享内存只有一块:
mmap出来的连续大数组,再也不扩、再也不新mmap。没有 “独立 malloc 的数据块”,所有数据都在这一块里。直接拿连续的 4KB 页(1 页或多页)。页的管理结构
ngx_slab_page_t→ 全部在共享内存的pages[]数组里(连续、预分配)。链表:只有页的双向链表,没有 “大对象链表”:pages[] 数组(连续) 空闲页:free ↔ page1 ↔ page2 ↔ ... 大对象:page3+page4(标记 NGX_SLAB_BIG)→ 地址返回给用户小对象:
slots[32B] → page1 → page2 → NULL (同尺寸页链表) ↓(位图切) 32B小块1、2、3…小块之间:不靠指针连,用页内位图管理空闲 / 已用。
页之间:同尺寸挂
slots[slot]双向链表。所有页:都在共享内存
pages[]数组里,无独立 malloc。关于进程外的东西再说详细点,整体架构思路 & 内存布局具体情况(其实我自己根据豆包一直搞到析构发现没标准答案就无意间查到了内存池、然后代码随想录的内存池,然后回头梳理进程外这件事的时候,发现此文搜“所以这里,”误以为每个块都有个空闲头,后就死磕理解之前其实不够理解的进程外的这个架构原理,死后才返现代码随想录写的V1就是在模拟这个):
当你一次mmap内存就给你这么块连续物理内存,用作你进程外使用共享这件事,里面按顺序切4段:
低地址 ─────────────────────────────────────────高地址 [ngx_slab_pool_t] [slots数组] [pages数组] [4KB数据页区] 总管理头 各种尺寸链表头 每页的管理员 真正存数据的内存 │ ├─ 记录 slots[] 在哪 ├─ 记录 pages[] 在哪 └─ 记录 4KB数据页区 在哪
4KB数据页区 = 全量真实内存,mmap一次性申请一大块内存后,全部先划分成固定的4KB页,统一放在4KB数据页区里。slots是结构体数组,里面存指针,用来串链表。
slots[0]管8B→ 拿一整页4KB切成无数个8B小块,要8B就从slots[0]链表里拿。
slots[1]管16B→ 再拿一页4KB切成无数个16B小块,要16B从slots[1]拿。
page就是桥接,一边连接slots链表,一边管理对应的4KB数据页。
ngx_slab_pool_t就是整个内存池的大总管,记着所有区域的起始位置、空闲页链表、总大小和互斥锁,统一调度一切。
ngx_slab_page_t:
ngx= Nginx 的专属前缀(所有结构体都带这个,无意义,标记是 Nginx 自己的)
slab= Slab 内存池(专门管理小块内存的机制)
page= 一页内存(这里固定是 4KB 一页)
slot为啥自己不管理?非要个桥接?slot[0]就直接记录page[0]记录的东西啊?因为一个slot要管很多很多页,一个slot装不下所有页的信息,并不是1个slot对应1个page,1个slot对应10个、100个page,必须用page做桥接单独记录每一页的状态,slot[0]只管8B大小,但系统可能需要100页4KB都切成8B来满足需求:
slot[0]= 队长(只负责:我是 8B 队,我有一条链表)
page[0]= 队员 1(管第 1 页 4KB)
page[1]= 队员 2(管第 2 页 4KB)
page[2]= 队员 3(管第 3 页 4KB)……
page[99]= 队员 100(管第 100 页 4KB)队长只有 1 个,队员可以有很多个,pages [i] 是 4KB 页 i 的管理员,记录:这页属于哪个 slot、切成多大、空闲块在哪、串在哪个链表。slot[0](8B) ↓ (一条链表) page[0] → page[2] → page[5] ↓ ↓ ↓ 4KB页0 4KB页2 4KB页5 (全8B) (全8B) (全8B) slot[1](16B) ↓ (一条链表) page[1] → page[3] → page[4] ↓ ↓ ↓ 4KB页1 4KB页3 4KB页4 (全16B) (全16B) (全16B)
mmap申请内存,得到是一个pool指针,指向的就是申请下来的整片的内存起始地址,pool是一个指针,类型为ngx_slab_pool_t*,它的值等于整片内存的起始地址,通过 pool 可以访问ngx_slab_pool_t结构体、slots 数组、pages 数组、4KB 数据页。那完整就是:初始状态:
整块 4KB数据页区:【全部100页 4KB 物理内存】(永远不动、永远全量在这) pages[]: 【page0 ~ page99】(100个管理员,一一对应) ngx_slab_pool_t free 链表: free → page0 → page1 → page2 → ... → page99 → NULL slots[]: slots[0] = NULL slots[1] = NULL ...全是空plaintext 低地址 ────────────────────────────────────────────────────────────────────────────→ 高地址 [ngx_slab_pool_t] [slots[]] [pages[]] [4KB数据页区] │ │ │ │ │ │ │ │ ├─ slots ─────────────▶ 指向这里 │ │ │ │ │ │ ├─ start ──────────────────────────────┼───────────────▶ 指向这里 │ │ │ │ │ │ │ │ └─ free ──────┐ │ │ │ │ │ │ │ ▼ │ │ │ pages[0]─────┼───────────────▶ 对应───────▶ 4KB页0 │ │ │ ▼ │ │ pages[1]─────┼───────────────▶ 对应───────▶ 4KB页1 │ │ │ ▼ │ │ pages[2]─────┼───────────────▶ 对应───────▶ 4KB页2 │ │ │ ▼ │ │ NULL │ │ │ │ │ │ slots[0] ◀──────────────────┘ │ (链表头) │ │ │ ▼ │ pages[3] ────────────────────────────────▶ 对应───────▶ 4KB页3图里
[ngx_slab_pool_t]就是ngx_slab_pool_t类型的数据,即ngx_slab_pool_t结构体实例。每个 page 自动对应 1 个 4KB 页,不用指针,靠位置计算对应,
ngx_slab_pool_t是结构体实例,里存 3 个关键地址
指向 slots []
指向 4KB 数据页区起始地址
free 是全局空闲页链头
slots[]也串pages[],但串的是已经分配、已经切分的页:
free 串:空闲、未分配、完整的 page
slot 串:已分配、已切割、在用的 page
同一个 page,同一时间只能在 free 或者 1 个 slot 里,不能同时在两边
从 free 拿走 → 进 slot
用完释放 → 回 free
4KB数据页区 = 全部真实物理内存。
至此说完 Nginx 的全部!完全没看任何源码、教程、书籍等任何资源,全靠追问豆包、死命和豆包语音。
再说另一波东西:
TCMalloc:Thread-Caching Malloc,Google 写的通用内存分配器。
用来替换系统 malloc 的,但只替换「当前这一个进程」里的
malloc,不是全局替换整台机器,也不跨进程。先说下TCMalloc的三级架构:
ThreadCache(线程本地缓存,属于上层),每个线程一个,私有,无锁(和 Nginx 的共享模式完全一样,此文搜“用链表把所有空闲块串起来,哈哈”,只不过不是给多个进程用,是给单个线程私用自己玩)
- 管小对象 ≤32KB
内部按
size-class分档:8B、16B、32B、…、32KB(几十种)每个
size一条 空闲链表(freelist)分配:直接从自己的
freelist摘一个,无锁、极快释放:塞回自己的
freelist比如线程申请 20B (≤32KB 小对象),线程查自己的 ThreadCache(无锁),ThreadCache 有 20B 空闲块直接拿走,分配完成,如果 ThreadCache 没有,去 CentralCache 批量取一批对应 Nginx:Nginx 没有线程本地缓存,worker 是单线程,直接在 pool 里 last 指针往后推。
CentralHeap(中央堆,也叫CentralCache),全局共享,所有线程共用,带锁。
ThreadCache 没货了,批量从 Central 拿一批(比如 64 个同尺寸块)
ThreadCache 空闲太多,批量还给 Central(Central = 线程本地缓存的 “仓库”,不碰 OS,所有线程共享的 “内存中转站”)
- 内部也是按 size-class 分桶 + 链表
用 自旋锁 保护,竞争比系统 malloc 小很多
对应 Nginx:Nginx 没有全局中央堆,每个请求 pool 完全独立,互不共享。
PageHeap(页堆),最底层,向 OS 要内存,按页管理。
- 页默认8KB / 页
管理单元叫
Span:一段连续的页(1 页、2 页、…、最多 256 页)申请大小 ≥256KB:直接分配若干整页,不走 Thread/Central
≤256的:把一整页(或几页)切成很多同样大小的小块,挂在 CentralCache 的 freelist 上,等待 ThreadCache 来取。
负责:
向 OS
mmap要内存空闲页合并、碎片整理
长期空闲的页还给 OS(munmap)
最后,咋用这玩意?
你代码里写的
malloc(size),就是 TCMalloc 暴露给上层的入口函数,它内部自动走你理解的那三级架构:判断大小 → 走 ThreadCache → CentralCache → PageHeap → OS,全程替你包办。至此总结下:
之前浪费时间走进的误区,我写的手写内存池叫块头内嵌法,最前面一段当链表节点(next 指针),后面全给用户,然后扩展下就得到了Nginx进程内的写法,也叫元数据与数据分离(双结构体)
链表节点(管理结构)和用户内存是两块独立内存
节点单独存在,用户内存单独
malloc结构:
[节点结构体] → [用户内存]总结梳理:Nginx懂了、TCMalloc 懂了,
然后普通的malloc是底层走ptmalloc,管理头
header嵌在同一块内存里,malloc(100)就是一块100+header,每次跟操作系统交换,很慢。网上好多博客写的哈希桶就是多规格,只用在 Nginx 共享内存 slab,进程内内存池完全没有!
我现在写的代码 = 教科书版「单规格定长内存池」
如果你想加:判断大小 → ≤64B 走池、>64B 直接 malloc = 这就是 Nginx 进程内内存池(ngx_pool_t)的逻辑!但又不完全是,因为人家进程内是线性的,不提前分割固定字节
如果你想加:多规格 + 按页 (4KB) 切割 + 空闲链表 + 哈希桶 = 这是 Nginx 共享内存 slab(跨进程用)
tcmalloc咋用?
g++ abc.cpp -o abc -ltcmalloc && ./abc即可,看不到,除非多线程,就可以看出替换的效果了。对外:TCMalloc 就是
malloc/free/new/delete的替代品,它自己实现了标准库的malloc函数,链接后程序里所有malloc都会走到 TCMalloc 的代码,不再走系统 glibc 的malloc,因为各种底层库都用malloc,因为 C++ 的标准容器 (vector/string) → 用allocator→ 调用::operator new→ 默认实现,就是调用malloc。那为啥底层不搞高效的?因为malloc是 Linux 系统内核 + 标准库唯一提供的、跨动态库兼容的、全局统一的堆内存入口。所有更快的(tcmalloc、jemalloc)都必须伪装成malloc,才能被底层使用,底层不可能直接调用它们。所有你写的
malloc→ 进 TCMalloc → 走 ThreadCache/CentralCache/PageHeap → 最终用mmap/sbrk向系统要页,全程不调用系统 malloc。所有管理结构(Span、ThreadCache、CentralCache、SizeClass 元数据)全部从 PageHeap 来的内存页里分配,也就是:先向 OS 拿大页(mmap),从这些页里切出管理节点、切出小对象。
所以我的手写内存池是:是全局单例 + 多线程安全 + 带锁,是目前我代码只有一个切割1024这件事,后续作为接口对外使用的话,类似于cpp文件就是同一个进程,该进程内的所有线程通过单例共享同一个内存池,多线程抢的是同一个全局内存池的锁和空闲块,所以线程B想干活就得等A申请内存这件事完事,即:
单例
GlobalMemoryPool全局唯一,仅限当前进程内所有线程共享;多线程调用
pool_alloc/pool_free会竞争mutex,是线程间抢夺进程之间内存独立
Nginx:多进程,每个进程单线程,进程内私有池 → 无锁
如果我代码改无锁:多线程,每个线程私有池 → 无锁。
所以目前发现几个改进就是:析构要完善,然后多规格(什么哈希桶之类的),然后无锁问题。
看代码随想录的内存池项目:
真的是发现了!之前那个析构还有http多线程服务器项目每次时间轮其实对错都很难发现错误!代码随想录的讨论 证实了这一点!!他也有错误。(wx搜“null”),之前《讲得不好就得认》,不过如此。全对上了!
无意间发现这里的 贡献者 有TCPIP网络编程的阅读笔记。看了代码随想录的V1真的好垃圾啊,原来这就是github啊???就这?
关于 Github 的基础使用:(涉及到 ①年薪、②水平定位、③中年危机知识护城河、④说实话感觉自己很笨,如果把自己的意志力死磕精神给985的本科生,他们估计只会用我1/5的时间、⑤感觉除了哈工大、北邮其他计算机研究生多很水,为啥他们毕业薪资就高,为啥感觉本科考的一些算法,水货研究生毕业再答同一套算法试卷就会比本科好?⑥为啥代码随想录这种级别也有很多讨论说有bug,看过鱼皮、吴师兄、代码随想录的算法感觉他们都太应试远不如我,而项目肯定是大厂的标杆啊,居然也这么多错误,不过我思考过许多比如windows和微信的各种bug也就不足为奇了,原来一直学东西拿资深专家的标准要求自己误以为是人人必备的最开发的门槛,拿万无一失0bug的东西当作自己项目目标结果win都有bug、⑦起步晚面试官咋看)
VScode有时候白屏,必须搞下窗口。
github复制需要点好几下,延迟好大。
github的左侧一会悬浮一会嵌入需要把页面拖出来再放回去
感觉比 Java 爽多了,就是照着豆包死磕底层代码就行,直接追问到底,妈逼的 Java 各种依赖包记得大学IDEA/eclipse搞的头大,写个helloworld倒腾软件半天都无法编译构建不起环境,本科课设不咋学习抄个别人的项目都不知道咋运行妈逼的!一屁眼子 jar 包。
为啥想到手写内存池?
开始malloc看不到回落,硬头皮啃这一节,但都是文字、概念,学完感觉学会了、背会了malloc和内存池的对比、背会了底层走的brk/mmap,但没啥东西心里不踏实,回想之前看到过什么优化内存,决定手写个内存池。
又回忆起,取关了的、感觉算法很烂又有点营销号风格的代码随想录,但关于培训 C++ 项目他应该很行,记得之前看到过公众号文章,但没钱买书、没钱买星球,可以参考他的 github,其实我一直都是完全闭门造车,各种戾气劝退C++,我就想那些清北华五就不会被劝退吗?他们走一个我就能站住坑位,爸爸的病没法再治疗了,我找份C++的工作真的这么难吗?
黄国平博士致谢论文、罗斯纪录片、艾弗森纪录片、当幸福来敲门... ...
叶问看X光学武功
为啥打算看github参考别人?
一直觉得自己基础语法不行,然后之前刚学的时候,编程指北的知乎文章里的 github 服务器的项目地址打不开,找其他的怕项目不与时俱进、运行不了增加负担、很多怕学深了、偏了、怕收费、怕自己有更好的思路和作者不同却受别人大厂头衔束缚不敢改,公众号文章一直只顾着收藏没看,但现在突然发现自己有基础、有功底、有能力去参考他们那些人的定位于进大厂的项目了,先看 代码随想录 的 内存池 吧!
一直知道鱼皮的Java+AI,AI+C++又是啥?(他妈的!发现个事!!A对话链接分享出B链接,B里点继续聊获得C,这三者删都互相影响,但A对话链接如果没了B的分享对话一定没,C还在。如果不删除对话,只是删除A对话里的分享内容,则B里不影响,C更还在。。。更新:妈逼的如今发现又更新了,A里删对话,分享出去的也没了,更新之前这么操作的依旧还在)
最主要的是豆包给出的代码我跑完了依旧感觉没啥特别难的呀,然后很多极致细节的思考豆包就不敢跟我对峙了,只要是析构那个模块(到底是直接写next还是咋),全网说的内存池代码看不懂,豆包说 Nginx 代码也这么写,尽管让豆包参考极致权威,但反复质疑,豆包也依旧总是顺从我,哪怕豆包说的是堆的。感觉没啥难度,所以才想去看github。
代码随想录的项目选哪个?结合豆包自己的思考:
代码随想录说服务器项目烂大街,但我的思考是虽说烂大街,但我也必须搞!因为可以通过这个有一个基本框架宏观的认知!!!不应该跳过这个不搞,去直接搞内存池RCP网络库那些,就像开车技术烂大街但不会开车的人必须先学开车不能直接去学咋修发动机。
对比维度 烂大街基础 HTTP 服务器 你的 HTTP 服务器 网络模型 × 基础 socket(套接字基础收发)、单线程串行处理 √ epoll 事件驱动(Linux 高并发 IO 模型) 连接形态 × 短连接(请求完成立刻断开连接) √ 长连接(单次链路可多次交互) 资源管理 × 无连接池(每次请求新建销毁套接字) √ 自研连接池(复用空闲连接减少损耗) 超时管控 × 无定时器(无法清理僵死无效连接) √ 手写时间轮(定时回收超时连接) 并发能力 × 单线程,同一时间只能处理一个请求 √ 多线程并发,可同时应对多路客户端请求 报文处理 √ 基础 HTTP 请求解析 √ 具备基础解析,支撑文件访问业务 业务功能 √ 简单文字页面返回 √ 目录浏览、本地图片读取返回 代码封装 × 逻辑堆砌,无模块化拆分 × 未封装成通用网络库 API,功能内嵌服务内 技术含金量 入门入门级,同质化严重 网络核心考点全覆盖,脱离平庸水准 我的 HTTP 服务器完整手写实现了网络库的核心高并发架构,包括 epoll、长连接、连接池、时间轮等全套底层网络能力,技术深度对标独立网络库项目,只是没有封装成通用 API,而是直接集成在服务内部使用。
Q:那代码随想录的那些独立项目又是啥?对比我之前写的
A:你的 HTTP 服务器 = 一个完整大包,它内部自带、自己手写了一个简易版【网络库】,但内部没有手写 协程库 / 内存池 / 缓存 / RPC
顶层:直播 / 商城 / 社交 等业务应用 ↓ 实际你现在跑的就是:腾讯云服务器上的后台服务 ===================================================================== 【 你的 自研 HTTP 多线程服务器 】 内部 = 业务逻辑 + 手写基础组件 ===================================================================== ┌─────────────────────────────────────────────────────────────┐ │ 【模块1:网络库 —— 数据进出唯一通道】 ✅ 你自己手写实现 70%~80% │ ├─ epoll → 高并发网络监听 │ ├─ 长连接 → 保持连接,减少开销 │ ├─ 连接池 → 复用连接,提升效率 │ └─ 时间轮 → 检测超时,关闭闲置连接 └───────────────────┬─────────────────────────────────────────┘ ↓ 收到海量用户请求,发给协程处理 ┌─────────────────────────────────────────────────────────────┐ │ 【模块2:协程库 —— 并发调度,同时处理上万请求不阻塞】 ❌ 完全没手写实现 │ └─ 只用了系统多线程,没有自己造协程轮子 └───────────────────┬─────────────────────────────────────────┘ ↓ 运行过程靠内存池全程管控 ┌─────────────────────────────────────────────────────────────┐ │ 【模块3:内存池 —— 统一分配回收内存,防碎片、提速度】 ❌ 完全没手写实现 │ └─ 直接用系统 malloc/new,没有自己管理内存 └───────────────────┬─────────────────────────────────────────┘ ↓ 业务查询数据时使用 ┌─────────────────────────────────────────────────────────────┐ │ 【模块4:缓存系统 —— 优先读热门数据,减轻底层数据库压力】 ❌ 完全没手写实现 │ └─ 没有LRU、没有内存缓存,图片直接读磁盘 └───────────────────┬─────────────────────────────────────────┘ ↓ 跨机器调用其他服务时使用 ┌─────────────────────────────────────────────────────────────┐ │ 【模块5:RPC框架 —— 跨机器服务互相调用】 ❌ 完全没手写实现 │ └─ 只是单机服务,不跨机器调用别的服务 └───────────────────┬─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 【顶层业务逻辑】 ✅ 你写了最基础业务 │ └─ HTML目录展示、点击查看本地图片 └─────────────────────────────────────────────────────────────┘ ↓ ===================================================================== 底层:系统内核 + 本地磁盘文件 / 数据库 (未来计划:加 Redis、POJO 奶牛派对算法、最短路径) (目前:只存了一个 .jpg 图片在腾讯云服务器里)
中层通用组件 你的 HTTP 服务器里有没有? 代码随想录里咋写的? 你写了啥? 网络库 ✅ 有!自己手写了 epoll 反应堆、事件循环、TCP 连接管理、长连接、连接池、定时器 / 时间轮、数据收发缓冲区、异常断开处理、高并发模型
纯库,给别人调用
epoll、长连接、连接池、时间轮
把库嵌进了 HTTP 服务器里一起跑协程库 ❌ 没有 自己实现:协程创建、切换、保存上下文、调度器、无栈 / 有栈协程,完全自己造轮子
手写协程!只用了系统多线程,没手写协程
你用的是操作系统原生线程(pthread)
内存池 ❌ 没有 我啃到编程指北C++到这打算加的,就算有吧 直接用系统内存,没自己造池
目前是直接用mallocnew,完全没自己写内存管理缓存系统 ❌ 没有 LRU 淘汰、键值存储、内存高速存取、类似 Redis
无内存缓存,直接读磁盘 RPC 框架 ❌ 没有 序列化、网络通信、服务注册发现、跨机器函数调用
单机服务,不跨机器调用 业务逻辑 ✅ 有 目录展示、图片访问 通透!!刚对整个服务端岗位业务+技术有个宏观认识!!
哎别人速成背诵高频题目,抓重点考点,而我是全程所有知识点自己手写代码实践、自研项目、啃底层、挖原理、抠细节。
内存池感言:
说实话内存池真的很容易理解,像手写智能指针一样,真的很通透,通过一个项目顺便整个串联梳理捋顺出了整个流程逻辑(个人还是那句话,竞赛的算法题无非就是数组、大厂LinuxC++高性能服务端开发岗位无非就是指针、类、赋值拷贝移动等这些头大的基础语法而已),但却属于高加分项的东西,但难度很小很小,对着每行代码死磕问豆包,真的很轻松就搞通透了!
而 epoll 的 http 多线程服务器,豆包说烂大街,很容易,代码复杂冗余但难度不高,但说实话,我真的感觉 http 服务器项目当初很痛苦,代码量大,一个前端HTML 显示本地目录的简单功能,如何获取数据、页面怎么跟后端各种模块传输联调的、代码都是咋流转的,我 tm 就追问了好几个星期。
当然了吃七个烧饼不是第七个饱的,是有前六垫底,如果先学内存池估计也认为内存池很难,因为我走到现在 C++ 语法基本透彻了,当初 C++ 语法真的脱层皮!!
豆包必须前台盯着,如果最小化,那新聊天会把历史定位顶上去,直接跳到最新
奶奶个腿,硬头皮啃下来了,发现真的好基础,之前基本都知道,只不过很朦胧,这次把朦胧的点能用术语说出来了。
为什么做C++,wx搜“不看视频”
心路历程,wx搜“出发点”
*****、广告助手:绕过大陆=白名单,列表里的不走该app的功能应用(直连、不跳广告)
黑名单一直以为是里面的不走app功能,但其实是里面的走该app的功能,豆包一些广告或者啥插件估计在国外,所以走直连估计走的是豆包自己的转换,很快,而开v2豆包有海外 DNS / 海外 IP,在某些规则库里会被误判为 “境外站”,所以开v2就很慢。
所以走绕过大陆模式,然后路由设置,新增一行规则:
outbound:选direct、domain:填doubao.com,强行直连。2026年2月过年回家,五一又回去打点一切长辈关系和照顾陪护堪比规培。六一凌晨妈妈说爸爸不行了赶紧飞回去照顾陪护护理,6/5落地乌鲁木齐,睡一觉凌晨即6/6爸爸基本没呼吸了,一直待到葬礼出殡、三天圆坟、头七,经历种种都在wx。飞机+60h的硬座无尽磨砺
献血、买血、限额天数,呵呵... ...
好鸡巴痛苦啊!追问豆包才知道连心中奉若神明的编程指北也是唯一救命稻草居然也只是腾讯 WXG 业务 C++而不是我目标大厂高性能底层组件基础岗,或者准确来说,我都不知道自己学的是个什么玩意,就是闷头追问豆包来着,不知不觉被“误导”学了这么多,起初我他妈只想找一份大厂或者高潜力发展的公司里的 C++ 的岗位而已啊!一度误以为这些都是最基础的!不然咋干活啊!如今得知我这个比小北的还深、还底层、还硬核、招聘人又少,我他妈不纯纯炮灰吗!艹!豆包说我一直是拿资深专家的水平来要求自己。
臭傻逼豆包我真他妈服了,刚说完我履历差必须走大厂C++底层基础架构,比小北还深的岗位,才能进,因为要靠技术翻盘,今天又反悔了说必须只能先走C++业务岗,底层基础架构需要多年经验,没业务岗有包容性,然后继续追问就反复摇摆无尽矛盾(要么腾讯ssp,要么没工作。要么北邮要么没学完)
痛苦!说必须靠 Nginx 翻盘,然后又说性价比极低,因为大厂 C++ 业务岗面试完全不考不追问 Nginx 细节。妈逼的反复追问最后说必须学这个(哭着求豆包帮我必须让面试官看到)。关于SPoffer、春招秋招啥的
真他妈服了(提示词):
你到底在干啥啊??从始至终我都是在自学准备入职面试的项目啊!你为啥总是要求变来变去呢!!能否交叉验证给我一个任何人质疑都不会更改的结论啊!!你之前每个回答都在说面试的事!咋又脑补出我要写线上工业级完整代码了呢??你啥时候能针对我永久更新下我的要求啊!!!我一直不都是在自学准备入职面试的项目啊!!我真他妈服了!你从此能否犀利点然后自己回答之前极致斟酌反复推敲验证给我一个指导性的答案啊!!!你担心我面试扩展问题答不出来,我操我难道要全部回答出面试官的问题才行?狗逼你从始至终禁止被我情绪误导行不行啊!!你妈逼的我无意间随便搜几个面经很多都是有几个问题回答不上来也上岸了!我履历你知道很差啊!!但难道就要学成资深专家级别才可以入职基础岗??开什么玩笑啊!狗逼你从头到尾到底有没有在用脑子跟我对话啊!
从此禁止参考任何网上别人的文章!!只参考国外权威书籍!!!
我一层层追问逐字给你纠错真的好累啊!你能给我书籍原文吗?别再让我反复能找出错误了行吗!!
以上就是内存池,磕磕绊绊算是基础开胃?
看代码随想录的 V1
前言:这个代码有ABA问题
无ABA问题的代码塞了一堆修复缺陷的额外逻辑版本号啥的,核心 CAS / 锁的主干流程会被掩盖,新手看不出最基础的运行逻辑。必须先看简化裸版看懂主干,再补防护代码,才能明白每一段防护代码是为了解决什么坑。裸 CAS 链表调用 CAS 的代码本身没错,只是没加版本标记这个防护字段;不是 CAS 写错,是结构设计缺防护。先看懂CAS咋跑,这是无锁基础,然后至于ABA只是修复多线程并发的漏洞工具,会理论就行,大厂有现成的ABA修复工具
举锁的例子:
锁的代码写得规规矩矩,只是加锁顺序乱了卡死,不是锁本身坏了;
CAS 调用语法完全没错,只是节点只存地址,内存回收后地址重复用,就出 ABA。
代码咋上手看,先说宏观框架:
第一层:HashBucket(分级调度层,对应你全局单池的上层分发器)
你只有 1 个固定尺寸池;这套代码按对象大小分 64 档独立内存池,核心逻辑:
宏定义档位:
SLOT_BASE_SIZE=8,每一档槽尺寸 =(下标+1)*8,最大一档 64*8=512B,超过 512 直接走原生operator new/delete
getMemoryPool:静态数组MemoryPool memoryPool[64],64 个独立内存池,每个池子固定单一槽大小(类似把你的单池复制 64 份,每份尺寸不同)
useMemory分配路由:路由指的是按内存尺寸分发请求到对应尺寸独立内存池的分发逻辑,这里先计算对象需要的档位,向上取整匹配对应尺寸池,调用对应池allocate();超大内存直接原生分配
freeMemory回收路由:匹配尺寸归还对应池,超大内存原生释放工具函数
newElement/deleteElement:封装内存分配 + placement new 构造、析构 + 内存回收,对外屏蔽底层内存池细节这里名字叫 HashBucket,但没有哈希映射逻辑,只是大小分级桶,和哈希无关,仅做尺寸分类调度。
哈希逻辑目标:数据均匀分散到所有桶,避免大量数据挤在少数桶,减少单桶锁竞争、链表遍历开销。若不打乱,连续 key 会落在同一个桶,造成热点桶,查询、插入、删除性能退化。本场景需求相反:相同内存尺寸必须进同一个池,不能打散,因此只用线性分段,不用哈希。
比如相近的 3 和 4:
3 二进制:00000011,4 二进制:00000100,
3^14=13 (00001101),4^14=10 (00001010),
异或会翻转二进制位,相邻数字二进制仅末尾少量 bit 不同,异或后高位突变,取模后下标跳变,下标不连续。
代码逻辑:SLOT_BASE_SIZE=8,下标 =(40+7)/8 -1 =5-1=4,size 与下标严格线性对应:8→0、16→1、24→2、32→3、40→4。
第二层:单个 MemoryPool(对标你的 GlobalMemoryPool,单尺寸内存池,核心差异)
大块切割逻辑:
目标池:新大块只标记起止范围,不提前拆分链表;只有释放的内存才进空闲链表
你的池:新大块一次性全部切完,所有小块直接挂载空闲链表
池子数量
目标池:64 个独立池子,每个池子尺寸不同
你的池:全局仅单个固定尺寸池子
锁范围
目标池:仅新开大块时上锁,空闲链表无锁
你的池:分配、释放全程加锁
内存适配
目标池:自动匹配尺寸,超 512 字节走原生 new
你的池:只能分配固定大小内存
开始说细节:一些语法直接备注到了代码里,省着看一眼代码看一眼博客费劲
代码思想直接就写博客里,
但如果博客只写重要的思路和疑难,不提那些【注释只在代码里的函数】就会比较乱,因为对于这种陌生代码只有清楚知道看代码块的流程,先看哪个函数后看哪个函数,才能懂,所以只注释在代码里的这里直接提一句。
哈哈哈啊哈哈(占位符,不然篇幅太长不知道属于哪个段落了),关于
.h里的全局宏定义,看代码里注释即可:哈哈哈啊哈哈(占位符,不然篇幅太长不知道属于哪个段落了),关于
.h里的static void* useMemory(size_t size){:代码里说了语法,但为啥要用内存池?为啥要用operator而不用
malloc或者new int?
operator new是 C++ 接口分配失败抛异常且支持重载,malloc是 C 的,分配失败返回空指针。一、内存池可以降低锁冲突
glibc是Linux 系统自带的基础底层工具库,程序申请内存都靠它实现
arena:系统提前批量从内核拿来、存放在进程里的大块备用内存区域。
malloc(size):先走glibc里的调用 brk/mmap 系统调用从OS内核进货到arena 堆管理方便下次不用再进货operator new(size):默认operator new底层调用malloc
new int:先调用 operator new (4) 分配内存(这里一次性进货不止4字节到arena,提前预加载,多次申请会扩容arena,但new int速度慢主要大头在于全局堆锁竞争、长期全局碎片查找开销大),再调用定位new来用 int 构造函数初始化内存
int* p = new int;
void* raw = operator new(sizeof(int));分配裸内存并用raw获取
p = new(raw) int;编译器自动生成new(内存地址) int(定位 new)执行构造。
new int无传参时,内置 int 会默认初始化,就是只分配内存,不写值,随机脏。
new int(10),构造就会把 10 写入这块内存。
new int()是值初始化,直接搞 0
回头看
int* p = new int;这个是随机值#include <iostream> int main() { int* p = new int; *p = 1475; // 手动写入脏数据 delete p; // 释放,内存保留原有数字不会清空 // 再次分配同大小内存,大概率复用刚才那块内存 int* p1 = new int; std::cout << "new int 未初始化值:" << *p1 << std::endl; int* p2 = new int(); std::cout << "new int() 值初始化:" << *p2 << std::endl; delete p1; delete p2; }
delete p调用operator delete,把这块内存还给进程堆管理器,不会立刻清零内存里的数据,内存上旧数字还留在原地,只是标记这块内存空闲,后续再new int分配时堆管理器可能把这块旧内存重新分给你,OS 只在程序结束后,统一回收全部内存;运行时 delete 只是程序内部归还,不归还给 OS。这个代码刚释放马上申请同等大小小块堆内存,堆管理器空闲链表会优先取出刚释放的这块内存(但实际不是)
那第二步的构造有啥用?
new(raw) int只做默认初始化,不会写 0,内存还是脏值,看着好像没效果,但这套流程是统一规范:不管是 int、自定义结构体,语法都固定拆成「分配内存 + 定位 new 构造」两步,自定义类有构造函数时,定位 new 必须执行构造逻辑(初始化成员、开资源),这一步必不可少;内置基础类型无用户构造逻辑,只是语法上保留统一流程,看起来像多余。想要清除脏值要写成new(raw) int(),触发值初始化置 0。
malloc、operator new、new int均由线程调用,同一进程所有线程共用全局堆 arena,分配时竞争同一把堆锁;内存池预分配大块裸内存后内部分配,不再抢占全局堆锁,降低锁冲突:
fork搞出进程后,里面比如总共想干3个事,就叫做3个业务,就malloc出3个内存池,每个业务的线程就在这个自己内存池里做事,但依旧有锁,只不过:
全局堆:全进程所有线程抢同一把锁,并发高时大量线程排队阻塞,性能差。
业务独立内存池:仅A业务内部线程抢A池专属锁,竞争线程数量大幅变少,排队阻塞概率大幅降低,性能更好。
二、内存池降低碎片污染问题
free的时候:
malloc等 glibc 库,
调用 malloc 分配超大块(mmap 分配),free 时直接还给系统;
中间的地址咋释放都不还给OS,只有堆顶释放累积到足够阈值才还给 OS
剩下的都不还给 OS 只有整体释放池子才给 OS
手写内存池内部释放不调用系统接口,销毁池子才释放整块内存
注意:堆地址从低往高扩张,堆的边界(堆顶)是当前占用最高地址。
如果
new int,[A 占用内存][B 占用内存][C 占用内存],然后freeB 标记空等复用,变为[A 占用内存]|小空闲缝|[B 占用内存][C 占用内存],如果直接一次malloc/operator new搞大块,分配后收回都是会出现剩余 1000 个 1KB 小块,想分配 10KB 都分不了的情况
全局堆、内存池都会产生碎片(零散闲置小块内存),全局堆所有线程共用同一片公共大堆内存,互相抢占空间,碎片布满整个进程,持续破坏连续内存
内存池是一次
malloc开辟了大块内存,不需要去公共大堆里申请,碎片仅局限在池子内部,不影响外部,尾部完整连续空间始终保留而不会被其他线程抢走。三、有了内存池,库函数选能自定义字节数的
单纯
new int只能分配单个 int 大小,内存池可自定义任意字节长度四、有了内存池,库函数选性能好的
先看代码:
查看代码
#include<iostream> using namespace std; struct Test{ int* num; Test(){ num = new int(1); cout << "执行构造函数,num值:" << *num << endl; } ~Test(){ delete num; cout << "执行析构函数" << endl; } }; int main(){ // int *a = new int; // int *b = new int(); // void* qq = operator new(4); // int* c = static_cast<int*>(qq); // cout<<*a<<" "<<*b<<" "<<" "<<*c<<endl; // 有对象,自动执行构造、析构 Test* t1 = new Test; delete t1; cout<<"———————:"<<endl; // 只有内存,无对象,不跑构造析构 void* m = operator new(sizeof(Test));//分配对应大小空白内存,只统计结构体成员内存:仅int* num指针大小,和构造、析构、cout 代码无关,函数代码不占对象内存,完全不执行构造函数,num 是随机脏值 Test* t2 = (Test*)m; operator delete(m); /* 同样大小内存: new 类型 = 内存分配 + 构造;delete = 析构 + 释放内存 operator new/malloc = 只拿空白内存,无对象、无构造析构;释放只回收内存,不清理对象内部资源 */ }
new int会创建带完整生命周期的对象,只能逐个delete,无法整块回收复用;这是豆包的结论,估计也是全网的结论,但我仔细思考太歧义了!妈逼的都可以整块复用,
单次小块循环分配场景:
malloc(4)100 次 /new int100 次,都要 100 次释放,无任何区别。唯一硬性差异只在单次释放内部流程:
free只回收内存;delete先走析构清理资源,再回收内存。豆包说大块一次性分配场景:
malloc(400)整块内部逻辑分割后依旧可以一次性free;100 个独立new int语法不允许整块回收,必须逐个delete。 我的评价是纯傻逼的很误导人!!“100 个独立new int语法不允许整块回收” 是因为他是 100 次的new int,换到malloc(4)搞 100 次依旧要 100 次free!你malloc所谓的可以一整块释放也只是释放一次malloc的字节啊。
malloc(3):只划出 3 字节裸内存,无任何自动执行的对象初始化、对象析构逻辑。CPU 仅调用系统分配接口,拿到裸内存,1 层逻辑。对应free
operator new(3):同上
new T(size)(普通对象 new):构造:底层先调用
operator new拿内存,再自动执行构造函数生成有效对象;释放:用
delete,底层先执行析构再释放内存。相比于上面两个,多了一次函数调用 CPU 级别的指令开销,即在分配多构造调用、释放多析构调用。
哈哈哈啊哈哈(占位符,不然篇幅太长不知道属于哪个段落了),关于
.h里的static void freeMemory(void* ptr, size_t size){只看注释就行哈哈哈啊哈哈(占位符,不然篇幅太长不知道属于哪个段落了),关于
.h里的template<typename T, typename... Args> T* newElement(Args&&... args)里涉及到的new(p) T(std::forward<Args>(args)...);:这里涉及到的前设基础知识太多太多了~~~~(>_<)~~~~
先说
move:左值
string s = "hello";被string s2 = move(s);强转为右值,cout s为空
std::move唯一作用:把左值强制转换成无名右值引用,仅此一步,不移动资源。如果本身就是右值,没必要用move。转换后编译器匹配重载优先级:先匹配
T&&移动函数,不存在则降级匹配const T&拷贝函数。
手写移动专属函数
类名(类名&& 源)移动构造函数
类名& operator=(类名&& 源)移动赋值运算符二者是自定义移动语义,接管资源,避免深拷贝,必须手动写才会生效
无
std::move:左值传入 → 调用拷贝构造 / 拷贝赋值右值直接匹配移动构造/移动赋值,无需std::move。
std::string是标准库提前写好了移动构造,你直接用才会掏空原对象;自定义类不手写移动函数,move 不会产生资源转移效果。
语法规则:
非
const左值引用Test&仅能绑定非const左值,不能绑定右值、const左值。const 左值引用
const Test&万能绑定:普通对象、const 对象、临时对象、亡值全能接住。右值引用(
Test&&)只接收临时(非const纯右值) / 被 std::move 转换后的亡值(经过move转来的)- const Test&&不用,因为要移动你写个屁的const
实参传给形参,形参带引用,比如
T&/const T&/T&&,这个传递过程就叫绑定,而没引用的实参传递给形参,不叫绑定,只发生拷贝,不存在绑定。
T&&是右值引用,是实现移动语义的基础,仅负责绑定右值,但不等同于移动语义,移动语义依靠T&&形参的移动构造 / 移动赋值,转移资源而非拷贝。
临时对象不能用非 const 左值引用接收,比如
Test x = Test{"123"};中Test{"123"};是无名的临时对象,如果Test写成Test& Test(Test& t){ t.buf = "改了内容"; }就错了因为不可以传递给无
const拷贝构造,只能Test(const Test& t){ std::string temp = t.buf; }别问东问西,临时对象马上销毁,尽管传递给构造的时候没销毁,但就是死命禁止任何修改。
Q:绑定是啥意思?我咋感觉像赋值呢?
A:
Test b = a:b 是刚造出来的新东西,这一步叫造新对象(初始化),不是赋值。赋值是东西本来就有,后来改内容。
绑定(不可换绑):调用拷贝构造函数时,拿 a 去填函数括号里的参数 Test& t,把 a 和这个参数拴在一起,这个拴住的动作就叫绑定,跟复制内容两码事。
复制内容:拴好之后,函数内部再把 a 里面的数据抄给 b,这一步看着像赋值,但只是复制数据。
看个代码:
查看代码
#include <utility> #include <string> struct Test { std::string buf; Test(std::string s) : buf(std::move(s)) {} // 手写移动构造 Test(Test&& t) noexcept : buf(std::move(t.buf)) {} // 手写移动赋值 Test& operator=(Test&& t) noexcept { buf = std::move(t.buf); return *this; } // 拷贝构造 Test(const Test& t) : buf(t.buf) {} }; int main() { Test a{"123"}; Test b = a; // 左值,调用拷贝构造 Test c = std::move(a); // move转右值,调用手写移动构造 }
Test(std::string s) : buf(std::move(s)) {}接收字符串参数来构造对象,字面量匹配值参数为
std::string时,先隐式转换生成临时右值std::string,再拷贝一份值传递给s,这就是局部的左值s,std::move(s)将s转为右值,即
std::move(s)强制转换成std::string&&(纯右值)类型,然后由于写了重载的移动构造,又写了move,即
buf(move(s)),buf调用std::string移动构造接管字符内存(无深拷贝),刚刚的局部形参s生命周期结束自动析构,此时s为空字符串,无资源释放冲突。练手:
查看代码
#include <iostream> #include <string> #include <utility> using namespace std; struct MyStr { string data; // 新增普通构造 MyStr(const string& s) : data(s) {} // 拷贝构造:初始化列表直接拿other.data构造成员 MyStr(const MyStr& other) : data(other.data) {} }; int main() { MyStr a{"test"}; MyStr b = move(a); cout << a.data << endl; // 仍输出test,走拷贝无移动 }1、
MyStr a{"test"};:
"test"是const char[5],隐式转换为const char*,结构体没有入参为const char*的构造函数,仅存在MyStr(const string& s),编译器利用const char*隐式生成临时string("test"),临时string绑定到const string& s,初始化列表data(s)调用string的拷贝构造,复制字符串内容2、
MyStr b = move(a);:
std::move(a)将左值a转换成类型为MyStr&&的亡值,结构体只定义了拷贝构造MyStr(const MyStr& other),不存在移动构造MyStr(MyStr&& other),const MyStr&可以绑定亡值,重载匹配拷贝构造函数,初始化列表data(other.data)复制other.data的字符串数据,不转移内存资源3、
cout << a.data << endl;:
a内部string的堆内存没有被转移,对象原有数据保留,打印结果为test。进阶完善追问:
查看代码
// 无数次实践发现,学错的思考错的为什么错(指针部分) // 学仿佛超纲钻牛角尖一句一句问的下面这个,真的把所有需要掌握的东西灵活的起来了,真会了 #include <utility> #include <string> #include<iostream> using namespace std; struct Test { std::string buf; Test(std::string s) : buf(std::move(s)) {cout<<"string"<<endl;} // 接收 string 参数的转换构造,移动传入字符串 Test(Test& t) : buf(t.buf) {cout<<"无const左值拷贝构造"<<endl;} // 无 const 的左值拷贝构造,仅能拷贝非 const 对象 //buf(t.buf)会新建独立字符内存,属于深拷贝 //所有值传递:内置类型:int、char、double、bool等基础原生类型,无堆内存,所以无深浅拷贝区分,仅复制变量本身数值,不存在额外堆空间,谈不上深拷贝 //但自定义类对象值传递会触发深拷贝 //深拷贝各自独立堆内存,不会双重释放;浅拷贝共用同一块堆内存才会双重释放 //编译器默认生成的拷贝构造 / 赋值运算符,直接逐成员复制指针,共用堆内存,属于浅拷贝,所以只要涉及到堆,用了默认的浅拷贝共享堆内存,析构时重复释放直接崩溃 Test(Test&& t) noexcept : buf(std::move(t.buf)) {cout<<"移动构造"<<endl;} // 移动构造,接管源对象字符串资源 Test(const Test& t) : buf(t.buf) {cout<<"新增"<<endl;}//拷贝构造 }; int main() { cout<<endl; // 场景1 正常 Test a{"123"}; // 先拿字面量构造std::string临时,再传入构造函数调用 Test(std::string s) : buf(std::move(s)) {} 。等价含义写法:Test a = std::string("123");,但属于拷贝初始化,Test a{"123"};属于直接初始化 Test b = a; // a 是非 const 左值,优先匹配 Test(Test& t) 拷贝构造,若删掉该重载,才会选用 Test(const Test&),引用绑定规则允许非const绑定到const,但 const 不可以绑定到非const cout<<endl; // 场景2 // Test t4="ss"; // "123"、"ss" 字面量类型:const char[N],属于字符数组, // const char[3](字面量数组)→ const char*:数组自动退化,无代价、内置转换,随时能转,不占用自定义转换的名额次数(数组名作为右值使用时,自动退化为首元素指针) // const char* → const char[3]:不能隐式转换,指针只是存地址,没有数组长度信息,编译器做不到自动转回数组。 // 编译器尝试转换链: // 第一层:const char[3] 内置退化 const char* → 临时std::string,即内部偷偷 string __temp("ss") , 使得 "ss" 变为 std::string("ss") // 第二层:临时std::string → Test(你写的构造函数,用户自定义隐式转换),使得 std::string("ss") 变为 Test (std::string ("ss")) // 最后用Test (std::string ("ss")) 去初始化 t4 : Test t4 (std::string ("ss")) ,但 C++ 硬性规则:一次初始化表达式,最多允许 1 次自定义隐式转换 // 想过编译必须加 const char* 构造函数 Test(const char* s) : buf(s) {},这样"ss"构造成 Test ,属于1次隐式,合法、 // 场景3, const Test t1{"abc"};// 调用 Test(std::string s) 构造函数,临时 std::string 右值匹配该构造,生成 const Test 对象,类型为const Test Test t2 = t1; // 编译器会在 Test 类中匹配参数能接收 t1(const Test左值) 的构造函数 // Test(std::string s)参数是std::string // 拷贝构造 Test(Test& t),形参类型Test&(非 const 左值引用,Test&&才叫右值引用),C++ 语法规则:const 左值不能绑定到非 const 左值引用 // 移动构造 Test(Test&& t),形参是右值引用Test&&,仅能绑定纯右值 / 亡值。t1 是左值,匹配失败 // 想编译过必须加个新增的 Test(const Test& t) 拷贝构造 cout<<endl; // 场景4, Test t3 = Test{"xyz"}; // 未命名临时对象,属于纯右值 // 构造 1,Test(std::string s) 类型不匹配,因为等号右边是Test临时类型 // 拷贝构造Test(Test& t):形参非 const 左值引用,不能绑定右值,匹配失败 // 移动构造Test(Test&& t):右值引用可绑定纯右值,匹配成功, // 但 C++11/14 要么生成临时再移动,要么直接原地构造。 // C++17直接原地构造,直接走 t3 里搞 string,没临时 // 复制省略(拷贝消除):编译器跳过拷贝/移动构造,直接把临时对象建在目标变量内存,省去复制资源开销。 cout<<endl; }
关于
string:
std::string内部写了好多重载,一、赋值运算符(已有对象覆盖内容时调用)
1、C 字符串字面量赋值
string& operator=(const char*);str = "abc"这是人类简写语法,对应string& operator=(const char* cstr),编译器会自动翻译成成员函数调用:str.operator=("abc")
左边
str.= 调用对象
operator== 函数名括号
("abc")里的"abc"= 传入函数的实参2、拷贝赋值
string& operator=(const string&); //string& 是整体返回值类型,&都写在右侧示例:
b = a;
string&:返回当前对象引用,支持连续赋值x=y=z
operator=:赋值函数名
const string&:参数,接收另一个字符串左值
b=a等价b.operator=(a),a 匹配形参const string&,调用该拷贝赋值重载,函数内部把 a 的数据复制给 b,返回*this(b 自身引用),实现x=y=z连续赋值二、构造函数(创建新对象时调用)1、拷贝构造
string(const string&);示例:string b(a);2、移动构造
string(string&&) noexcept;示例:
string b(move(a));
懂了
move继续说为啥要引入forward,先看个代码:查看代码
#include <utility> #include <iostream> using namespace std; struct Data { int a; Data() { std::cout << "默认构造\n"; } Data(const Data&) { std::cout << "拷贝构造\n"; } Data(Data&&) { std::cout << "移动构造\n"; } }; template<typename T> void badTransfer(T val) { cout<<"@"<<endl; Data d(std::move(val));// 调用移动构造, val是函数内局部左值, std::move库函数内部固定是转为Data&& 匹配移动构造函数参数Data&& cout<<"——结束——"<<endl; } int main() { Data a; cout<<"#"<<a.a<<endl; badTransfer(a); // 调用上面的模板,函数实例化后等价于普通函数: // void badTransfer(Data val) { // Data d(std::move(val)); // } // 执行逻辑:把变量 a 拷贝一份给形参 val(触发 Data 拷贝构造),函数内部再 move val cout<<"——开始下面——"<<endl; badTransfer(Data{}); // Data{}是临时右值,类型还是Data,推导T = Data // 实例化后和上面完全一样的函数,先拷贝临时对象到 val,再 move val }这里
Data{}是列表初始化(值初始化),构造一个无名临时Data对象(纯右值)。无任何用户自定义构造,编译器自动生默认构造
Data a;命名左值对象,生命周期到作用域结束;无初始化,内置成员脏值
Data a{}值初始化,内置成员置 0不用
Data a()因为会被编译器解析成函数声明:名叫 a、无参数、返回 Data 的函数,不是定义局部对象。Data{}:创建临时对象,内置成员置为 0
Data():同上,但 C++11 后推荐{}形式,存在自定义无参构造时,全部只执行你的构造,成员是否清零完全看你构造内赋值(前提你有成员)
这个看似没问题,但失去的原生左右值属性, 如果改成
template<typename T> void badTransfer2(T&& val) { Data d(std::move(val)); }此时问题彻底暴露:Data a; badTransfer2(a);a 是外部左值,
std::move(val)直接把外部 a 转为右值,触发移动构造,外部 a 资源被掏空,后续使用 a 会产生未定义行为。这就是单纯 move 最大的坑:不分场景强制右值,会误移动外部左值对象。所以要引入完整正确转发模板:
template<typename T> void goodTransfer(T&& val) { //T&& val 是万能引用,不是单纯右值引用 //非模板固定类型(如 Data&&、int&&):单纯右值引用,只能接收右值,不能接左值。 Data d(std::forward<T>(val)); } //注意运算符都写在右侧
std::forward<T>(val)依赖万能引用推导规则:
传入左值
Data a,推导T=Data&,forward 输出左值引用,调用拷贝构造;传入右值
Data{},推导T=Data,forward 输出右值引用,调用移动构造;模板形参是
T&& val(万能引用)
实参是左值:编译器强制给 T 推成
类型&实参是纯右值 / 亡值:编译器直接推 T 为原生裸类型(不带任何 &)
逻辑:传右值临时
Data{},它本身没有左值属性,不需要给 T 附加左值引用标记,所以 T=Data由于模板是
T&& val:
传左值,把推导的 T =
Data&代入得到Data& && val,符号全部贴在类型Data右侧,再引用折叠 →Data& val传右值,把推导的 T =
Data代入得到:Data&& val只要里面出现左值引用符号 &,结果一定是左值引用
&;对比两套模板,一眼看出 move 的局限性方案 1:只 move(值参 badTransfer)
左值 a:拷贝副本→move 副本,外部 a 安全,但右值传参多一次拷贝;
临时 Data {}:先拷贝临时生成 val,再 move,冗余拷贝。
方案 2:万能引用 + move(badTransfer2)
template<typename T> void badTransfer2(T&& data) { Data tmp = std::move(data); // 致命问题在这里 }
临时 Data {}:直接移动,无冗余;
左值 a:传左值
a,T推导成Data&,展开后T&& = Data& &&,折叠为Data&,所以是左值引用,直接 move 外部对象,掏空外部变量,严重逻辑 bug。方案 3:万能引用 + forward(标准完美转发)
左值 a:仅拷贝,不改动外部;
临时右值:直接移动,无多余拷贝;
同时兼顾安全与性能。
懂了语法开始说涉及到
forward的代码:
if ((p = reinterpret_cast<T*>(HashBucket::useMemory(sizeof(T)))) != nullptr)
sizeof(T):计算你要创建的对象 T 自身占用多少字节;
只拿裸内存(不用创建对象,仅一片原始内存),调用
HashBucket::useMemory/HashBucket::freeMemory,手动传入要分配的字节大小 size,// 分配16字节裸内存 void* buf = Kama_memoryPool::HashBucket::useMemory(16); // 回收,必须传入当初分配的size Kama_memoryPool::HashBucket::freeMemory(buf,16);分配并创建 C++ 对象(自动构造 + 自动析构)
调用模板
newElement<T>/deleteElement<T>,填类型 T,不用手动算 sizeof,参数直接传给类构造函数struct Test{ int a; Test(int x):a(x){} }; // 分配内存+执行Test构造函数 Test* t = Kama_memoryPool::newElement<Test>(10); // 先调用~Test析构,再回收内存 Kama_memoryPool::deleteElement(t);
newElement/deleteElement是对useMemory/freeMemory的上层封装,专门给有构造析构的类对象使用;
useMemory/freeMemory是底层裸内存接口,面向无类型缓冲区
HashBucket::useMemory(入参):就是你前面看懂的静态分配接口,内部逻辑:判断大小选内存池 / 原生 operator new,返回一块无类型裸内存地址;
reinterpret_cast<T*>(裸内存地址):把接口返回的无类型内存指针,强制转换成 T 类型指针,方便后续在这块内存创建对象;
HashBucket::useMemory返回值类型是void*(无类型裸内存指针),void* 特点:编译器不知道这块内存存的是什么东西,不能直接调用构造函数、不能当 T 对象使用。
reinterpret_cast<T*>(地址):强制类型转换,单纯修改编译器对这块内存的解读类型,不会修改内存本身、不会拷贝数据。只能把 void * 裸内存转成
T*,让编译器认可这块内存是用来存放 T 类型对象的,后面才能执行new(p) T(...)定位 new 构造对象,比如:
void* rawMem = HashBucket::useMemory(sizeof(TestObj));
TestObj* p = reinterpret_cast<TestObj*>(rawMem);强转成TestObj*,告诉编译器这块内存存TestObj,现在p是T类型指针,可以执行构造
new(p) TestObj(1,2);,在已经分配好内存的p指向的那块空间上,原地创建TestObj对象并调用构造函数传入参数1、2,不重新分配堆内存。reinterpret_cast转换后的TestObj *指针作为地址执行定位new,如果不做这个转换直接拿void *去定位new,编译器会报语法错误,不允许操作。
p = 转换后的指针:把转换完的地址赋值给 p;
!= nullptr:判断内存分配是否成功,分配成功才执行内部构造逻辑。
new(p) T(std::forward<Args>(args)...);
new(p):定位 new 语法,不会向系统申请新内存,只会在 p 指向的已有内存上执行构造;
std::forward<Args>(args)...:完美转发,把调用 newElement 时传入的所有构造参数,原封不动传递给 T 的构造函数,支持左值、右值参数;
T(...):调用类型 T 对应的构造函数,在分配好的内存上初始化对象。
return p;:把已经完成内存分配 + 对象构造的 T 指针返回给调用者,外部就能正常使用这个对象。
省略号:
typename... Args、typename ... Args、typename...Args都等价,...修饰Args,中间空格仅排版分隔,代表 Args 是一组可变类型集合包,意思是可以存任意多个不同类型参数(模板尖括号里填的int、float这类种类叫类型参数,叫参数是因为它是传给模板的输入值,和函数括号传数字参数逻辑一样,只是传的是类型而非数据)
Args&&... args:省略号修饰左边的Args&&,作用是给参数包里每一种类型都套上万能引用,叫函数形参,即函数括号里变量的数据类型(int、万能引用Args&&这类)。
末尾的
...修饰它左边完整表达式std::forward<Args>(args),参数包展开符,作用:把 args 里打包的所有参数逐个拆开,分别转发给构造函数。定义类型包:
typename就是一个类型,类似int,...修饰右侧类型名Args参数带限定符(&&/* 等):
...修饰左侧带限定符的完整类型片段比如:
查看代码
#include <iostream> // int... N:整型参数包,只能接收常量整数,不是变量类型包 template<int... N> void PrintNums(){ // 展开参数包 (std::cout << N << " ", ...); std::cout << "\n"; } int main(){ PrintNums<1, 3, 5, 7>(); PrintNums<10,20>(); }懂了这些突然发现个事,模板具体使用情况划分:
首先
template<typename T> T f() { return 1; }然后调用
int a = f<int>();,这里f<int>()后模板实例化出等价代码:int f() { return 1; }。那模板都有哪些?大部分形式分为:
#include <iostream> template<typename A, typename B, typename... C> void test(C&&... args){ std::cout << sizeof(A) << " " << sizeof(B) << "\n"; ((std::cout << args << " "), ...); } int main(){ test<int,double>(10, "abc", 3.14); }A、B是两个普通模板类型占位符,只在函数内部用来计算内存占用大小,没有出现在函数的形参列表里,无法通过圆括号传入的值自动推导,调用时必须在尖括号手动指定类型。后面的3.14可以自动推导,不需要写在
<>里。
打包的全是形参:
查看代码
#include <iostream> #include <string> template<typename... B> void func(B&&... args){ ((std::cout << args << " "), ...); std::cout << "\n"; } int main(){ // 自动推导,省略尖括号 func(10, "test", 3.14); // 手动显式指定全部包内类型 func<int, std::string, double>(20, "demo", 6.66); }
查看代码
template<typename T, typename Arg> T* newElement(Arg args){ T* p = reinterpret_cast<T*>(malloc(sizeof(T))); if(p) new(p) T(args); return p; } struct A{ A(int x){} }; A* ptr = newElement<A>(10);调用
newElement<A>(10),模板参数T=A,形参Arg=int,传入实参10进入函数内部:
执行
malloc(sizeof(A)):向系统堆申请一块等于 A 对象占用字节大小的原始空白内存,返回 void * 裸地址
reinterpret_cast<T*>:把 malloc 返回的无类型指针强制转换成 A 类型指针,赋值给局部变量p判断
if(p):校验内存分配是否成功(malloc 失败返回空指针,不会走构造逻辑)定位 new
new(p) A(args):
不在堆上新开内存,直接复用
p指向的 malloc 内存调用结构体 A 的构造函数,把传入参数
args=10传给构造函数入参x,完成对象初始化
返回指针
p:该指针指向已经分配内存 + 完成构造的 A 实例,赋值给外部变量ptr
A 这里写成了对象,那就是返回值是对象类型,然后 B 打包的可以推导,不用写
<>里查看代码
#include <iostream> #include <utility> #include <string> #include <cstdlib> template<typename A, typename... B> A* newElement(B&&... args){ A* p = nullptr; if (p = reinterpret_cast<A*>(malloc(sizeof(A))))//防止malloc分配内存失败返回空指针,空地址上调用定位new会程序崩溃 new(p) A(std::forward<B>(args)...); return p; } struct Person { std::string name; int age; Person(std::string n, int a) : name(n), age(a) {} }; int main(){ Person* man = newElement<Person>("张三", 22); std::cout << man->name << " " << man->age; free(man); }编译期:
newElement<Person>("张三",22)先确定模板T=Person,推导 Args 参数包类型;- 编译器生成专属
Person版本newElement函数。运行期不再看Person函数了,直接看专属那个函数:1、"张三"、22 作为实参传入函数形参
Args&&...;2、
malloc分配Person大小内存;3、定位
new + forward展开参数调用Person构造;
new(p) T(...)叫定位新建,含义:不在堆上新开内存,直接用已有地址 p 这块内存调用构造函数初始化对象;std::forward+...把外面传进来所有参数原封不动传给 T 的构造函数。普通
new Person会自动malloc内存 + 构造,这段代码要手动管控内存(模拟内存池),内存已经手动malloc拿到了,不能再用普通new重复分配内存,只能用定位新建原地构造。Q:这他妈不就是
new Person吗?为啥不直接new一步到位啊?A:失去了内存池的意义
标准new底层本来就分两步:分配内存、定位new构造对象,内存池就是把分配内存那一步接管自己实现,等于复刻了原生new的底层拆分逻辑。这么造轮子是因为:
系统 malloc/free 频繁调用存在锁竞争、内存碎片,高并发服务性能差;内存池一次性批量申请大块内存,后续取用归还无系统调用,大幅提速。
自有内存块可做内存复用,减少频繁向操作系统申请释放内存的内核开销。
适配自定义对齐、连续内存缓存场景,提升 CPU 缓存命中率,高性能服务必需。
C++默认对齐规则:对象起始地址必须是类内最大基础变量字节数的整数倍,防止硬件读取出错。
普通 new 场景:
- 结构体 Person 内部只有一个 long long 变量,这个变量占 8 字节,规定只按照类里最大成员对齐,系统自带 new 分配出来的对象,存放地址只能是 0、8、16、24、32、40、48、56、64 这类 8 的倍数。Person 整个结构体大小 16 字节,比如 56 刚好空闲,把对象放在地址 56 处:占用 56、57、58、59、60、61、62、63、64、65、66、67、68、69、70、71。CPU 缓存每一块固定 64 字节,第一块范围 0~63,第二块 64~127。这个对象前 8 字节落在第一块缓存,后 8 字节落在第二块缓存。
内存池自定义对齐场景:
- 同样的 Person 结构体,手动规定对象地址必须是 64 的倍数。只能放在 0、64、128、192 这类地址。地址 64 存放对象:占用 64 到 79,整块数据全部处于 64~127 这一块缓存里。
默认8字节对齐可能让对象跨两块缓存读取变慢,内存池强制64字节对齐让对象只占一块缓存读取更快(主流CPU硬件缓存行固定就是64字节,所以统一按64对齐刚好贴合硬件标准)
4、返回对象指针;
5、
main打印,手动析构,free释放内存。
哈哈哈啊哈哈(占位符,不然篇幅太长不知道属于哪个段落了),关于
.h里的deleteElement:1、
template<typename T>:模板声明,T 为要销毁的对象类型;2、
if (p):判断指针不为空才执行销毁回收,避免空指针操作报错;3、
p->~T():手动调用 T 类型的析构函数,清理对象内部资源;单纯析构
p->~T ():只执行类内部的清理逻辑(释放成员指针、关闭文件等),完全不碰对象本身占用的那一块堆内存,内存地址依然有效,还能继续使用这块内存。
delete p是捆绑两步操作:1、自动调用p->~T()析构清理内部资源;2、调用底层operator delete,把对象本身的内存还给操作系统,这块地址失效,不能再读写内存池只用
p->~T()对象内部资源清理完毕,承载对象的内存完好保留,交给内存池回收存入空闲链表,下次分配直接复用;如果写delete p:清理内部资源后,直接把内存还给系统,内存池收不到这块内存,失去复用能力。手写
delete是捆绑析构+释放,智能指针是销毁时自动执行delete,而内存池场景要手动单独析构再回收内存不还给系统。查看代码
#include <iostream> struct Test { int* arr; Test() { arr = new int[10]; } ~Test() { delete[] arr; std::cout << "执行析构\n"; } }; int main() { Test* p = new Test(); delete p; // 自动先调用~Test析构,再释放Test自身内存 }4、
reinterpret_cast<void*>(p):把对象指针转为无类型裸内存指针;5、
HashBucket::freeMemory(..., sizeof(T)):传入内存地址与对象占用字节,调用之前看懂的回收函数,把内存还给对应内存池或系统;科普声明和定义区别:
声明:告诉编译器有这个东西,不给内存
extern int x;定义:给变量分配内存(学多了就知道这个是错的,类、模板都是定义不分配内存),全局只允许一处
int x;实现(函数专用)
声明:
void func();实现:
void func(){},补全函数内部逻辑
int x=10属于定义,同时附带初始化。以上都是
.h头文件的:仅声明,定义宏、结构体、类、函数签名;模板
newElement、deleteElement完整实现在此处;MemoryPool成员函数、HashBucket的initMemoryPool、getMemoryPool只写声明,无内部执行逻辑。
.cpp源文件:存放.h中仅声明未实现的全部函数完整代码,补齐类成员、静态函数执行逻辑。
useMemory和freeMemory是HashBucket内部静态成员函数,直接写在.h类定义体内,属于类内就地实现,不用丢到.cpp;而
initMemoryPool、getMemoryPool仅在.h写函数声明,函数体逻辑全部放到.cpp实现。
但这里其实还有个问题,
我的思考:
同样是
static但为啥
下面开始
.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里的:


。


浙公网安备 33010602011771号