C++ 内存管理 —— new/delete 到底比 malloc/free 多做了什么?
编译参数:
g++ -g -O0 -Wall -Wextra -std=c++17
说明:文中每一段"运行结果"都是本机实跑得到的,不是为了排版编的。
1. 一句话总结
new/delete = operator new/operator delete(只要空间、只还空间,底层就是 malloc/free)+ 构造函数/析构函数(管对象的生与死)。
malloc/free 只做了前半段。所以:
- 对内建类型:
new/delete和malloc/free几乎是同一件事; - 对自定义类型:
new/delete多了一整条对象生命周期,这就是它存在的唯一理由。
2. 知识地图
C/C++ 内存管理
├── 内存分布(底层模型,回答"变量到底住在哪")
│ ├── 栈:非静态局部变量 / 函数参数 / 返回值,向下增长
│ ├── 内存映射段:共享库、大块动态申请走的 mmap
│ ├── 堆:动态申请的空间,向上增长
│ ├── 数据段(静态区):全局变量 + 静态变量
│ └── 代码段(常量区):可执行代码 + 只读常量
│
├── C 语言的方式:malloc / calloc / realloc / free
│ └── 只分配空间,不初始化、不构造;失败返回 NULL
│
└── C++ 的方式:new / delete
├── 内置类型:new int、new int(10)、new int[3](+ delete / delete[])
├── 自定义类型:先调构造、先调析构
├── 底层函数:operator new / operator delete(重点)
│ └── operator new 内部就是 while + malloc,失败抛 bad_alloc
├── 实现原理:new T[N] / delete[] 的四步流程
└── 定位 new(placement new):在已有内存上"只调构造"
关系说明:这几块知识是层层往下钻的同一条线 —— 表面是"new 和 malloc 有什么区别"(用法层),往下是"new 失败为什么抛异常而 malloc 返回 NULL"(operator new 层),再往下是"new 申请空间和调构造是两个可以拆开的动作"(定位 new 层)。记住这条线,它们就不是零散知识点。
3. 核心概念深度解析
3.1 C/C++ 内存分布
这一节从一段最经典的代码开始:
int globalVar = 1;
static int staticGlobalVar = 1;
void Test()
{
static int staticVar = 1;
int localVar = 1;
int num1[10] = { 1, 2, 3, 4 };
char char2[] = "abcd";
const char* pChar3 = "abcd";
int* ptr1 = (int*)malloc(sizeof(int) * 4);
int* ptr2 = (int*)calloc(4, sizeof(int));
int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
free(ptr1);
free(ptr3);
}
进程地址空间(高地址在上):
高地址
┌──────────────────────────────────┐
│ 栈 stack(向下增长) │ localVar / num1 / char2 / pChar3 / ptr1
├──────────────────────────────────┤
│ 内存映射段(mmap) │ 共享库;大块 malloc 也走这里
├──────────────────────────────────┤
│ 堆 heap(向上增长) │ *ptr1 —— malloc/new 出来的空间
├──────────────────────────────────┤
│ 数据段(静态区) │ globalVar / staticGlobalVar / staticVar
├──────────────────────────────────┤
│ 代码段(可执行代码 + 只读常量) │ 函数指令、"abcd" 这类字面量
└──────────────────────────────────┘
低地址
先自己答一遍:globalVar / *ptr1 这些变量分别在哪?(A 栈 B 堆 C 数据段 D 代码段)
| 变量 | 答案 | 实测地址(macOS arm64,-O0) |
|---|---|---|
globalVar | C 数据段 | 0x100bac000 |
staticGlobalVar | C 数据段 | 0x100bac008 |
staticVar | C 数据段 | 0x100bac004 |
localVar | A 栈 | 0x16f25afdc |
num1 | A 栈 | 0x16f25afe0 |
char2 | A 栈 | 0x16f25afd4 |
*char2 | A 栈 | 0x16f25afd4(和 char2 同一个地址) |
pChar3 | A 栈 | 0x16f25afc8 |
*pChar3 | D 代码段(常量区) | 0x100ba4814 |
ptr1 | A 栈 | 0x16f25afc0 |
*ptr1 | B 堆 | 0x100bd91d0 |
这张表里真正值钱的是这两组对比,把内存分布一次讲透:
① 三个"static/global"变量地址挤在一起(...c000、...c004、...c008) —— 它们确实住在数据段,staticVar 写在函数里也照样是静态存储期,函数退出不销毁。
② char2 和 *char2 是同一个地址,pChar3 和 *pChar3 完全不是一个地址。
char char2[] = "abcd"; // 栈上开 5 字节数组,把 'a','b','c','d','\0' 拷进去
char2 ──→ 栈 0x16f25afd4 (数组名)
*char2 ──→ 栈 0x16f25afd4 (就是数组第一个元素,也在栈上)
const char* pChar3 = "abcd"; // 栈上开 8 字节指针,指向代码段里的只读字面量
pChar3 ──→ 栈 0x16f25afc8 (指针变量本身)
*pChar3 ──→ 常量区 0x100ba4814(字面量"abcd",和函数代码住在同一段)
*pChar3 实测地址 0x100ba4814 与函数 Test 的地址 0x100ba4530 同属 0x100ba... 这一段 —— 这就是"代码段(常量区)"字面上的含义:只读字面量和指令放在一起,所以改它就是 UB(段错误)。
这五条按内存分配的顺序捋一遍就够了:
- 栈又叫堆栈,非静态局部变量/函数参数/返回值都在上面,向下增长;
- 内存映射段是高效的 I/O 映射方式,用来装载共享库,也能给进程间通信创建共享内存(Linux 内容,先了解);
- 堆用于动态内存分配,向上增长;
- 数据段存全局数据和静态数据;
- 代码段存可执行代码和只读常量。
3.2 C 语言的方式:malloc / calloc / realloc / free
C 这边一共四个函数,先看它们最典型的用法:
int* p2 = (int*)calloc(4, sizeof(int)); // 4 个 int,全部清零
int* p3 = (int*)realloc(p2, sizeof(int) * 10); // 把这块调整成 10 个 int
// 这里需要 free(p2) 吗? ← 先自己回答一遍
free(p3);
第一个高频面试题:malloc / calloc / realloc 的区别?
| 函数 | 作用 | 是否初始化 | 说明 |
|---|---|---|---|
malloc(size) | 申请 size 字节 | 否(内容是脏的) | 返回 void*,失败返回 NULL |
calloc(n, size) | 申请 n * size 字节 | 是,全部置 0 | 按"n 个元素"给,适合数组 |
realloc(p, size) | 把 p 指向的块调整成 size 字节 | 新增部分不保证 | 可能原地扩、可能搬家、可能失败返回 NULL |
“这里需要 free(p2) 吗?”——不需要,而且不能 free。 原因:
realloc 成功的两种情况:
① 原地扩展:p2 的地址没变,但"所有权"已经交给 p3 了
② 搬家:realloc 内部已经 free 了旧块,返回新地址
所以 realloc 成功后,p2 就是一个"已失效的指针":
free(p2) → double free(同一块内存释放两次)
free(p3) → 正确(就是上面代码干的事)
但 realloc 失败时返回 NULL,原块还活着:
p3 = NULL 时,p2 仍然有效,这时候 p2 才需要你 free
正确的写法(把"失败"这条路径也处理掉):
int* tmp = (int*)realloc(p2, sizeof(int) * 10);
if (tmp == NULL)
{
// p2 依然有效,需要自己决定是继续用还是 free 掉
// ...
}
else
{
p2 = tmp; // 只有成功才覆盖原指针
}
free(p2);
第二个高频面试题:malloc 的实现原理?glibc 里的 malloc 是怎么做的?
要点(面试够用):glibc 的 malloc 用 ptmalloc(源自 Doug Lea 的 dlmalloc)。
- 小块内存:通过
brk/sbrk抬高 heap 边界,在连续堆里切chunk; - 大块内存:直接
mmap一块独立的区域(默认阈值 128KB,可用mallopt调); - 空闲的 chunk 按大小分箱管理(tcache / fastbin / smallbin / unsorted bin / largebin),所以频繁 malloc/free 小对象不一定每次都进内核;
- 每个 chunk 都有自己的头部(大小、标志位),这就是"malloc 怎么知道 free 该还多少"的答案。
macOS 上不是这一套(Apple 用自己的 zone allocator),所以我在 Mac 上实测出的地址分布规律不能直接套到 Linux 的结论上 —— 后端主战场是 Linux,glibc 的那套才是要背的。
3.3 C++ 的方式:new / delete
C 那套在 C++ 里能继续用,但**“只能用不能忍”**的两点:不能初始化(除了 calloc 的清零)、对自定义类型不调构造/析构。所以 C++ 给了自己的方案。
3.3.1 内置类型
申请、初始化、释放的三种典型写法放在一起看:
int* p0 = new int; // 一个 int,未初始化
int* p1 = new int[10]; // 10 个 int,未初始化
delete p0; // 配对 delete
delete[] p1; // 配对 delete[](数组必须用 delete[])
int* p2 = new int(0); // 一个 int,初始化为 0(圆括号)
int* p3 = new int[10]{0}; // 10 个 int,全部初始化为 0
int* p4 = new int[10]{1,2,3,4,5}; // 前 5 个 1..5,其余补 0
delete p2;
delete[] p3;
delete[] p4;
实测(*p2 = 0, p3[0..2] = 000, p4[0..6] = 1234500):
new int(0)是"申请 + 给初值",new int[10]{0}是"申请 10 个 + 全部给 0";new int[10]{1,2,3,4,5}只写了 5 个初始值,剩下的 5 个按 C++ 规则补 0,不是随机值;- 从语法就能看出和 malloc 的差别:
new后面跟类型,编译器自己算大小;malloc后面跟字节数,要你自己sizeof再强转。
3.3.2 自定义类型(分水岭)
一句话就是全部:申请自定义类型的空间时,new 会调用构造函数,delete 会调用析构函数,而 malloc 与 free 不会。
对照着看最直观:
A* p1 = (A*)malloc(sizeof(A)); // 只拿到 sizeof(A) 字节,_a 是垃圾值,"还不算一个对象"
A* p2 = new A(1); // 拿空间 + 调 A(1),这才是一个对象
free(p1); // 只还内存,不调 ~A()
delete p2; // 先 ~A(),再还内存
A* p5 = (A*)malloc(sizeof(A)*10); // 10 份空间,0 次构造
A* p6 = new A[10]; // 10 份空间,10 次构造
free(p5);
delete[] p6; // 10 次析构
一句话:malloc/free 是"内存级"的,new/delete 是"对象级"的。
3.4 new 的底层:operator new / operator delete(本节是重点)
这里最关键的一句话:new 和 delete 是操作符,operator new 和 operator delete 是系统提供的全局函数。new 在底层调用 operator new 申请空间,delete 在底层通过 operator delete 释放空间。
分清楚这两个名字,很多"到底调了谁"的问题就没了:
new / delete → 语言层面的操作符,不能重载(可以重载的是 operator new)
operator new → 全局函数,干的事只有一个:申请 size 字节
operator delete → 全局函数,干的事只有一个:释放这块空间
MSVC 的 operator new 实现长这样:
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
void *p;
while ((p = malloc(size)) == 0) // ① 底层还是 malloc
if (_callnewh(size) == 0) // ② 失败就问"空间不足应对措施"
{
static const std::bad_alloc nomem;
_RAISE(nomem); // ③ 没人管,就抛 bad_alloc
}
return (p);
}
流程画出来:
new T
│
▼
operator new(size)
│
┌───────▼────────┐
│ p = malloc(size) │
└───────┬────────┘
│
┌───────┴────────┐
成功│ │失败(malloc 返回 NULL)
▼ ▼
返回 p 是否设置了"空间不足应对措施"(new_handler)?
│ │
有│ │没有
▼ ▼
执行它并再试 ──→ 抛 std::bad_alloc 异常
这个"空间不足应对措施",在标准库里就是 std::set_new_handler 注册的回调函数(void(*)()):malloc 失败时 operator new 会反复调用它,希望它释放点内存出来(典型用法:释放缓存、记日志、然后决定抛异常)。注意它是回调 —— 又是"控制反转"那套思路:库不决定释放什么,由你自己决定。
operator delete 那一半同样直白:
void operator delete(void *pUserData)
{
_CrtMemBlockHeader * pHead;
RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
if (pUserData == NULL)
return;
_mlock(_HEAP_LOCK);
// ... 校验块合法性(Debug 版)
_free_dbg( pUserData, pHead->nBlockUse ); // 最终还是 free
_munlock(_HEAP_LOCK);
return;
}
// free 的 Debug 版就是一个宏
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
所以结论是:operator new/operator delete 本质就是 malloc/free 的包装,多做的事是"加异常语义 + 留可替换的钩子"。 这就是为什么面试问"new 和 malloc 的区别",答到最后一定会落到异常 vs NULL 上。
3.5 new / delete 的实现原理
内置类型:new/malloc、delete/free 基本类似,三个差别:
new/delete处理单个元素,new[]/delete[]处理连续空间;new申请失败抛异常,malloc失败返回 NULL;new后跟类型(自己算大小、能初始化),malloc要自己算字节数。
自定义类型(四步流程,背下来):
new T : operator new ──→ 构造函数(1 次)
new T[N] : operator new[] ──→ operator new ──→ 构造函数(N 次,依次构造)
delete p : 析构函数(1 次) ──→ operator delete
delete[] p : 析构函数(N 次) ──→ operator delete[] ──→ operator delete
文字版:
new T:① 调operator new申请空间 → ② 在空间上执行构造函数;new T[N]:① 调operator new[](内部调operator new)申请 N 个对象空间 → ② 执行 N 次构造函数;delete p:① 执行析构函数清理资源 → ② 调operator delete释放空间;delete[] p:① 执行 N 次析构函数 → ② 调operator delete[](内部调operator delete)释放空间。
注意顺序:new 是"先要空间再构造",delete 是"先析构再还空间"。 顺序反了就没法解释"为什么
new[]必须先构造完才能给你指针"。
3.6 定位 new(placement new)
用途:在已经分配好的原始内存上,只调用构造函数,不申请空间。
new (place_address) type; // 默认构造
new (place_address) type(initializer-list); // 带参构造
place_address 必须是指针,需要 #include <new>。完整用法是这样:
A* p1 = (A*)malloc(sizeof(A)); // p1 只是一段和 A 一样大的空间,还不是对象(没构造)
new (p1) A; // 显式调构造函数 —— 到这一步它才成为对象
p1->~A(); // 手动调析构(因为它不是 new 出来的,没人替你调)
free(p1);
A* p2 = (A*)operator new(sizeof(A)); // 直接用 operator new 拿裸空间
new (p2) A(10); // 显式构造,带参要在这里传
p2->~A();
operator delete(p2); // 配 operator delete 释放
为什么要用它:内存池。 内存池一次申请一大块,切出来的内存没有初始化、也没有构造,所以自定义类型的对象必须用定位 new 显式调构造来初始化,用完后手动调析构再把内存还回池子。
这段代码还顺手揭示了 3.4 的结论:"申请空间"和"调构造函数"本来就是两个可以分开的动作 —— new 只是把这两个动作打包成一个操作符而已。
3.7 malloc/free 与 new/delete 的区别
共同点:都是从堆上申请空间,都需要用户手动释放。
| # | 维度 | malloc / free | new / delete |
|---|---|---|---|
| 1 | 本质 | 是函数 | 是操作符 |
| 2 | 初始化 | 不初始化 | 可以初始化(new int(10)) |
| 3 | 大小 | 手动 sizeof 算字节数并传参 | 后跟类型即可;多个对象用 [] 给个数 |
| 4 | 返回值 | void*,必须强转 | 不需要,类型已由 new 后的类型决定 |
| 5 | 失败 | 返回 NULL,必须判空 | 抛异常,必须 try/catch(不判空) |
| 6 | 自定义类型 | 只开辟空间,不调构造/析构 | 申请后调构造,释放前调析构 |
(还有一条别漏:malloc 配 free,new 配 delete,new[] 配 delete[]。)
4. 实测:new/delete 到底调了谁
4.1 探针类 A
要看清编译器到底调了谁,最有效的办法是写一个"探针类":每个特殊成员函数里都打印一句话,让它们自己报数。
class A
{
public:
A(int a1 = 0, int a2 = 0) : _a1(a1), _a2(a2) { cout << "A(int a1 = 0, int a2 = 0)" << endl; }
A(const A& aa) : _a1(aa._a1) { cout << "A(const A& aa)" << endl; } // 只拷了 _a1!
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa) { _a1 = aa._a1; } // 也只赋了 _a1!
return *this;
}
~A() { cout << "~A()" << endl; } // 若类里出现指针成员,这里必须 delete _ptr;
void Print() { cout << "A::Print->" << _a1 << endl; }
A& operator++() { _a1 += 100; return *this; } // 前置 ++,返回引用(支持 ++++a)
private:
int _a1 = 1; // 类内默认值
int _a2 = 1;
};
三个值得注意的设计:
- 构造函数带默认参数(
a1 = 0, a2 = 0)→ 它既是默认构造,又是"收一个 int 就能用"的转换构造,所以A a = 1;这种写法合法(见 4.4); ~A()现在什么都不用做:当前A只有两个int成员,没有指针,析构里没有资源要清理 —— 但记住这条规则:一旦类里出现new出来的指针成员,析构函数里就必须有对应的delete,同时必须写深拷贝的拷贝构造和赋值重载(三法则);Print()只打印_a1—— 也就是说_a2从头到尾没被任何代码读过。直接这么编译(-Wall -Wextra)会实打实地报一条private field '_a2' is not used;为了观察_a2的实际取值,我在Print()里把它也打印了出来(下面的实测输出会标出来)。这条警告和 5.3 踩坑 1 直接相关,别错过。
4.2 内置类型的 new / delete
先看最基本的一组:一个 int、一个 int 数组,以及带初值的申请。
int * p0 = new int;
int * p1 = new int[10];
delete p0;
delete[] p1;
int * p2 = new int(0);
int * p3 = new int[10]{0};
int * p4 = new int[10]{1,2,3,4,5};
delete p2;
delete[] p3;
delete[] p4;
实测输出:
*p2 = 0, p3[0..2] = 000, p4[0..6] = 1234500
三条结论(都对上 3.3.1):
new int(0)、new int[10]{0}、new int[10]{1,2,3,4,5}都真的初始化了,这是malloc给不了的;p4只写了 5 个初值,p4[5]、p4[6]是 0(补零,不是随机值);- 申请数组用
new[],释放就必须用delete[],配对使用。
4.3 自定义类型:Listnode 链表
struct Listnode{
int _val;
Listnode * next;
Listnode(int val = 1): _val(val),next(nullptr) { }
};
用 new 手搭一条链表,验证"构造是不是真的被调了":
Listnode * p1 = new Listnode();
Listnode * p2 = new Listnode(2);
Listnode * p3 = new Listnode(3);
Listnode * p4 = new Listnode(4);
p1->next = p2; p2->next = p3; p3->next = p4; p4->next = nullptr;
给 Listnode 的构造和析构各加一句打印,实测输出:
Listnode(int val = 1):0x104d751d0
Listnode(int val = 1):0x104d750a0
Listnode(int val = 1):0x104d750b0
Listnode(int val = 1):0x104d750c0
1 2 3 4
~Listnode():0x104d751d0
~Listnode():0x104d750a0
~Listnode():0x104d750b0
~Listnode():0x104d750c0
验证了三件事:
- 每个
new Listnode(...)都调了一次构造函数,new Listnode()走的是默认参数val = 1; delete时每个节点都调了一次析构 ——malloc做这件事是完全不可能的;- 4 个节点的地址在堆上连成一片(
0x104d75xxx),和 3.1 里*ptr1的堆地址规律一致。
⚠️ 但上面这段代码没有 delete:4 个节点 new 出来就再也没还过。演示可以,真实代码里这就是 4 处内存泄漏(见第 6 节修正)。
4.4 对象的拷贝发生在什么时候
这部分本来属于拷贝构造,但放在内存视角下看特别有说服力(栈上的临时对象也是"内存管理")。
① 隐式类型转换
A aa1 = 1;
const A& aa2 = 1;
实测:
A(int a1 = 0, int a2 = 0) ← A aa1 = 1; int → A 隐式转换构造
A(int a1 = 0, int a2 = 0) ← const A& aa2 = 1; 临时对象绑定到常引用
A aa1 = 1; 之所以合法,是因为构造函数"给两个默认参数后只传一个 int 也能调" —— 编译器拿它当作单参构造,允许隐式转换。第二行是常引用绑定临时对象:临时对象本来该在这一行结束就销毁,绑定到 const A& 后生命周期被延长到引用离开作用域。
想禁止这种隐式转换就写
explicit A(int a1 = 0, int a2 = 0);。这是 C++ 里"能编译通过但不是你想写的代码"的经典来源。
② 按值传参:void f1(A aa)
A aa1(1);
f1(aa1); // 传左值
f1(A(1)); // 传匿名对象
f1(1); // 传 int,隐式转换出匿名对象
实测输出:
A(int a1 = 0, int a2 = 0) ← A cc(3, 5);
A(const A& aa) ← f1(cc); 传左值 → 实打实的拷贝构造
A::Print->_a1=3 _a2=1 ← 拷进来的对象 _a2 丢了,用了类内默认值 1
~A() ← 形参 aa 出作用域析构
--
A(int a1 = 0, int a2 = 0) ← f1(A(1)); 直接构造,没有拷贝
A::Print->_a1=1 _a2=0
~A()
--
A(int a1 = 0, int a2 = 0) ← f1(1); 同样只有一次构造,没有拷贝
A::Print->_a1=1 _a2=0
~A()
结论:
| 实参形式 | 普通构造 A(int,int) | 拷贝构造 A(const A&) | 说明 |
|---|---|---|---|
f1(aa1)(左值实参) | 0 | 1 | 按值传参会真的拷贝一份,改形参不影响实参 |
f1(A(1))(匿名对象) | 1 | 0 | 临时对象直接在形参位置构造(拷贝被省略) |
f1(1)(隐式转换) | 1 | 0 | 同上,int 先转成匿名 A |
这就是 -Wall -Wextra 也报不出来、但性能上实实在在的拷贝 —— 结论:只读参数一律写 const A&(第 6 节修正)。
③ 返回局部对象:A f2()
A f2() { A aa(1); ++aa; return aa; }
实测:
A(int a1 = 0, int a2 = 0) ← f2() 里的 aa 直接在调用方要用的位置上构造
A::Print->_a1=101 _a2=0 ← 前置 ++ 生效(1 + 100)
~A() ← 只在 main 结束时析构一次
即使 -O0,return aa; 也没有输出 A(const A& aa) —— 编译器把局部对象直接构造在调用方的目标位置(返回值优化 NRVO)。所以"返回对象会多一次拷贝"在今天的编译器上通常不成立,不要为了省这次拷贝而返回指针(返回局部对象的地址才是真错误)。
4.5 new 失败会发生什么
这里可以做两套实验:
实验一:一次性申请三个 1GB
void * p1 = new char [1024*1024*1024];
void * p2 = new char [1024*1024*1024];
void * p3 = new char [1024*1024*1024];
实测:三次全部成功(0x79ae400000、0x79ee400000、0x7a2e400000)。
原因不是"内存够",而是 macOS 和 Linux 一样超售(overcommit)虚拟内存:new 拿到的是虚拟地址,只有真正写入页面时才会分配物理内存。所以"申请 1GB 没崩"不代表你占了 1GB。
实验二:死循环申请 1MB(func())
void func()
{
int n = 1;
while(1)
{
void * p1 = new char [1024*1024];
cout << p1 << "->" << n << endl; // 既没 delete,也不需要 p1,纯粹观察
++n;
}
}
int main()
{
try { func(); }
catch(const exception & e) { cout << e.what() << endl; }
return 0;
}
跑 4 秒后手动 kill,实测结果:
0x7b53400000->1
0x7b53500000->2
0x7b53600000->3
...
0x1245e00000->721387
共 721387 行,约 700 GB 虚拟地址
三点观察:
- 地址单调递增(不是每次正好 +1MB,因为 malloc 是成块向系统要大区域再切分)—— 这是"堆向上增长"在真实程序里的样子;
- 4 秒就吃到 700GB 虚拟空间还没有抛异常:在 overcommit 的机器上,这个
while(1)可能跑很久都等不到bad_alloc,中途只会把地址打满屏。这段代码的真正价值是演示意图,不是演示结果; catch(const exception & e)的写法是对的(按引用捕获,避免对象切片),但它在这台机器上基本轮不到执行。
那怎么可靠地看到 bad_alloc? 实测两个办法:
// ① 申请超过用户态虚拟地址空间能给的量:直接抛 bad_alloc
try { void* p = new char[64ull*1024*1024*1024*1024]; } // 64TB
catch (const std::exception& e) { cout << e.what() << endl; }
// 实测输出:std::bad_alloc
// ② Linux 上限制进程虚拟内存(后端主战场,推荐)
// $ ulimit -v 524288 # 512MB
// $ ./a.out # 很快就会打印 std::bad_alloc
(顺带实测:macOS 的 bash 直接 ulimit -v 会报 Invalid argument,setrlimit(RLIMIT_AS) 也返回 -1 —— 这不是命令写错了,是 Darwin 不支持这两种限制方式。)
5. 注释提炼
5.1 知识注解类 —— 把容易滑过去的那句话抠细
“new 后面跟类型就行,不用自己算大小”
写惯 C 之后,手会先去找 sizeof。但看这一行:new 后面直接跟类型名就够了,编译器负责算大小、返回正确类型的指针。
A* p1 = new A(); // 类型即大小,不需要 sizeof,不需要强转
A* p2 = (A*)malloc(sizeof(A)); // 大小自己算,返回 void* 自己强转
差别不只在于"少写几个字":malloc 那行万一 sizeof 写错(比如写成 sizeof(A*),指针大小),编译器不会拦你;new A 大小写错了根本编译不过。类型安全不是风格问题,是 bug 数量问题。
为什么说"内置类型几乎和 malloc 一样"
要理解这句"几乎":内置类型没有构造/析构,所以 new 相对 malloc 只多做了两件事 —— 初始化(可选)和异常语义。真正拉开差距的是自定义类型。
为什么 catch 要写 const exception&
catch(exception e) 是按值捕获:异常对象会被拷贝一份,如果抛的是 bad_alloc 而按 exception 值捕获,多态信息就被"切"没了(对象切片)。写 const std::exception& 才能拿到 what() 里真正的内容(实测打印出 std::bad_alloc 就是靠它)。
5.2 个人理解类
我的理解:new 的本质是"把两件事打包"。
把 new 拆开看就一目了然 —— operator new 负责"要空间",构造函数负责"变成对象",定位 new 就是把这两件事拆开单独用的工具(3.6)。所以 new 不是"更高级的 malloc",它是语言为"对象"这个概念特意做的语法糖:因为 C 里根本没有"对象",所以 C 的 malloc 也不需要调用构造。
我的理解:operator new 的 while 循环揭示了"内存不足"是可以协商的。
malloc 失败就返回 NULL,调用方(库)没有任何办法;operator new 在失败和抛异常之间插了一个"回调你自己注册的处理函数"的环节。这就是把"策略"交给使用者:库负责机制(申请不到就问你),你负责策略(丢缓存还是直接崩)。这个套路(机制在内、策略在外)在后端里到处都是:缓存淘汰策略、线程池拒绝策略、连接池超时策略,全是同一个形状。
5.3 踩坑感悟
踩坑 1:拷贝构造和赋值重载漏了 _a2,编译器一声不吭
A(const A& aa) : _a1(aa._a1) { } // _a2 没拷
A& operator=(const A& aa) { if (this != &aa) _a1 = aa._a1; return *this; } // _a2 没赋
实测证据(A s1(7, 8); A s2(s1); A s3(9,9); s3 = s1;):
A::Print->_a1=7 _a2=1 ← 拷贝出来的 s2:_a2 掉回类内默认值 1(不是 8!)
A::Print->_a1=7 _a2=9 ← 赋值后的 s3:_a2 保持原值 9(不是 8!)
为什么这个 bug 阴险:_a2 有类内默认值 = 1,所以"没被初始化"变成了"被默认值初始化",运行时不崩、不报错,只是数据悄悄丢了。如果 _a2 没有默认值,反而会踩到"未初始化成员是随机值"的老问题。
那编译器警告能兜住吗?——不完全能。 直接 -Wall -Wextra 编译,确实会报一条 private field '_a2' is not used,但理由是"没人读 _a2",不是"拷贝时漏了 _a2"。我把它加进 Print() 之后,这条警告立刻消失,而漏拷贝的 bug 一点变化都没有。也就是说:-Wunused-private-field 只能帮你判断"这个成员是不是多余",检测不出"这个成员忘了拷" —— 真实项目里 _a2 只要被任何一处读到,警告就没了,问题却还在。
这条坑的教训不是"记得多写一行",而是:写拷贝构造/赋值前,先把类的成员一个一个数一遍。 成员一多就该考虑 = default(编译器生成的版本会老老实实拷所有成员)或拷贝再交换(copy-and-swap)的写法。
踩坑 2:new[] 配 delete(以及 new 配 delete[])
int* p = new int[10];
delete p; // 错!数组要用 delete[]
对内置类型可能"看起来没事",对自定义类型(比如 4.3 的 Listnode)就是只析构第一个对象、堆结构被破坏。规则:new 配 delete,new[] 配 delete[],一个字都不能省。
踩坑 3:realloc 之后再去 free 原指针
前面那段 Test() 里 free(p3) 是对的,free(p2) 就是 double free。详见 3.2 —— realloc 成功时旧指针已经失效,失败时(返回 NULL)旧指针才还需要你 free。
踩坑 4:只读参数写成按值传参
void f1(A aa) 每调一次就拷贝一次对象(实测输出里的 A(const A& aa))。对象一大(里面还有 vector、string、数组),这个开销直接进热路径。只读 → const A&;要改副本 → 按值,但自己知道在拷贝什么。
踩坑 5:func() 那种写法在真机上几乎等不到 bad_alloc
while(1) + 每次 1MB + 不 delete,在 overcommit 的系统上 4 秒能刷 72 万行、吃 700GB 虚拟空间(实测),程序既不崩也不进 catch。想验证"new 失败抛异常",要么申请一个明显超过地址空间大小的块(new char[64TB],实测抛 std::bad_alloc),要么在 Linux 上用 ulimit -v 把地址空间限死。 靠"慢慢把内存耗光"这个思路在 macOS 上不成立。
踩坑 6:中文注释在编码混编下会悄悄变成乱码
比如注释里本该写"匿名对象",存盘之后却变成了一个奇怪的字符:
写下的: // 匿名对象
存盘后: // Ż
这是编码问题留下的疤。GBK 和 UTF-8 混编时,中文注释就是这样悄悄坏掉的。养成习惯:C++ 源文件统一 UTF-8,编辑器关掉自动猜编码 —— 注释坏了比代码错了更难发现,因为它不影响编译。
5.4 对比总结
按存储位置选"把数据放哪":
| 数据 | 放哪 | 何时申请 | 谁负责释放 |
|---|---|---|---|
| 生命周期明确的小对象 | 栈(局部变量) | 进作用域 | 编译器(离开作用域) |
| 需要跨函数存活 / 大小运行时才定 | 堆(new / malloc) | 你说了算 | 你(delete / free) |
| 全程序共享、只一份 | 数据段(全局/静态) | 程序启动 | 程序结束 |
| 只读文本 | 代码段(字面量) | 编译期 | 不用管,也别改(改就是 UB) |
四组 API 的关系一图流:
new T / new T[N]
①申请空间 ◄──────────────────────────────────► ②构造对象
│ │
▼ ▼
operator new / operator new[] T() / T[N] 个构造函数
│
▼
malloc(size) ── 失败 ──→ std::bad_alloc(C 里是返回 NULL)
delete p / delete[] p :析构(1 次 / N 次)──→ operator delete ──→ free
面试一句话版本:new = operator new(底层 malloc,失败抛 bad_alloc)+ 构造函数;delete = 析构函数 + operator delete(底层 free)。所以对内置类型二者等价,对自定义类型差一个完整的对象生命周期。
6. 代码修正
修正 1:拷贝构造补齐 _a2(真实 bug)
// 修改前:_a2 掉回类内默认值 1
A(const A& aa) : _a1(aa._a1) { cout << "A(const A& aa)" << endl; }
// 修改后:成员初始化列表补齐,顺序与声明顺序一致(_a1 在前)
A(const A& aa)
: _a1(aa._a1)
, _a2(aa._a2)
{
cout << "A(const A& aa)" << endl;
}
修正 2:赋值重载补齐 _a2
// 修改前
A& operator=(const A& aa)
{
if (this != &aa) { _a1 = aa._a1; } // _a2 丢了
return *this;
}
// 修改后:两个成员都要赋值,返回值支持连续赋值 a = b = c
A& operator=(const A& aa)
{
if (this != &aa)
{
_a1 = aa._a1;
_a2 = aa._a2;
}
return *this;
}
如果类里将来真的出现指针成员,这两处必须改成深拷贝(
_ptr = new int(*aa._ptr);)+ 析构里delete _ptr,也就是让~A()里真的写上delete _ptr;—— 三法则缺一不可。
修正 3:只读参数用常引用,别按值拷贝
// 修改前:每次调用拷贝一个对象
void f1(A aa) { aa.Print(); }
// 修改后:不拷贝;函数内确实要改就自己拷一份局部变量
void f1(const A& aa) { aa.Print(); }
注意:
Print()目前是非 const 成员函数,改成const A&后必须把它也改成void Print() const,否则编译不过。这也是为什么"成员函数该加 const 就加 const"是习惯问题,不是洁癖。顺带一提:
f1是空函数体,直接编译还会报unused parameter 'aa'—— 这里写它只是为了验证"什么时候发生拷贝",形参压根没用,所以这个警告本身是合理的。
修正 4:链表节点必须释放
// 修改前:4 个节点 new 出来就不管了
// Listnode* p1 = new Listnode();
// ...
// 修改后:谁申请谁释放,遍历释放
Listnode* p1 = new Listnode();
Listnode* p2 = new Listnode(2);
Listnode* p3 = new Listnode(3);
Listnode* p4 = new Listnode(4);
p1->next = p2; p2->next = p3; p3->next = p4; p4->next = nullptr;
for (Listnode* cur = p1; cur != nullptr; )
{
Listnode* next = cur->next; // 先存下一个节点,再 delete 当前节点
delete cur;
cur = next;
}
顺序很重要:delete cur 之后再读 cur->next 就是访问已释放内存。真实工程里更该做的是把"申请/释放"绑在一起(unique_ptr + 自定义链表析构,属于下一讲 RAII 的内容)。
修正 5:func() 的内存耗尽实验改成能收场的写法
// 修改前:无限循环 + 每次 1MB 从不释放,且 catch 基本等不到
void func()
{
int n = 1;
while(1)
{
void * p1 = new char [1024*1024];
cout << p1 << "->" << n << endl;
++n;
}
}
// 修改后:① 要观察"堆向上增长",就跑有限次并及时释放
void func(int times)
{
for (int n = 1; n <= times; ++n)
{
void* p1 = new char[1024 * 1024];
cout << p1 << "->" << n << endl;
delete[] p1; // 观察完就还回去,不泄漏
}
}
// ② 要观察 bad_alloc,直接申请一个超大的块(不要靠"慢慢耗")
int main()
{
try
{
void* p1 = new char[64ull * 1024 * 1024 * 1024 * 1024]; // 64TB
cout << p1 << endl;
}
catch (const exception& e)
{
cout << e.what() << endl; // 实测输出:std::bad_alloc
}
return 0;
}
new char[...]的返回值直接丢给void*是没问题的(new返回的就是对应类型的指针);但不 delete 就一定会泄漏,哪怕你手里只有void*,delete[] (char*)p1一样能还回去 —— 类型信息用来决定"怎么还",不是"能不能还"。
7. 小结
这一块的主线只有一条:C 的内存管理停在了"空间"这一层,C++ 往上加了"对象"这一层。
- 内存分布在讲"空间":栈、堆、静态区、常量区,
char2和*char2同地址、pChar3和*pChar3不同地址,这一组对比就是全部考点; malloc/calloc/realloc在讲"空间":给字节数、拿void*、失败给NULL、要你自己算大小;new/delete在讲"对象":给类型、拿指针、失败抛异常、自动调构造和析构;operator new/delete是这两层之间的桥:它证明new底下的"申请空间"就是malloc,多出来的是异常语义和可替换的钩子;- 定位 new 则是最后一块拼图:它证明"申请空间"和"构造对象"本来就是两个动作,
new只是把它们打包了。
把这几层顺序理顺,再把上面那些实验自己写一遍、跑一遍(对着打印看谁被调用了),这一块就不会再有"背了但不确定"的地方。
文中所有运行结果均由
g++ -g -O0 -Wall -Wextra -std=c++17在本机(macOS arm64)实跑得到;涉及平台差异(overcommit、ulimit -v、malloc 实现)的地方已单独标注。
296



被折叠的 条评论
为什么被折叠?



