内存管理:new/delete 与 malloc/free 的本质区别,与 delete[] 前面那 4 个字节
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
离开类和对象,进内存管理。这章要回答两件事:C++ 为什么要在 C 的 malloc/free 之外再造一套 new/delete? 以及 delete 和 delete[] 到底差在哪?——后者那个"差 4 个字节"的细节,是理解整章的钥匙。
一、内存区域划分,与编译期那把"锁"
先摆一张表(Linux 下进程地址空间从高到低):
| 区域 | 存放内容 | 特点 |
|---|---|---|
| 栈 | 局部变量、函数参数、返回值 | 向下生长,函数栈帧销毁即释放 |
| 堆 | malloc/new 出来的空间 | 向上生长,手动申请手动释放 |
| 数据段(静态区) | 全局变量、static 变量 | 程序运行期间一直存在 |
| 代码段(常量区) | 可执行代码、字符串字面量、只读常量 | 只读,生命周期最长 |
一个让我纠结了很久、后来想通的问题,写在笔记的第一行:
为什么会有编译期这个东西?明明使用地址就可以访问到想要的一切,硬件上地址随便访问,但就是要在语法层面来加上锁!
答案是规范性。编译器要求:相同域才能访问到相同域的量,无论 static 还是 const。理论上拿到地址就能访问一切,但编译器把这条野路子堵死了——除了指针强转这种主动"破域"的手法,其余只能在相同域内相互访问。
我的理解:这段代码通过了编译器检查,就直接转换为地址,硬件直接找——编译期管的是"你能不能写这行代码",编译通过之后硬件层面就只是在认地址了。
成员函数为什么在"常量区/代码段"
成员函数存放在代码段,和静态区一样——硬件对此并无要求,是编译器在检查。
有意思的是检查的次数:对非 static 成员函数,编译器查两次:
- 是否为相同域(类域检查);
- 是否有 this 指针(C++ 创始者规定的)。
而 static 成员函数只查一次——因为它没有 this,自然不用查第二项。这也解释了为什么 A::GetCount() 能直接调用:没有 this 需要传,就不需要一个对象作为调用载体。
const 并没有改变生命周期
一个容易记错的点:
Const 并无修改变量生命周期的能力,加上 const 仅仅是模仿常量区的只读性。
以字符串为例,两条语句看着像,其实差得远:
const char* pchar3 = "abcd"; // ①
char char2[] = "abcd"; // ②
- ①的第一个
"abcd"是常量区本体,所以它本身就在常量区,生命周期长且只读。pchar3只是个指向它的指针; - ②的
"abcd"是数组:把常量区的那份拷贝到栈区,在栈区重新分配空间。所以你可以改char2[0],改不了pchar3[0]。
另外,const 是在链接期之后才被划到常量区的;链接期之前,const 变量属于文件域(只在当前 .cpp 内可见)。这也是为什么 const 全局变量能直接写进头文件——每个 .cpp 各拿各的那份,不会在链接时撞车。
二、C 语言动态内存回顾
| 函数 | 作用 |
|---|---|
malloc | 开指定字节的空间,不初始化 |
calloc | 字节量 × 单字节量,每个字节初始化为 0 |
realloc | 扩容:目标地址 + 总字节数,保留原有数据 |
free | 释放 |
realloc 分两种情况:原地扩容(后面还有足够空间,直接往后延伸,地址不变)和异地扩容(后面不够,重新找一块,把旧数据拷过去,返回新地址)。这个区分在后面写 vector 的扩容时会再遇到。
三、new 与 malloc 的第一个区别:它会调构造函数
看 C++ 这边的写法:
int* p1 = new int; // 不开初始化
int* p2 = new int(10); // 初始化为 10
int* p3 = new int[10]{1,2,3}; // 数组 + 列表初始化
New 甚至不用强转——malloc 返回 void*,必须写 (int*)malloc(...);new 因为有类型信息,直接返回对应类型指针。
但真正的分水岭在自定义类型:
动态内存开辟会调用构造函数。如果只是用 malloc,就无法以文件域访问类域 private 量——而且我们不能手动调用构造函数,new 就可以调用构造函数。
malloc 只负责"给你一块字节",它不知道这块字节里要放一个 Date;new 做的是"开空间 + 调用构造函数初始化"两件事。
还有一句挺有意思的吐槽:
编译器就是懒。你构建就必须要调用构造函数啊,参数要合适——编译器只会调默认构造,其余不认识,也懒。
这正好和上一篇的初始化列表接上:对于自定义类型成员,编译器只会自动调默认构造函数;想让它调别的构造函数,你得在初始化列表里手动把参数写清楚。
new 失败的判断方式与 malloc 不同
| 失败时 | 判断方式 | |
|---|---|---|
malloc | 返回 NULL | if (p == NULL) |
new | 抛异常 | try/catch |
所以不能以返回值来判断 new 是否开辟失败——new 失败不会返回空指针,而是走异常机制(C++ 的 bad_alloc)。
四、operator new 与 operator delete:new 底下那层
operator new 和 operator delete 是系统自带的全局函数。而 new 之所以能开空间,是因为它自动转换为 operator new —— 这本质上就是运算符重载(new 也是运算符)。
所以完整的链条是:
new → operator new → 构造函数
delete → 析构函数 → operator delete
展开一点:
operator new[]→operator new→ 构造函数- 析构函数 →
operator delete[]→operator delete
operator new 内部最终还是调 malloc(在 malloc 失败时按 new 的语义抛异常),operator delete 内部最终是 free。这就是 new/delete 与 malloc/free 的关系:不是两套平行的东西,而是 new/delete 在 malloc/free 之上加了"构造/析构"这层语义。
五、delete[] 前面那 4 个字节
这是整章最漂亮的细节。看一个问题:
class A { int _a; }; // 4 字节
A* p = new A[10]; // 开 10 个 A,是多少字节?
第一反应是 40 字节——不是,是 44 个字节。
那多出来的 4 个字节是干什么的?
为什么要在指针首位储存个数?因为要 delete,知道调用多少次析构函数。
也就是说,当你写 new A[10] 时,编译器实际申请了 44 字节:开头 4 字节存"数组元素个数",后面 40 字节才是真正的 10 个 A。返回的指针 p 指向的是第 5 个字节,即真正数据的开头。
这解释了两件常见的事:
① 为什么 new[] 必须配 delete[]
delete[] p 的执行过程是:先从头指针往前偏移 4 个字节,读出个数 n,然后循环 n 次调用析构函数,最后整体释放空间。
而 delete p 不会做"往前偏移"这个动作,它会直接把 p 当作一整块空间的起始地址去释放——会崩溃掉,因为程序知道这个 pa 的前面还有 4 个字节。释放地址和申请地址对不上,堆管理器直接报错。
② 有没有显式定义析构,行为不一样
- 显式定义了析构函数 → 计算机认为"有资源要释放" → 需要记录个数 → 多申请 4 字节;
- 没有显式定义析构 → 说明没有资源要释放 → 不调用析构函数 → 也就不用存个数 → 不额外申请。
这个"按需分配"的设计很符合 C++ 一贯的脾气:不为你用不到的东西付出任何成本。但千万别利用这个特性去手动释放——结果难料。
delete[] 的完整逻辑串一遍:告诉计算机"要 free 的这是一个数组类型",然后从头部取个数 N → 循环 N 次(每次:调用析构函数清理内部资源)→ 再整体 free 掉空间。边析构边 free 掉的指针都是原生内置类型。
一句话收尾,也是笔记里反复出现的那条原则:
析构函数只负责"清理内部资源",绝对不负责"释放自己"。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/xiongditian/article/details/168335312



