惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

阮一峰的网络日志
阮一峰的网络日志
Blog — PlanetScale
Blog — PlanetScale
B
Blog RSS Feed
L
LangChain Blog
Jina AI
Jina AI
爱范儿
爱范儿
C
Check Point Blog
云风的 BLOG
云风的 BLOG
Last Week in AI
Last Week in AI
月光博客
月光博客
GbyAI
GbyAI
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Stack Overflow Blog
Stack Overflow Blog
V
V2EX
A
About on SuperTechFans
有赞技术团队
有赞技术团队
Microsoft Azure Blog
Microsoft Azure Blog
The GitHub Blog
The GitHub Blog
博客园 - Franky
Apple Machine Learning Research
Apple Machine Learning Research
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Google DeepMind News
Google DeepMind News
博客园 - 三生石上(FineUI控件)
S
SegmentFault 最新的问题

博客园 - GerJCS

啃内存池前思考(从分离写法衍生出的思考:类/模版类型、强弱符号穷举思考static和inline等组合、实体、友元、RVO优化历史、volatile、原子、单例(从懒汉饿汉,追到懒汉局部static主流,废弃的DCL双重检查锁+原子+内存序的裸指针修正版、CAS(自旋锁、单生产单消费者、思考多线程并发会产生的问题)、fetch_add、mutex区别、条件变量、租的腾讯云崩了从SSH折腾到VNC) 《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 续啃《编程指北 C++》智能指针(牵扯无穷无尽的其他知识) 续啃:编程指北 C++ (从 RAII开始的,RAII这一小节,学了将近两个月,RAII 的内容早在之前就主动追问豆包搞懂了,这节主要是自己无尽追问探索出很多其他知识,后来发现其实堪比精啃 CSAPP & APUE 等圣书) 编程指北的 C++ 续:啃操作系统 项目:从零开始做一个HTTP服务器(准备篇 —— 无尽弯路错路) C++:继续上一篇文章学CGI编程:乱码问题、误入歧途学了CGI编程(其实只是安装劝退因祸得福)、通过知乎直答及时更改学习方向、找到方向(花了2天) C++:网页网站、互联网、服务器、(web)服务器、(http)服务器、超文本、大型数据中心、云服务器、访问网页发生的流程、URL、DNS、域名、网址链接、IP地址、路由器、CGI架构图、万维网、tomcat、servlet、apache、学C++意义、动态静态请求、脚本、编码、Editplus、乱码 C++:多线程、进程、std::thread、互斥锁、reference_wrapper()、lambda()、cv.wait()、ref、条件变量、原子操作 、看书的意义(内存管理seq和宽松这需要看看)、线程局部存储、死锁、线程间通信(future/promise)、execution库并行 C++:#define等宏预处理器、中断信号处理 C++:命名空间、模版 C++:异常处理、二维数组/三维数组/对象的动态内存分配与delete释放 C++:数据抽象、数据封装、接口(之前做测试的时候听他们说接口始终不理解)、文件和流 C++:基本之前都深入问过豆包了,没啥新东西:类和对象、get()set()、公有私有保护、作用域解析运算符::、继承、函数重载、多态(从这开始不帖回答了,只放豆包回答链接)、虚函数 C++:结构体、vector、队列、链表、哈希表、映射map、简述回顾O(logn)复杂度、哈希表和桶排序里的“桶”、set C++ 基本的输入输出、缓冲机制、cerr引发的超长折磨、同步问题 C++:数组、字符串、指针和引用、时间戳、覆盖静态存储区问题 C++:三角函数、随机数、数论线性同余(待研究)、math库函数、配置C++20、C++20的number、GCC版本和C++版本区别、M_PI、条件编译、头文件和命名空间和库函数区别、控制台/终端/命令行/cmd/bash lambda后续,实在受不了了,太痛苦了,全网找不到答案,详见最后,搜“诡异”,一个lambda整整搞了6天 菜鸟教程:运算符、指针和引用、(从刷算法题到现在目前为止最难啃的)Lambda、第一次主动了解学习new。对我来说这篇博客写的异常痛苦,不亚于刷过的最难算法题,最异常痛苦的是豆包的回答是错的,亏我还追着问了他整整3天关于lambda的事 菜鸟教程:存储类 菜鸟教程:修饰符、静态非静态、构造函数、类相关的杂七杂八的知识
续啃《代码随想录》的《内存池项目》 —— 上一篇是关于分离写...
GerJCS · 2026-08-21 · via 博客园 - GerJCS

开始说细节:

一些语法直接备注到了代码里,省着看一眼代码看一眼博客费劲

代码思想直接就写博客里,

但如果博客只写重要的思路和疑难,不提那些【注释只在代码里的函数】就会比较乱,因为对于这种陌生代码只有清楚知道看代码块的流程,先看哪个函数后看哪个函数,才能懂,所以只注释在代码里的这里直接提一句。

关于.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;

    1. void* raw = operator new(sizeof(int)); 分配裸内存并用raw获取

    2. 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。

mallocoperator newnew int均由线程调用,同一进程所有线程共用全局堆 arena,分配时竞争同一把堆锁;

内存池预分配大块裸内存后内部分配,不再抢占全局堆锁,降低锁冲突:

fork搞出进程后,里面比如总共想干3个事,就叫做3个业务,就malloc出3个内存池,每个业务的线程就在这个自己内存池里做事,但依旧有锁,只不过:

  • 全局堆:全进程所有线程抢同一把锁,并发高时大量线程排队阻塞,性能差。

  • 业务独立内存池:仅A业务内部线程抢A池专属锁,竞争线程数量大幅变少,排队阻塞概率大幅降低,性能更好。

二、内存池降低碎片污染问题

free的时候:  

  • malloc等 glibc 库,

    • 调用 malloc 分配超大块(mmap 分配),free 时直接还给系统;

    • 中间的地址咋释放都不还给OS,只有堆顶释放累积到足够阈值才还给 OS

剩下的都不还给 OS 只有整体释放池子才给 OS

  • 手写内存池内部释放不调用系统接口,销毁池子才释放整块内存

注意:堆地址从低往高扩张,堆的边界(堆顶)是当前占用最高地址。

如果new int[A 占用内存][B 占用内存][C 占用内存],然后free B 标记空等复用,变为 [A 占用内存]|小空闲缝|[B 占用内存][C 占用内存]

如果直接一次malloc/operator new搞大块,分配后收回都是会出现剩余 1000 个 1KB 小块,想分配 10KB 都分不了的情况

  • 全局堆、内存池都会产生碎片(零散闲置小块内存),全局堆所有线程共用同一片公共大堆内存,互相抢占空间,碎片布满整个进程,持续破坏连续内存

  • 内存池是一次malloc开辟了大块内存,不需要去公共大堆里申请,碎片仅局限在池子内部,不影响外部,尾部完整连续空间始终保留而不会被其他线程抢走。

三、有了内存池,库函数选能自定义字节数的

单纯new int只能分配单个 int 大小,内存池可自定义任意字节长度

四、有了内存池,库函数选性能好的

先看代码:

查看代码
#include<iostream>
using namespace std;

struct Test{
    int* num;
    Test(){ 
        num = new int(1); 
        cout << "执行构造函数,num值:" << *num << endl;
    }
    ~Test(){ 
        delete num; 
        cout << "执行析构函数" << endl;
    }
};
int main(){   
    // int *a = new int;
    // int *b = new int();

    // void* qq = operator new(4);
    // int* c = static_cast<int*>(qq);
 
    // cout<<*a<<" "<<*b<<" "<<" "<<*c<<endl;

   
    // 有对象,自动执行构造、析构
    Test* t1 = new Test;
    delete t1;

cout<<"———————:"<<endl;

    // 只有内存,无对象,不跑构造析构
    void* m = operator new(sizeof(Test));//分配对应大小空白内存,只统计结构体成员内存:仅int* num指针大小,和构造、析构、cout 代码无关,函数代码不占对象内存,完全不执行构造函数,num 是随机脏值
    Test* t2 = (Test*)m;
    operator delete(m);

/*
    同样大小内存:
    new 类型 = 内存分配 + 构造;delete = 析构 + 释放内存
    operator new/malloc = 只拿空白内存,无对象、无构造析构;释放只回收内存,不清理对象内部资源
*/

}

new int会创建带完整生命周期的对象,只能逐个delete,无法整块回收复用;

这是豆包的结论,估计也是全网的结论,但我仔细思考太歧义了!妈逼的都可以整块复用,

单次小块循环分配场景:malloc(4)100 次 / new int100 次,都要 100 次释放,无任何区别。

唯一硬性差异只在单次释放内部流程:free只回收内存;delete先走析构清理资源,再回收内存。

豆包说大块一次性分配场景:malloc(400)整块内部逻辑分割后依旧可以一次性free;100 个独立new int语法不允许整块回收,必须逐个delete。 我的评价是纯傻逼的很误导人!!“100 个独立new int语法不允许整块回收” 是因为他是 100 次的new int,换到malloc(4)搞 100 次依旧要 100 次free!你malloc所谓的可以一整块释放也只是释放一次malloc的字节啊。

  • malloc(3):只划出 3 字节裸内存,无任何自动执行的对象初始化、对象析构逻辑。CPU 仅调用系统分配接口,拿到裸内存,1 层逻辑。对应free

  • operator new(3):同上

  • new T(size)(普通对象 new):

构造:底层先调用operator new拿内存,再自动执行构造函数生成有效对象;

释放:用delete,底层先执行析构再释放内存。

相比于上面两个,多了一次函数调用 CPU 级别的指令开销,即在分配多构造调用、释放多析构调用。

关于.h里的static void freeMemory(void* ptr, size_t size){只看注释就行。

关于.h里的template<typename T, typename... Args> T* newElement(Args&&... args)里涉及到的new(p) T(std::forward<Args>(args)...);

这里涉及到的前设基础知识太多太多了~~~~(>_<)~~~~

先说move

左值string s = "hello";string s2 = move(s);强转为右值,cout s为空

std::move 唯一作用:把左值强制转换成无名右值引用,仅此一步,不移动资源。如果本身就是右值,没必要用move。

转换后编译器匹配重载优先级:先匹配 T&& 移动函数,不存在则降级匹配 const T& 拷贝函数。 

手写移动专属函数

  • 类名(类名&& 源) 移动构造函数

  • 类名& operator=(类名&& 源) 移动赋值运算符

二者是自定义移动语义,接管资源,避免深拷贝,必须手动写才会生效

std::move:左值传入 → 调用拷贝构造 / 拷贝赋值

右值直接匹配移动构造/移动赋值,无需std::move。

std::string是标准库提前写好了移动构造,你直接用才会掏空原对象;自定义类不手写移动函数,move 不会产生资源转移效果。

语法规则: 

  • const左值引用Test&仅能绑定非const左值,不能绑定右值、const左值。

  • const 左值引用 const Test& 万能绑定:普通对象、const 对象、临时对象、亡值全能接住。

  • 右值引用(Test&&)只接收临时(非const纯右值) / 被 std::move 转换后的亡值(经过move转来的)

  • const Test&&不用,因为要移动你写个屁的const

实参传给形参,形参带引用,比如T&/const T&/T&&,这个传递过程就叫绑定,而没引用的实参传递给形参,不叫绑定,只发生拷贝,不存在绑定。

T&&是右值引用,是实现移动语义的基础,仅负责绑定右值,但不等同于移动语义,移动语义依靠T&&形参的移动构造 / 移动赋值,转移资源而非拷贝。

临时对象不能用非 const 左值引用接收,比如Test x = Test{"123"};Test{"123"};是无名的临时对象,如果Test写成

Test& Test(Test& t){ 
    t.buf = "改了内容"; 
}

就错了因为不可以传递给无const拷贝构造,只能

Test(const Test& t){
    std::string temp = t.buf; 
}

别问东问西,临时对象马上销毁,尽管传递给构造的时候没销毁,但就是死命禁止任何修改。

Q:绑定是啥意思?我咋感觉像赋值呢?

A:

Test b = a:b 是刚造出来的新东西,这一步叫造新对象(初始化),不是赋值。赋值是东西本来就有,后来改内容。

绑定(不可换绑):调用拷贝构造函数时,拿 a 去填函数括号里的参数 Test& t,把 a 和这个参数拴在一起,这个拴住的动作就叫绑定,跟复制内容两码事。

复制内容:拴好之后,函数内部再把 a 里面的数据抄给 b,这一步看着像赋值,但只是复制数据。

看个代码:

查看代码
#include <utility>
#include <string>
struct Test {
    std::string buf;

    Test(std::string s) : buf(std::move(s)) {}

    // 手写移动构造
    Test(Test&& t) noexcept : buf(std::move(t.buf)) {}
    
    // 手写移动赋值
    Test& operator=(Test&& t) noexcept {
        buf = std::move(t.buf);
        return *this;
    }
    
    // 拷贝构造
    Test(const Test& t) : buf(t.buf) {}
};

int main() {
    Test a{"123"};
    Test b = a; // 左值,调用拷贝构造
    Test c = std::move(a); // move转右值,调用手写移动构造
}

Test(std::string s) : buf(std::move(s)) {} 接收字符串参数来构造对象,

字面量匹配值参数为std::string时,先隐式转换生成临时右值std::string,再拷贝一份值传递给s,这就是局部的左值s,std::move(s)s转为右值,

std::move(s)强制转换成std::string&&(纯右值)类型,

然后由于写了重载的移动构造,又写了move,即buf(move(s))buf调用std::string移动构造接管字符内存(无深拷贝),刚刚的局部形参s生命周期结束自动析构,此时s为空字符串,无资源释放冲突。

练手:

查看代码
#include <iostream>
#include <string>
#include <utility>
using namespace std;

struct MyStr {
    string data;
    // 新增普通构造
    MyStr(const string& s) : data(s) {}
    
    // 拷贝构造:初始化列表直接拿other.data构造成员
    MyStr(const MyStr& other) : data(other.data) {}
    
};

int main() {
    MyStr a{"test"};
    MyStr b = move(a);
    cout << a.data << endl; // 仍输出test,走拷贝无移动
}

1、MyStr a{"test"};

"test"const char[5],隐式转换为 const char*,结构体没有入参为 const char* 的构造函数,仅存在 MyStr(const string& s),编译器利用 const char* 隐式生成临时 string("test"),临时 string 绑定到 const string& s,初始化列表 data(s) 调用 string 的拷贝构造,复制字符串内容

2、MyStr b = move(a);

std::move(a) 将左值 a 转换成类型为 MyStr&& 的亡值,结构体只定义了拷贝构造 MyStr(const MyStr& other),不存在移动构造 MyStr(MyStr&& other)const MyStr& 可以绑定亡值,重载匹配拷贝构造函数,初始化列表 data(other.data) 复制 other.data 的字符串数据,不转移内存资源

3、cout << a.data << endl;

a 内部 string 的堆内存没有被转移,对象原有数据保留,打印结果为 test

进阶完善追问:

查看代码
// 无数次实践发现,学错的思考错的为什么错(指针部分)
// 学仿佛超纲钻牛角尖一句一句问的下面这个,真的把所有需要掌握的东西灵活的起来了,真会了
#include <utility>
#include <string>
#include<iostream>
using namespace std;

struct Test {
    std::string buf;
    
    Test(std::string s) : buf(std::move(s)) {cout<<"string"<<endl;} // 接收 string 参数的转换构造,移动传入字符串

    Test(Test& t) : buf(t.buf) {cout<<"无const左值拷贝构造"<<endl;} // 无 const 的左值拷贝构造,仅能拷贝非 const 对象
    //buf(t.buf)会新建独立字符内存,属于深拷贝
    //所有值传递:内置类型:int、char、double、bool等基础原生类型,无堆内存,所以无深浅拷贝区分,仅复制变量本身数值,不存在额外堆空间,谈不上深拷贝
    //但自定义类对象值传递会触发深拷贝      
    //深拷贝各自独立堆内存,不会双重释放;浅拷贝共用同一块堆内存才会双重释放
    //编译器默认生成的拷贝构造 / 赋值运算符,直接逐成员复制指针,共用堆内存,属于浅拷贝,所以只要涉及到堆,用了默认的浅拷贝共享堆内存,析构时重复释放直接崩溃
 
    Test(Test&& t) noexcept : buf(std::move(t.buf)) {cout<<"移动构造"<<endl;} // 移动构造,接管源对象字符串资源

    Test(const Test& t) : buf(t.buf) {cout<<"新增"<<endl;}//拷贝构造
};

int main() {
    cout<<endl;
    
    // 场景1 正常
    Test a{"123"}; // 先拿字面量构造std::string临时,再传入构造函数调用 Test(std::string s) : buf(std::move(s)) {} 。等价含义写法:Test a = std::string("123");,但属于拷贝初始化,Test a{"123"};属于直接初始化
    Test b = a; // a 是非 const 左值,优先匹配 Test(Test& t) 拷贝构造,若删掉该重载,才会选用 Test(const Test&),引用绑定规则允许非const绑定到const,但 const 不可以绑定到非const
    cout<<endl;



    // 场景2
    // Test t4="ss";
    // "123"、"ss" 字面量类型:const char[N],属于字符数组,
    // const char[3](字面量数组)→ const char*:数组自动退化,无代价、内置转换,随时能转,不占用自定义转换的名额次数(数组名作为右值使用时,自动退化为首元素指针)
    // const char* → const char[3]:不能隐式转换,指针只是存地址,没有数组长度信息,编译器做不到自动转回数组。
    // 编译器尝试转换链:
    // 第一层:const char[3] 内置退化 const char* → 临时std::string,即内部偷偷 string __temp("ss") , 使得 "ss" 变为 std::string("ss")
    // 第二层:临时std::string → Test(你写的构造函数,用户自定义隐式转换),使得 std::string("ss") 变为 Test (std::string ("ss"))
    // 最后用Test (std::string ("ss")) 去初始化 t4 : Test t4 (std::string ("ss")) ,但 C++ 硬性规则:一次初始化表达式,最多允许 1 次自定义隐式转换
    // 想过编译必须加 const char* 构造函数 Test(const char* s) : buf(s) {},这样"ss"构造成 Test ,属于1次隐式,合法、


    // 场景3,
    const Test t1{"abc"};// 调用 Test(std::string s) 构造函数,临时 std::string 右值匹配该构造,生成 const Test 对象,类型为const Test
    Test t2 = t1; 
    // 编译器会在 Test 类中匹配参数能接收 t1(const Test左值) 的构造函数
    // Test(std::string s)参数是std::string
    // 拷贝构造 Test(Test& t),形参类型Test&(非 const 左值引用,Test&&才叫右值引用),C++ 语法规则:const 左值不能绑定到非 const 左值引用
    // 移动构造 Test(Test&& t),形参是右值引用Test&&,仅能绑定纯右值 / 亡值。t1 是左值,匹配失败
    // 想编译过必须加个新增的 Test(const Test& t) 拷贝构造
    cout<<endl;  



    // 场景4,
    Test t3 = Test{"xyz"};
    // 未命名临时对象,属于纯右值
    // 构造 1,Test(std::string s) 类型不匹配,因为等号右边是Test临时类型
    // 拷贝构造Test(Test& t):形参非 const 左值引用,不能绑定右值,匹配失败
    // 移动构造Test(Test&& t):右值引用可绑定纯右值,匹配成功,
    // 但 C++11/14 要么生成临时再移动,要么直接原地构造。
    // C++17直接原地构造,直接走 t3 里搞 string,没临时
    // 复制省略(拷贝消除):编译器跳过拷贝/移动构造,直接把临时对象建在目标变量内存,省去复制资源开销。

    cout<<endl;
}

关于string

std::string内部写了好多重载,

一、赋值运算符(已有对象覆盖内容时调用)

1、C 字符串字面量赋值

string& operator=(const char*);

str = "abc" 这是人类简写语法,对应string& operator=(const char* cstr),编译器会自动翻译成成员函数调用:str.operator=("abc")

  • 左边str. = 调用对象

  • operator= = 函数名

  • 括号("abc")里的"abc" = 传入函数的实参

string& operator=(const string&);
//string& 是整体返回值类型,&都写在右侧

示例:b = a;

  • string&:返回当前对象引用,支持连续赋值x=y=z

  • operator=:赋值函数名

  • const string&:参数,接收另一个字符串左值

  • b=a等价b.operator=(a),a 匹配形参 const string&,调用该拷贝赋值重载,函数内部把 a 的数据复制给 b,返回 *this(b 自身引用),实现 x=y=z 连续赋值

1、拷贝构造

2、移动构造

string(string&&) noexcept;

示例:string b(move(a));

懂了move继续说为啥要引入forward,先看个代码:

查看代码
#include <utility>
#include <iostream>
using namespace std;
struct Data {
    int a;
    Data() { std::cout << "默认构造\n"; }
    Data(const Data&) { std::cout << "拷贝构造\n"; }
    Data(Data&&) { std::cout << "移动构造\n"; }
};

template<typename T>
void badTransfer(T val) {
    cout<<"@"<<endl;
    Data d(std::move(val));// 调用移动构造, val是函数内局部左值, std::move库函数内部固定是转为Data&& 匹配移动构造函数参数Data&&
cout<<"——结束——"<<endl;
}

int main() {
    Data a;
    cout<<"#"<<a.a<<endl;
        
    badTransfer(a);  
    // 调用上面的模板,函数实例化后等价于普通函数:
    // void badTransfer(Data val) {
    //     Data d(std::move(val));
    // }
    // 执行逻辑:把变量 a 拷贝一份给形参 val(触发 Data 拷贝构造),函数内部再 move val

    cout<<"——开始下面——"<<endl;
    badTransfer(Data{});
    // Data{}是临时右值,类型还是Data,推导T = Data
    // 实例化后和上面完全一样的函数,先拷贝临时对象到 val,再 move val
}

这里Data{} 是列表初始化(值初始化),构造一个无名临时 Data 对象(纯右值)。

无任何用户自定义构造,编译器自动生默认构造

  • Data a;命名左值对象,生命周期到作用域结束;无初始化,内置成员脏值

  • Data a{} 值初始化,内置成员置 0

  • 不用Data a()因为会被编译器解析成函数声明:名叫 a、无参数、返回 Data 的函数,不是定义局部对象。

  • Data{} :创建临时对象,内置成员置为 0
  • Data():同上,但 C++11 后推荐 {} 形式,

存在自定义无参构造时,全部只执行你的构造,成员是否清零完全看你构造内赋值(前提你有成员)

这个看似没问题,但失去的原生左右值属性, 如果改成

template<typename T>
void badTransfer2(T&& val) {
    Data d(std::move(val));
}

a 是外部左值,std::move(val) 直接把外部 a 转为右值,触发移动构造,外部 a 资源被掏空,后续使用 a 会产生未定义行为。这就是单纯 move 最大的坑:不分场景强制右值,会误移动外部左值对象。

所以要引入完整正确转发模板:

template<typename T>
void goodTransfer(T&& val) {
    //T&& val 是万能引用,不是单纯右值引用
    //非模板固定类型(如 Data&&、int&&):单纯右值引用,只能接收右值,不能接左值。
    Data d(std::forward<T>(val));
}
//注意运算符都写在右侧

std::forward<T>(val) 依赖万能引用推导规则:

  1. 传入左值 Data a,推导 T=Data&,forward 输出左值引用,调用拷贝构造;

  2. 传入右值 Data{},推导 T=Data,forward 输出右值引用,调用移动构造;

模板形参是 T&& val(万能引用)

  • 实参是左值:编译器强制给 T 推成 类型&

  • 实参是纯右值 / 亡值:编译器直接推 T 为原生裸类型(不带任何 &)

逻辑:传右值临时 Data{},它本身没有左值属性,不需要给 T 附加左值引用标记,所以 T=Data

由于模板是 T&& val

  1. 传左值,把推导的 T = Data&代入得到Data& && val,符号全部贴在类型 Data 右侧,再引用折叠 → Data& val

  2. 传右值,把推导的 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:传左值aT推导成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);
}
  1. newElement<Person>("张三",22) 先确定模板 T=Person,推导 Args 参数包类型;

  2. 编译器生成专属 Person 版本 newElement 函数。

运行期不再看Person函数了,直接看专属那个函数:

1、"张三"、22 作为实参传入函数形参 Args&&...;

2、malloc 分配 Person 大小内存;

3、定位 new + forward 展开参数调用 Person 构造;

    • new(p) T(...)叫定位新建,含义:不在堆上新开内存,直接用已有地址 p 这块内存调用构造函数初始化对象;std::forward+...把外面传进来所有参数原封不动传给 T 的构造函数。

    • 普通new Person会自动 malloc 内存 + 构造,这段代码要手动管控内存(模拟内存池),内存已经手动 malloc 拿到了,不能再用普通 new 重复分配内存,只能用定位新建原地构造。

Q:这他妈不就是new Person吗?为啥不直接new一步到位啊?

A:失去了内存池的意义

标准new底层本来就分两步:分配内存、定位new构造对象,内存池就是把分配内存那一步接管自己实现,等于复刻了原生new的底层拆分逻辑。这么造轮子是因为:

      • 系统 malloc/free 频繁调用存在锁竞争、内存碎片,高并发服务性能差;内存池一次性批量申请大块内存,后续取用归还无系统调用,大幅提速。

      • 自有内存块可做内存复用,减少频繁向操作系统申请释放内存的内核开销。

      • 适配自定义对齐、连续内存缓存场景,提升 CPU 缓存命中率,高性能服务必需。

C++默认对齐规则:对象起始地址必须是类内最大基础变量字节数的整数倍,防止硬件读取出错。

普通 new 场景:

        • 结构体 Person 内部只有一个 long long 变量,这个变量占 8 字节,规定只按照类里最大成员对齐,系统自带 new 分配出来的对象,存放地址只能是 0、8、16、24、32、40、48、56、64 这类 8 的倍数。Person 整个结构体大小 16 字节,比如 56 刚好空闲,把对象放在地址 56 处:占用 56、57、58、59、60、61、62、63、64、65、66、67、68、69、70、71。CPU 缓存每一块固定 64 字节,第一块范围 0~63,第二块 64~127。这个对象前 8 字节落在第一块缓存,后 8 字节落在第二块缓存。

内存池自定义对齐场景:

        • 同样的 Person 结构体,手动规定对象地址必须是 64 的倍数。只能放在 0、64、128、192 这类地址。地址 64 存放对象:占用 64 到 79,整块数据全部处于 64~127 这一块缓存里。

默认8字节对齐可能让对象跨两块缓存读取变慢,内存池强制64字节对齐让对象只占一块缓存读取更快(主流CPU硬件缓存行固定就是64字节,所以统一按64对齐刚好贴合硬件标准)

4、返回对象指针;

5、main 打印,手动析构,free 释放内存。

关于.h里的deleteElement

1、template<typename T>:模板声明,T 为要销毁的对象类型;

2、if (p):判断指针不为空才执行销毁回收,避免空指针操作报错;

3、p->~T():手动调用 T 类型的析构函数,清理对象内部资源;

单纯析构p->~T ():只执行类内部的清理逻辑(释放成员指针、关闭文件等),完全不碰对象本身占用的那一块堆内存,内存地址依然有效,还能继续使用这块内存。

delete p是捆绑两步操作:1、自动调用 p->~T() 析构清理内部资源;2、调用底层 operator delete,把对象本身的内存还给操作系统,这块地址失效,不能再读写

内存池只用 p->~T()对象内部资源清理完毕,承载对象的内存完好保留,交给内存池回收存入空闲链表,下次分配直接复用;如果写 delete p:清理内部资源后,直接把内存还给系统,内存池收不到这块内存,失去复用能力。

手写delete是捆绑析构+释放,智能指针是销毁时自动执行delete,而内存池场景要手动单独析构再回收内存不还给系统。

查看代码
#include <iostream>
struct Test {
    int* arr;
    Test() { arr = new int[10]; }
    ~Test() { delete[] arr; std::cout << "执行析构\n"; }
};

int main() {
    Test* p = new Test();
    delete p; // 自动先调用~Test析构,再释放Test自身内存
}

4、reinterpret_cast<void*>(p):把对象指针转为无类型裸内存指针;

5、HashBucket::freeMemory(..., sizeof(T)):传入内存地址与对象占用字节,调用之前看懂的回收函数,把内存还给对应内存池或系统;

科普声明和定义区别: 

声明:告诉编译器有这个东西,不给内存extern int x;

定义:给变量分配内存(学多了就知道这个是错的,类、模板都是定义不分配内存),全局只允许一处int x;

实现(函数专用)

  • 声明:void func();

  • 实现:void func(){},补全函数内部逻辑

以上都是.h头文件的:

仅声明,定义宏、结构体、类、函数签名;模板newElementdeleteElement完整实现在此处;MemoryPool成员函数、HashBucketinitMemoryPoolgetMemoryPool只写声明,无内部执行逻辑。

.cpp源文件:存放.h中仅声明未实现的全部函数完整代码,补齐类成员、静态函数执行逻辑。

useMemoryfreeMemoryHashBucket内部静态成员函数,直接写在.h类定义体内,属于类内就地实现,不用丢到.cpp

initMemoryPoolgetMemoryPool仅在.h写函数声明,函数体逻辑全部放到.cpp实现。

但这里其实还有个问题,

我的思考:这里其实有引了学3周的东西:单例、内存序、内存屏障等,见上下一篇文章,懂了回来就可以看懂以下结论:

1、代码里友元没必要、

2、为了好看方便,但实际getMemoryPool写在头文件类内实现没问题,函数内 static 局部数组全局唯一,不会多份实例。 

下面开始.cpp

关于.cpp里的构造MemoryPool::MemoryPool(size_t BlockSize)

MemoryPool::MemoryPool(size_t BlockSize)
: BlockSize_ (BlockSize)
    , SlotSize_ (0)
    , firstBlock_ (nullptr)
    , curSlot_ (nullptr)
    , freeList_ (nullptr)
    , lastSlot_ (nullptr)
{}

怎么事?我只知道构造是Person(int a, string n) : age(a), name(n) {},为啥参数不同,我这个age和name,参数和冒号后个数一一对应啊?

A:括号里的参数 = 函数入参(只控制外部传进来的值),Person(int a, string n) 这里 a、n 是外界调用构造时传的参数,数量随便,和初始化列表行数没关系。

冒号后 age(a), name(n) = 成员初始化列表(只给类内部变量赋值),列表写几行,只看你的类里有多少成员要初始化,和前面入参个数没有绑定规则。

agename的例子,入参 2 个,初始化列表 2 行,刚好对应,而内存池是只需要外部搞来一个,剩下的直接就可以初始化(固定空指针)

Q:懂了,那为啥搞个::

A:namespace Kama_memoryPool { }:命名空间(隔离代码的容器),把内存池全部类、函数包进去,防止和别的库同名代码冲突,里面所有定义都归属于这个命名空间。

MemoryPool::MemoryPool(size_t BlockSize):这的:: 是作用域解析符号,前面 MemoryPool是类名,后面 MemoryPool是构造函数名(构造函数名和类名必须一模一样),整句含义:属于 MemoryPool 类的构造函数实现,写在命名空间 Kama_memoryPool 内部的源文件里。

合起来完整含义:在 Kama_memoryPool 命名空间下,实现 MemoryPool 类的带参构造函数,参数是 BlockSize。比如熟悉的例子:

namespace Test{
    Person::Person(int a) : age(a) {}
}

含义:Test 命名空间内,Person 类构造函数的实现。

Q:你他妈解释啥呢?老子问的是这个吗?我他妈问的是正常直接写了,为啥搞个解析符号!!

A:你之前写的这种,是写在类大括号内部,属于声明 + 简单定义,不需要作用域符号:

class Person{
public:
    // 直接在类里面实现,不用 Person::
    Person(int a, string n) : age(a), name(n)
    {}
    int age;
    string name;
};

这里身处类内部,编译器知道当前写的是 Person 的成员,不用额外标记,但现在是,.h头文件只放声明:MemoryPool(size_t BlockSize = 4096);,到.cpp文件单独写实现,脱离了类 {} 包裹,编译器不知道这个函数属于哪个类,必须用类名::指明归属。

Q:那为啥要分开啊?(妈逼的因为很简单,结果发现编译链接整套东西不亚于一个微型学科)

A: 先科普下编译链接咋回事吧(为什么感觉自己好笨,编译链接这好像别人也没像我研究、追问这么久啊,这些都不懂咋干活啊?编译效率啥的,难不成一股脑全编译?银行外包测试张苏的讥讽“这小子没干过项目制”):

分开仨文件:

print.h文件:

#include "print.h"
#include <iostream>
void PrintHello(){
    std::cout << "初始内容" << std::endl;
}

main.cpp文件:

#include "print.h"
int main(){
    PrintHello();
}
g++ -c main.cpp  #等价于 g++ -c main.cpp -o main.o
g++ -c print.cpp

目录多出 main.oprint.o,这两份是编译缓存结果(机器码,也叫目标文件)

然后链接生成程序:

g++ main.o print.o -o app

运行 ./app(叫可执行文件),输出:初始内容

操作 2:只修改 print.cpp 打印行:

std::cout << "修改后的内容" << std::endl;

此时 main.o 文件完全没改动,不用重新编译 main.cpp,只单独编译改动的文件:

只用上面这一行,不会处理 main.cpp

再链接旧的 main.o 和新的 print.o

g++ main.o print.o -o app

运行 ./app,输出:修改后的内容

而如果直接一次性g++ main.cpp print.cpp -o app就相当于,全部代码写在单个 main.cpp

#include <iostream>
void PrintHello(){
    std::cout << "初始内容" << std::endl;
}
int main(){
    PrintHello();
}

第一次编译:

g++ -c main.cpp
g++ main.o -o app

修改函数内部打印文字后,还要重新编译:

g++ -c main.cpp
g++ main.o -o app

这也就是g++ main.cpp -o app一步合并「编译 + 链接」,先内部自动执行-c生成临时.o,再立刻链接,不会保留.o文件,不是单纯打包。

总结:

  1. 分离 .h/.cpp + -c 拆分编译:仅改动实现文件时,只执行 g++ -c print.cppmain.o 直接复用,少一份文件编译,速度更快;

  2. 单文件不分离:只要改动任意代码,必须完整重编译整个文件,无缓存复用;

所以可以看到,头文件.h只声明+.cpp单独实现的分离式写法,对比全都在一个.cpp里,解决两个核心痛点

1. 多人协作开发提高编译速度

  • A 写内存池逻辑(MemoryPool.cpp),B 写压测代码(UnitTest.cpp),如果所有实现全塞头文件里,B 一改测试代码,A 这边所有依赖头文件的代码全部要重新编译,巨慢;拆分后:改 cpp 实现,其他文件不用重编,编译速度大幅提升。

2. 对外只暴露接口,隐藏底层复杂细节

  • 以后你把这套内存池封装成工具给别人用,别人只看.h,知道怎么调用 newElement、初始化池子,cpp 里一堆扩容、空闲链表、内存分配的底层脏逻辑全部藏起来,使用者不用看懂复杂实现,只调用接口就行。

3. 如果实现放入.hcpp多次引入就会,每引入一次就复制一遍代码,程序体积变大;

总结:

你现在只写小 demo,就一两百行代码,不分家看着舒服;一旦项目上万行、十几个工具类、多人同时开发,不分家会出现:

  1. 各种类名重复冲突,到处改名字;

  2. 改一行底层代码,整个项目全部重新编译,等待几分钟;

  3. 别人想用你的工具,一打开头文件几百行底层逻辑,分不清哪些是可调用接口。

继续补充几个东西:

void func();int func();仅返回值不同,编译报错。

void PrintHello(){ }void PrintHello(int num) { }参数不同,属于函数重载。

声明void func();可重复书写,带函数体的定义void func() {}仅能存在一次,不可以重定义。

print.cpp引入print.h:把声明和实现放在同一编译单元,若出现同名同参仅返回值不一致会当场报编译错误,提前拦截问题,避免等到链接阶段才发现错误。

main.cpp不包含print.h,写PrintHello();编译直接报未声明标识符。

完整编译链接流程(你的三份文件)

文件:print.hprint.cppmain.cpp

1、预处理:#include "print.h"直接把头文件文本粘贴进当前.cpp

  • main.cpp粘贴 print.h,拿到void PrintHello();声明,识别函数调用

  • print.cpp粘贴 print.h,声明和自身实现同一份代码,编译时校验签名匹配

2、编译:g++ -c分别处理两个 cpp,生成main.oprint.o目标文件

  • print.oPrintHello函数机器码

  • 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:但我之前手写版为啥freemalloc,代码随想录的是newdelete族的?

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里的