【C++】内存管理

🔥 H.莓飛: 个人主页
✨ 心不畏死,踏屍前行。
📦 代码仓库:cocatrice_csdn_code
📖 博主简介:

概览
- 一个进程的地址空间分成哪几段
- malloc 家族的四个函数与返回值检查
- new 与 delete 的基本写法
- new 与 malloc 的差别
- operator new 与 delete 的真实身份
- new 表达式与 delete 表达式的完整步骤
- new\[\] 与 delete\[\] 的配对与定位 new
核心知识
前面几篇里的对象大多落在栈上,函数一返回就自己消失,写代码的人不用管回收这件事。一旦对象要活过函数调用,或者大小要到运行时才能确定,就得把它放到堆上,放上堆之后由写代码的人自己负责归还,new 与 delete 就是这件事的入口。
malloc 会申请空间却不会构造对象,new 把申请空间与构造对象连成一件事,这中间少掉任何一步都会留下问题。这一篇先把地址空间分段看一遍,弄清楚堆到底在哪里,再顺着 new 的调用链往下走:new 表达式先调 operator new 拿到一块原始内存,再在这块内存上跑构造函数;delete 反过来,先跑析构函数,再把内存交给 operator delete 归还。
凡是带地址的输出都只当作对照来读,同一份程序两次运行的地址不会一样,需要看的只是相对位置与字节数。

图 1 一个进程的地址空间分成哪几段**(此处箭头应当为低地址指向高地址,图示错误)**
一、C/C++ 的内存分布
地址空间是从低地址往高地址排的一长条区间,编译器与运行库把它切成几段,每一段放不同生命周期的数据。内核空间排在最上面,用户代码不能读写那一块;往下是栈,函数里的局部变量与调用现场都在这里,栈往低地址的方向增长;再往下是内存映射段,动态库与文件映射落在这块;堆在中间偏下的位置,new 与 malloc 拿到的空间都从这里划出来,它增长的方向与栈相反,往高地址涨;最底部是可执行文件自己的段,数据段放已经初始化的全局变量与静态变量,代码段放机器指令与字符串字面值。
只看划分图不够直观,把几个名字的地址打出来,再与 /proc/self/maps 对照一遍,位置就落地了。下面这段程序把全局变量、静态变量、局部变量、字符串字面值与 malloc 出来的空间各取一个地址,然后到 maps 里查出每个地址落在哪一段。
cpp
// 文件:/home/cocatrice/cpp06/demo/address.cpp(节选)
void Test()
{
int localVar = 1;
static int staticVar = 1;
int num1[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
char char2[] = "abcd";
const char* pChar3 = "abcd";
int* ptr1 = (int*)malloc(sizeof(int) * 4);
printf("&globalVar %p\n", (void*)&globalVar);
printf("&staticGlobalVar %p\n", (void*)&staticGlobalVar);
printf("&g_arr %p\n", (void*)&g_arr);
printf("&staticVar %p\n", (void*)&staticVar);
printf("&localVar %p\n", (void*)&localVar);
printf("&num1 %p\n", (void*)&num1);
printf("&pChar3 %p\n", (void*)&pChar3);
printf("pChar3 指向的字面值 %p\n", (void*)pChar3);
printf("&ptr1 %p\n", (void*)&ptr1);
printf("ptr1 %p\n", (void*)ptr1);
printf("&Test %p\n", (void*)&Test);
ShowRange("globalVar", &globalVar);
ShowRange("staticVar", &staticVar);
ShowRange("ptr1 指向的堆", ptr1);
ShowRange("localVar", &localVar);
ShowRange("字符串字面值", pChar3);
ShowRange("函数 Test", (void*)&Test);
free(ptr1);
}
ShowRange 做的事情是打开 /proc/self/maps 逐行扫描,找到包含这个地址的那一行打印出来,省得手工比对。编译时用 -O0,把优化关掉能让每一个局部变量的地址都留在栈上。
text
$ g++ -std=c++11 -Wall -O0 -o address address.cpp
$ ./address
======== 各个名字的地址 ========
&globalVar 0x602088
&staticGlobalVar 0x60208c
&g_pChar3 0x602090
&g_arr 0x6020c0
&staticVar 0x602098
&localVar 0x7ffc6d21c6d4
&num1 0x7ffc6d21c680
&char2 0x7ffc6d21c6c0
&pChar3 0x7ffc6d21c6b8
pChar3 指向的字面值 0x400e3c
&ptr1 0x7ffc6d21c6b0
ptr1 0x22b1c20
&Test 0x4009f5
======== 谁落在哪个映射里 ========
globalVar 0x602088 00602000-00603000 rw-p 00002000 fd:01 925073 /home/cocatrice/cpp06/demo/address
staticVar 0x602098 00602000-00603000 rw-p 00002000 fd:01 925073 /home/cocatrice/cpp06/demo/address
ptr1 指向的堆 0x22b1c20 022a0000-022d2000 rw-p 00000000 00:00 0 [heap]
localVar 0x7ffc6d21c6d4 7ffc6d1fd000-7ffc6d21e000 rw-p 00000000 00:00 0 [stack]
字符串字面值 0x400e3c 00400000-00402000 r-xp 00000000 fd:01 925073 /home/cocatrice/cpp06/demo/address
函数 Test 0x4009f5 00400000-00402000 r-xp 00000000 fd:01 925073 /home/cocatrice/cpp06/demo/address
======== 栈的两个局部变量 ========
main 里的局部 0x7ffc6d21c6d4
被调函数里的局部 0x7ffc6d21c66c
两个地址相差 104 字节
从这段输出能读出四条:&globalVar 是 0x602088,落在 00602000 那段 rw-p 里,这段空间属于可执行文件本身,程序一装进内存全局变量就已经有位置了;&localVar 是 0x7ffc6d21c6d4,落在 stack 那段,它的值接近栈顶,而 &char2 与 &num1 这些数组也都挨在一起;pChar3 本身在栈上,它指向的 0x400e3c 却在 00400000 那段 r-xp 里,与函数 Test 同一个映射,字符串字面值就放在只读的代码段里;ptr1 的值 0x22b1c20 落在 heap,malloc 交出来的空间从堆里划。
栈的两个局部相差 104 字节这句话信息量不小,main 里的变量地址比被调函数里的更高,说明后压入的栈帧落在更低的地址上,栈确实是往低地址长的。数组中间的间隔、对齐补的字节,都会影响这个差值的具体大小。
静态变量在符号表里还有一层区分,用 nm 看它们的类型字母就能分辨是已初始化还是未初始化。
text
$ nm -C address | grep -E "globalVar|staticGlobalVar|g_pChar3|g_arr|staticVar"
00000000006020c0 B g_arr
0000000000602088 D globalVar
0000000000602090 D g_pChar3
000000000060208c d staticGlobalVar
0000000000602098 d Test()::staticVar
$ size address
text data bss dec hex filename
4088 700 72 4860 12fc address
$ readelf -S address | grep -E "text|rodata|data|bss"
[13] .text PROGBITS 0000000000400740 00000740
[15] .rodata PROGBITS 0000000000400d70 00000d70
[24] .data PROGBITS 0000000000602080 00002080
[25] .bss NOBITS 00000000006020a0 0000209c
字母 D 与 d 代表已初始化的数据,B 代表未初始化的全局数组,大写与小写的区别在于链接时能不能被别的文件看见。size 这一行给出了三个段的字节数,data 700 字节里装着那些有初值的全局变量,bss 72 字节里只记了一个大小,真正的初值全零用不着占文件空间,所以 .bss 的属性写的是 NOBITS。

图 2 五个名字与它们所在的段
段与段之间并不全都连着,可执行文件里的 .bss 段只记下要占多大,程序启动时才由内核给出真正可写的一片地址,所以未初始化的全局数组在文件里不占空间,在进程里却有地址。堆与栈之间隔着内存映射段,动态库与文件映射都落在那里,这也是堆向上长、栈向下长却不容易撞到一起的原因。
二、malloc 家族复习
malloc 这一族有四个函数,malloc 只管申请,calloc 申请之后把整块内存清成 0,realloc 用来把已有的块改大或者改小,free 负责归还。四个函数的声明都在 stdlib.h 里,成功时返回的是一块原始内存的首地址,类型是 void*,失败时返回 NULL 并把 errno 置成对应的错误码,检查返回值这件事在 C 代码里是必须做的。
cpp
// 文件:/home/cocatrice/cpp06/demo/malloc_family.cpp(节选)
int* p1 = (int*)malloc(sizeof(int) * 4);
int* p2 = (int*)calloc(4, sizeof(int));
Show("malloc 出来的", p1, 4);
Show("calloc 出来的", p2, 4);
for (int i = 0; i < 4; ++i)
p1[i] = i + 1;
Show("p1", p1, 4);
char* q = (char*)malloc(100);
printf("realloc 之前 p2 = %p\n", (void*)p2);
p2 = (int*)realloc(p2, sizeof(int) * 8);
printf("realloc 之后 p2 = %p\n", (void*)p2);
for (int i = 4; i < 8; ++i)
p2[i] = i + 1;
Show("p2 八个元素", p2, 8);
printf("realloc 之前 q = %p\n", (void*)q);
char* q2 = (char*)realloc(q, 1024 * 1024);
printf("realloc 之后 q = %p\n", (void*)q2);
printf("旧地址与新地址%s\n", q == q2 ? "相同" : "不同");
void* z = malloc(0);
printf("malloc(0) 返回 %p\n", z);
free(z);
errno = 0;
void* big = malloc((size_t)-1);
printf("malloc((size_t)-1) 返回 %p\n", big);
if (big == NULL)
{
printf("errno = %d,strerror 说 %s\n", errno, strerror(errno));
}
p2 一开始拿的是 calloc 的清零空间,前面四个位置写进 5 6 7 8 之前,realloc 把它从 16 字节扩到 32 字节。q 那次扩容跨得远,从 100 字节直接要 1M,两条路径在实测里表现完全不同。
text
$ g++ -std=c++11 -Wall -O0 -o malloc_family malloc_family.cpp
$ ./malloc_family
======== malloc 与 calloc 的初值 ========
malloc 出来的 地址 0x23aec20 : 0 0 0 0
calloc 出来的 地址 0x23aec40 : 0 0 0 0
写入之后:
p1 地址 0x23aec20 : 1 2 3 4
======== realloc 扩容 ========
realloc 之前 p2 = 0x23aec40
realloc 之后 p2 = 0x23aecd0
p2 八个元素 地址 0x23aecd0 : 0 0 0 0 5 6 7 8
realloc 之前 q = 0x23aec60
realloc 之后 q = 0x7efd15c9d010
旧地址与新地址不同
======== 返回值检查 ========
malloc(0) 返回 0x23aec40
malloc((size_t)-1) 返回 (nil)
errno = 12,strerror 说 Cannot allocate memory
刚申请出来的两块都读到 0,这件事很容易把 malloc 与 calloc 看成一样,其实第一次申请走的是内核刚交出来的新页,新页本来就是清零的,两者在这一点上看不出区别。要分辨得换一个场景:先申请、写满、释放,再申请同样大小的一批。
text
$ g++ -std=c++11 -Wall -O0 -o malloc_dirty malloc_dirty.cpp
$ ./malloc_dirty
======== 刚启动程序,第一次 malloc ========
malloc 后 地址 0x1d08c20 : 0 0 0 0
calloc 后 地址 0x1d08c40 : 0 0 0 0
两块都是新页,内核交出来的页是清零的,看不出区别
======== 写过再释放,然后再申请同一批 ========
写进去的值 : 100 101 102 103
再 malloc 地址 0x1d08c40 : 30444560 0 0 0
再 calloc 地址 0x1d08c20 : 0 0 0 0
这一批输出把两个函数的差别摆清楚了,写过的块释放之后又回到管理器的空闲链表里,malloc 再拿到它时里面还是上一次留下的内容,第一格里那个 30444560 就是堆块自己的管理数据被当成 int 读了出来,只有 calloc 会在给出空间之前把每个字节清成 0。
realloc 的地址变化也有讲究,p2 从 0x23aec40 挪到 0x23aecd0,说明原来那块后面没有足够的连续空间,管理器换了一块更大的并搬了过去,旧数据 0 0 0 0 5 6 7 8 里前四个 0 就是原来的内容。q 那次从 100 字节扩到 1M,地址直接从 0x23aec60 跳到 0x7efd15c9d010,跨进了内存映射段,这么大的块 glibc 不会走堆,而是单独 mmap 一段出来。
返回值检查里有三个数字:malloc(0) 返回的是一个可以 free 的地址,不是空指针;malloc((size_t)-1) 返回 NULL,errno 被置成 12,strerror 给出 Cannot allocate memory;valgrind 下同样这几行跑出来 errno 是 0,那是因为 valgrind 自己接管了内存,这一条在真机上以不带 valgrind 的输出为准。

图 3 malloc 家族四个函数的入参与返回值
二节里那几行回显还能看出两个平时容易忽略的细节。free 过之后的指针不会变成空,里面还是原来那个地址,再去读它就是读到一块已经交还出去的内存;realloc 扩容时如果原地放不下,会把老内容整块搬到新地址,搬完把老地址还掉,所以扩容之后要用返回值,不能继续用原来的指针。
三、new 与 delete 的用法
new 的写法有三种:new int 申请一个 int,new int(10) 申请一个 int 并把初值写成 10,new int5 申请五个 int 的数组。带自定义类型时把构造参数写在类型后面的括号里,new A(1, 2) 会在拿到空间之后把这两个参数交给 A 的构造函数。释放的时候要认准对应的写法,单个对象用 delete,数组用 delete\[\]。
cpp
// 文件:/home/cocatrice/cpp06/demo/new_basic.cpp(节选)
int* p1 = new int;
int* p2 = new int(10);
int* p3 = new int[5];
int* p4 = new int[5]();
printf("new int -> %d(没有初值)\n", *p1);
printf("new int(10) -> %d\n", *p2);
printf("new int[5] -> %d %d %d %d %d(没有初值)\n", p3[0], p3[1], p3[2], p3[3], p3[4]);
printf("new int[5]() -> %d %d %d %d %d(值初始化)\n", p4[0], p4[1], p4[2], p4[3], p4[4]);
delete p1;
delete p2;
delete[] p3;
delete[] p4;
A* pa = new A(1, 2);
printf("pa 的地址 %p\n", (void*)pa);
delete pa;
A* parr = new A[3];
printf("parr 的地址 %p\n", (void*)parr);
delete[] parr;
A aa1(11, 12);
A aa2(21, 22);
A aa3(31, 32);
A* parr2 = new A[3]{aa1, aa2, aa3};
delete[] parr2;
A* parr3 = new A[3];
for (int i = 0; i < 3; ++i)
printf("parr3[%d] 的地址 %p\n", i, (void*)(parr3 + i));
printf("sizeof(A) = %d\n", (int)sizeof(A));
delete[] parr3;
A 里放了两个 int,构造函数、拷贝构造函数、赋值重载与析构函数各打印一行并带上 this,跑起来之后每一次构造与析构都能从日志里数出来。
这四种内置类型的写法里,只有 new int5 后面那对空括号能把数组清零,前一种写法拿到的五个 int 没有初值。数组的析构顺序与构造顺序相反,日志里 0x1598cb8 先走、0x1598ca8 最后走,正好倒过来。
text
$ g++ -std=c++11 -Wall -O0 -o new_basic new_basic.cpp
$ ./new_basic
======== 内置类型 ========
new int -> 0(没有初值)
new int(10) -> 10
new int[5] -> 0 0 0 0 0(没有初值)
new int[5]() -> 0 0 0 0 0(值初始化)
======== 自定义类型,单个对象 ========
A::A(1, 2) 构造,this = 0x1598c80
pa 的地址 0x1598c80
A::~A() 析构,this = 0x1598c80
======== 自定义类型,数组 ========
A::A(0, 0) 构造,this = 0x1598ca8
A::A(0, 0) 构造,this = 0x1598cb0
A::A(0, 0) 构造,this = 0x1598cb8
parr 的地址 0x1598ca8
A::~A() 析构,this = 0x1598cb8
A::~A() 析构,this = 0x1598cb0
A::~A() 析构,this = 0x1598ca8
======== 自定义类型,数组加初始化列表 ========
A::A(11, 12) 构造,this = 0x7fff2793afe0
A::A(21, 22) 构造,this = 0x7fff2793afd0
A::A(31, 32) 构造,this = 0x7fff2793afc0
A::A(const A&) 拷贝构造,this = 0x1598ca8
A::A(const A&) 拷贝构造,this = 0x1598cb0
A::A(const A&) 拷贝构造,this = 0x1598cb8
parr2 的地址 0x1598ca8
A::~A() 析构,this = 0x1598cb8
A::~A() 析构,this = 0x1598cb0
A::~A() 析构,this = 0x1598ca8
======== 数组里每个元素的地址 ========
A::A(0, 0) 构造,this = 0x1598ca8
A::A(0, 0) 构造,this = 0x1598cb0
A::A(0, 0) 构造,this = 0x1598cb8
parr3[0] 的地址 0x1598ca8
parr3[1] 的地址 0x1598cb0
parr3[2] 的地址 0x1598cb8
sizeof(A) = 8
内置类型那四行里,new int 与 new int5 这次都读到 0,这个 0 只是这块内存恰好来自刚交出来的新页,语言层面没有这条保证。new int(10) 后面的括号是把初值写进去,new int5 后面那对空括号是值初始化,这两处括号的位置不一样,作用也不一样,把 new int5 里的括号漏掉写法的差别在自建类型上更明显。
自定义类型的三段日志把构造与析构的顺序说清楚了。new A(1, 2) 打印一行构造,this 与 pa 的值一样,delete 时打印一行析构;new A3 连着打印三行构造,三个 this 是 0x1598ca8、0x1598cb0、0x1598cb8,相邻之间正好差 sizeof(A) 的 8 字节,delete\[\] 时析构顺序颠倒过来,从最后一个元素往前跑。
数组带初始化列表这一段日志更长,栈上先构造出 aa1、aa2、aa3 三个临时对象,地址在 0x7fff2793afe0 附近,接着数组的三个位置各调用一次拷贝构造,把这三个对象复制进去。所以 new A3{aa1, aa2, aa3} 一共产生了三次构造加三次拷贝构造,delete\[\] 结束时先析构数组里的三个,再析构栈上那三个临时对象。
数组初始化列表这个写法是 C++11 才有的,用 -std=c++98 编译同一份源码只得到一行警告,程序照旧能跑。
text
$ g++ -std=c++98 -Wall -O0 -o new_basic98 new_basic.cpp
new_basic.cpp: In function 'int main()':
new_basic.cpp:67:20: warning: extended initializer lists only available with -std=c++11 or -std=gnu++11 [enabled by default]
A* parr2 = new A[3]{aa1, aa2, aa3};
^
四、new 与 malloc 的差别
把两个函数放在一份程序里对照,差别集中在四个地方:返回值的类型、参数怎么写、有没有调用构造函数、申请失败时的行为。前三条能在同一份源码里一次跑出来,第四条要看申请失败那一刻。
cpp
// 文件:/home/cocatrice/cpp06/demo/new_vs_malloc.cpp(节选)
int* m = (int*)malloc(sizeof(int));
int* n = new int;
printf("m = %p n = %p\n", (void*)m, (void*)n);
free(m);
delete n;
int* ma = (int*)malloc(sizeof(int) * 10);
int* na = new int[10];
printf("ma = %p na = %p\n", (void*)ma, (void*)na);
free(ma);
delete[] na;
char* dirty = (char*)malloc(4096);
memset(dirty, 0xAB, 4096);
free(dirty);
A* m2 = (A*)malloc(sizeof(A));
printf("malloc 出来的 A:_a1 = %d,_a2 = %d,构造函数没有被调用\n", m2->_a1, m2->_a2);
printf("按十六进制看 _a1 = 0x%X\n", (unsigned)m2->_a1);
free(m2);
A* n2 = new A(1, 2);
printf("new 出来的 A:_a1 = %d,_a2 = %d\n", n2->_a1, n2->_a2);
delete n2;
text
$ g++ -std=c++11 -Wall -O0 -o new_vs_malloc new_vs_malloc.cpp
$ ./new_vs_malloc
======== 差别一:返回值的类型 ========
malloc 给的是 void*,赋给 int* 要写强转;new int 拿到的就是 int*
m = 0x23dcc20 n = 0x23dcc40
======== 差别二:参数写什么 ========
malloc 要写 sizeof(int) * 10,也就是 40 字节
new int[10] 只写个数,剩下的编译器算,实际也是 40 字节
ma = 0x23dcc60 na = 0x23dcc90
======== 差别三:有没有调用构造函数 ========
malloc 出来的 A:_a1 = -1414812757,_a2 = -1414812757,构造函数没有被调用
按十六进制看 _a1 = 0xABABABAB
A::A(1, 2) 构造,this = 0x23dcc20
new 出来的 A:_a1 = 1,_a2 = 2
A::~A() 析构,this = 0x23dcc20
======== 差别四:申请失败时的行为 ========
这一条要用 ulimit 把地址空间压小之后分开跑,见 bad_alloc.cpp
malloc 的返回值是 void*,赋给 int* 必须写强转,漏掉强转就是一条编译错误。参数那一栏的差别更直观:malloc 只认字节数,要申请十个 int 得自己乘 sizeof(int),new int10 只写元素个数,乘法交给编译器算,两边拿到的都是 40 字节。
第三个差别是这一篇的重点,后面几段实验都围着它转。malloc 只把一块原始内存交出来,里面的字节是什么就一直是什么,程序在申请之前先把 4096 字节全写成 0xAB 再释放,malloc 复用了同一批内存,读 A 的成员就把 0xABABABAB 当 int 解释了出来,-1414812757 就是这个十六进制按有符号数读的结果。new A(1, 2) 走的是另一条路,构造函数的 this 与 malloc 拿到的是同一类地址,但构造函数跑过一次,_a1 与 _a2 才是 1 与 2。
第四个差别留给五与六两节展开,简单的说,malloc 失败只返回 NULL,调用方必须自己检查;new 失败会抛出一个 std::bad_alloc 异常,不写 try 的人会看到 terminate。
四个差别合起来是同一件事的两半:malloc 交出来的是一块字节区域,new 交出来的是一个已经活过来的对象。想清楚这一条,五节与六节里 operator new 与构造函数的先后顺序就好记了。

图 4 new 与 malloc 的四处差别
operator new 与 operator delete 是一对普通的函数,它们只跟字节打交道,跟类型无关,所以能像别的函数一样重载,也能像普通函数一样直接调用。重载的时候要注意作用域,写成类的静态成员只影响这个类,写成全局函数才影响整个程序。
五、operator new 与 operator delete
operator new 与 operator delete 长得像运算符,其实是两个普通的函数,名字里的 operator 只是让编译器在遇到 new 表达式时优先去找它们。标准给的原型是 void* operator new(size_t size) 与 void operator delete(void* p),前者的返回值必须是 void*,第一个参数必须是 size_t,返回值类型或者参数类型写错都会直接报编译错误。
它们可以写在全局作用域里被整份程序替换掉,重载一份就能把每一次申请都记下来。
cpp
// 文件:/home/cocatrice/cpp06/demo/opnew_count.cpp(节选)
static int g_new_count = 0;
static int g_delete_count = 0;
static unsigned long g_new_bytes = 0;
void* operator new(size_t size)
{
void* p = malloc(size);
++g_new_count;
g_new_bytes += size;
printf("[operator new] 第 %2d 次调用,size = %lu,malloc 返回 %p\n",
g_new_count, (unsigned long)size, p);
return p;
}
void operator delete(void* p)
{
if (p == NULL)
return;
++g_delete_count;
printf("[operator delete] 第 %2d 次调用,归还 %p\n", g_delete_count, p);
free(p);
}
这份重载里申请与归还都走 malloc 与 free,自己记了两个计数与一个字节数。这一段只重载了单个对象的版本,数组版没动,所以 new int10 与 new A3 也会走到这里来,收到的 size 就是数组那一整块。
text
$ g++ -std=c++11 -Wall -O0 -o opnew_count opnew_count.cpp
$ ./opnew_count
======== new int(10) ========
[operator new] 第 1 次调用,size = 4,malloc 返回 0x1fd2c20
*p1 = 10
[operator delete] 第 1 次调用,归还 0x1fd2c20
======== new int[10] ========
[operator new] 第 2 次调用,size = 40,malloc 返回 0x1fd2c40
数组的地址 0x1fd2c40
[operator delete] 第 2 次调用,归还 0x1fd2c40
======== new A(1, 2) ========
[operator new] 第 3 次调用,size = 8,malloc 返回 0x1fd2c20
A::A(1, 2) 构造,this = 0x1fd2c20
A::~A() 析构,this = 0x1fd2c20
[operator delete] 第 3 次调用,归还 0x1fd2c20
======== new A[3] ========
[operator new] 第 4 次调用,size = 32,malloc 返回 0x1fd2c40
A::A(0, 0) 构造,this = 0x1fd2c48
A::A(0, 0) 构造,this = 0x1fd2c50
A::A(0, 0) 构造,this = 0x1fd2c58
数组首元素的地址 0x1fd2c48,sizeof(A) = 8
A::~A() 析构,this = 0x1fd2c58
A::~A() 析构,this = 0x1fd2c50
A::~A() 析构,this = 0x1fd2c48
[operator delete] 第 4 次调用,归还 0x1fd2c40
======== 汇总 ========
operator new 被调用 4 次,累计申请 84 字节
operator delete 被调用 4 次
四行调用记录里的 size 一栏是最值得看的地方。new int(10) 报的是 4,正是 sizeof(int);new int10 报的是 40,10 乘 4 由编译器算好交给 operator new;new A(1, 2) 报的是 8,也就是 sizeof(A);new A3 报的是 32,比 3 乘 8 多出 8 字节,多出来的这 8 字节是数组头部用的,malloc 返回 0x1fd2c40,而数组首元素的地址是 0x1fd2c48,中间隔的正好是这 8 字节。
同一份记录里还有一处细节:new A(1, 2) 那次 malloc 返回 0x1fd2c20,与第一次 new int(10) 拿到的地址相同,说明这块 8 字节的块被归还之后又分了出来。累计申请 84 字节等于 4 加 40 加 8 加 32,四次的 size 加起来就是屏幕上这个数。
自己写的 operator new 底下去调了 malloc,标准库给的默认版本也是这么做的。libstdc++ 里这两个函数的符号名分别是 _Znwm 与 _ZdlPv,看符号表就能找到它们,进函数体之后看到的就是一次 malloc 与一次 free。
text
$ echo $LD_LIBRARY_PATH
:/home/cocatrice/.VimForCpp/vim/bundle/YCM.so/el7.x86_64
$ ldd new_expand | grep -E "libstdc|libc"
libstdc++.so.6 => /home/cocatrice/.VimForCpp/vim/bundle/YCM.so/el7.x86_64/libstdc++.so.6 (0x00007fe013b67000)
libc.so.6 => /lib64/libc.so.6 (0x00007fe013281000)
$ objdump -T /usr/lib64/libstdc++.so.6 | grep -E " _Znwm| _ZdlPv"
000000000005d300 g DF .text 0000000000000005 GLIBCXX_3.4 _ZdlPvRKSt9nothrow_t
000000000005f170 g DF .text 0000000000000090 GLIBCXX_3.4 _Znwm
000000000005f200 g DF .text 0000000000000078 GLIBCXX_3.4 _ZnwmRKSt9nothrow_t
000000000005d2e0 g DF .text 0000000000000012 GLIBCXX_3.4 _ZdlPv
这里有个小插曲,测试机的 LD_LIBRARY_PATH 里带着另一个目录,程序实际加载的是那一份 libstdc++.so.6,与 /usr/lib64 下的那一份不是同一个文件。要看默认版本就明确指向 /usr/lib64,两个文件的 md5 分别是 fb32fedce5089591fbf9ca7d66d0fbb1 与 10e54b28aa981051d1b17288a1959759。
text
$ objdump -d --no-show-raw-insn --start-address=0x5f170 --stop-address=0x5f200 /usr/lib64/libstdc++.so.6
/usr/lib64/libstdc++.so.6: file format elf64-x86-64
Disassembly of section .text:
000000000005f170 <_Znwm@@GLIBCXX_3.4>:
5f170: push %rbp
5f171: mov $0x1,%eax
5f176: push %rbx
5f177: mov %rdi,%rbx
5f17a: sub $0x8,%rsp
5f17e: test %rdi,%rdi
5f181: cmove %rax,%rbx
5f185: mov %rbx,%rdi
5f188: callq 594c0 <malloc@plt>
5f18d: test %rax,%rax
5f190: jne 5f1b8 <_Znwm@@GLIBCXX_3.4+0x48>
5f192: lea 0x2a5327(%rip),%rbp # 3044c0 <_ZSt15future_category@@GLIBCXX_3.4.14+0x11c98>
5f199: nopl 0x0(%rax)
5f1a0: mov 0x0(%rbp),%rax
5f1a4: test %rax,%rax
5f1a7: je 5f1bf <_Znwm@@GLIBCXX_3.4+0x4f>
5f1a9: callq *%rax
5f1ab: mov %rbx,%rdi
5f1ae: callq 594c0 <malloc@plt>
5f1b3: test %rax,%rax
5f1b6: je 5f1a0 <_Znwm@@GLIBCXX_3.4+0x30>
5f1b8: add $0x8,%rsp
5f1bc: pop %rbx
5f1bd: pop %rbp
5f1be: retq
5f1bf: mov $0x8,%edi
5f1c4: callq 59c70 <__cxa_allocate_exception@plt>
5f1c9: mov 0x291940(%rip),%rdx # 2f0b10 <_ZTVSt9bad_alloc@@GLIBCXX_3.4+0x5ff0>
5f1d0: mov 0x2916a1(%rip),%rsi # 2f0878 <_ZTISt9bad_alloc@@GLIBCXX_3.4+0x5d78>
5f1d7: mov %rax,%rdi
5f1da: add $0x10,%rdx
5f1de: mov %rdx,(%rax)
5f1e1: mov 0x291d38(%rip),%rdx # 2f0f20 <_ZNSt9bad_allocD1Ev@@GLIBCXX_3.4+0x293f80>
5f1e8: callq 5a0d0 <__cxa_throw@plt>
5f1ed: cmp $0xffffffffffffffff,%rdx
5f1f1: mov %rax,%rdi
5f1f4: je 5f1fb <_Znwm@@GLIBCXX_3.4+0x8b>
5f1f6: callq 5b180 <_Unwind_Resume@plt>
5f1fb: callq 59930 <__cxa_call_unexpected@plt>
$ objdump -d --no-show-raw-insn --start-address=0x5d2e0 --stop-address=0x5d2f2 /usr/lib64/libstdc++.so.6
/usr/lib64/libstdc++.so.6: file format elf64-x86-64
Disassembly of section .text:
000000000005d2e0 <_ZdlPv@@GLIBCXX_3.4>:
5d2e0: test %rdi,%rdi
5d2e3: je 5d2f0 <_ZdlPv@@GLIBCXX_3.4+0x10>
5d2e5: jmpq 59c20 <free@plt>
5d2ea: nopw 0x0(%rax,%rax,1)
5d2f0: repz retq
这几十行汇编把 operator new 的骨架交代得很干净。开场 test %rdi,%rdi 加 cmove %rax,%rbx 处理申请 0 字节的情况,把 0 换成 1 再往下走,这样 malloc 不会拿到一个 0 长度的参数;中间 callq 594c0 malloc@plt 说明默认版本的底层就是 malloc;test %rax,%rax 之后 jne 跳走是成功路径,返回指针前把栈恢复成进来时的样子。
失败路径从 5f192 那一行开始,lea 0x2a5327(%rip),%rbp 取到的是 new_handler 这个函数指针的地址,mov 0x0(%rbp),%rax 读出当前的值,test 之后 je 判断它是不是空。处理器非空就 callq *%rax 调一次,调完 mov %rbx,%rdi 接着 callq malloc 再申请,仍然失败就回到 5f1a0 重新取指针,只要用户换上的处理器还在,这个循环就继续转,用户也可以在处理函数里多腾一点内存出来。
处理器为空时走的是最后一段,__cxa_allocate_exception 先给异常对象分配空间,_ZTISt9bad_alloc 是 bad_alloc 的类型信息,__cxa_throw 把异常抛出去。operator delete 那边短得多,test %rdi,%rdi 挡住空指针,je 直接跳过,其余情况 jmpq 59c20 free@plt 一步交还给 free,连栈帧都没有调整。

图 5 申请不到空间时程序往哪走
六、new 的完整过程
new 表达式要做两件事,先拿到一块原始内存,再在这块内存上把对象构造出来。拿内存这一步调的就是上一节的 operator new,参数是编译器算好的 sizeof(类型);构造这一步由编译器插入直接的构造函数调用,两步之间没有别的动作,所以任何一步出的问题都会以构造失败的形式暴露出来。
把 new A(7) 的一句源码编译出来看,两步在汇编里排得很清楚。
text
$ objdump -d -C --no-show-raw-insn new_expand | sed -n "/<main>:/,/^$/p"
00000000004007dd <main>:
4007dd: push %rbp
4007de: mov %rsp,%rbp
4007e1: push %r12
4007e3: push %rbx
4007e4: sub $0x10,%rsp
4007e8: mov $0x4,%edi
4007ed: callq 4006d0 <operator new(unsigned long)@plt>
4007f2: mov %rax,%rbx
4007f5: mov $0x7,%esi
4007fa: mov %rbx,%rdi
4007fd: callq 400888 <A::A(int)>
400802: mov %rbx,-0x18(%rbp)
mov 0x4,%edi 把 4 这个字节数放进第一个参数,callq operator new(unsigned long)@plt 拿到空间,返回值搬到 %rbx 存着,接着 mov 0x7,%esi 放构造参数,mov %rbx,%rdi 把刚拿到的空间地址当作 this 传进构造函数,callq A::A(int) 完成构造。整个流程里没有出现 malloc,申请那一步全部被包在 operator new 里。
new 表达式不会把构造函数的返回值当结果,最后落到变量里的是 operator new 交出来的那个地址,也就是 %rbx 的内容。这解释了为什么定位 new 能让 new 表达式与手工构造拿到同一个地址,两者的区别只在于空间从哪来。
构造函数如果在中途抛了异常,已经申请到的空间不能就这样挂着,编译器会插入一段清理代码。
text
400868: mov %rax,%r12
40086b: mov %rbx,%rdi
40086e: callq 400690 <operator delete(void*)@plt>
400873: mov %r12,%rax
400876: mov %rax,%rdi
400879: callq 4006e0 <_Unwind_Resume@plt>
这几行是异常表里的着陆点,%rbx 里存的还是 operator new 拿到的地址,先把它交给 operator delete 归还,再把异常对象交给 _Unwind_Resume 继续往外传。手工写 new A(7) 的人不需要写这段代码,但理解它的存在能解释一个现象:构造函数抛异常不会漏内存。
数组版本的 new int4 走的是另一个符号,编译器把元素个数乘出来之后调用 operator new\[\](unsigned long)。下面这两行与前面 new A(7) 是一份程序里相邻的语句。
text
400836: mov $0x10,%edi
40083b: callq 400680 <operator new[](unsigned long)@plt>
放到自定义类型上还会多一个动作,new A3 先向 operator new\[\] 要 32 字节,再从里面划出 24 字节给三个对象,剩下的 8 字节留给数组头部,所以首元素的地址比 operator new\[\] 返回的地址高 8 字节,这件事在八节里再展开。
new 只负责给空间,初值要靠写出来的括号去要,这一点在同一批内存上对比最清楚。程序先把 45 个字节的位置写成 41 到 45,释放之后用 new int5 申请同样大小的一块。
text
$ g++ -std=c++11 -Wall -O0 -o new_uninit new_uninit.cpp
$ ./new_uninit
======== 先看一下堆块被复用时的内容 ========
写进 old 的值 41 42 43 44 45
======== 同样大小的一批,换成 new int[5] ========
new int[5] : 0 0 43 44 45
十六进制 : 0x00000000 0x00000000 0x0000002B 0x0000002C 0x0000002D
new int[5]() : 0 0 0 0 0
======== 再换成 malloc 同一批 ========
malloc 出来的 : 32107536 0 0 0 0
new int5 读到了 43 44 45 三个残留值,前两格是 0 只是因为它们被别的东西覆写过,hex 那一行里 0x2B 0x2C 0x2D 正是 43 44 45。换成 new int5 之后五个格子全是 0,因为那对空括号要求做值初始化,编译器会插入清零的指令。同一批内存换成 malloc 申请,第一格读出来的 32107536 是堆块的管理数据被当成 int 解释的结果。

图 6 new 表达式与 delete 表达式各自做的事
把 main 的反汇编与 libstdc++ 里 operator new 的实现拼在一起,new A(7) 的路径就完整了:编译器把 sizeof(A) 交给 operator new(unsigned long)@plt,这一步进到库里去要内存,拿到地址之后再调 A::A(int),两步都成功才把指针交给变量。出错的时候多一条 _Unwind_Resume@plt,异常从构造函数里往外传,已经申请到的内存由编译器插进去的清理代码归还。
七、delete 的完整过程
delete 表达式的顺序与 new 相反,先析构对象,再把空间还给 operator delete。两者的地址是同一个,析构函数里的 this 与 operator delete 收到的参数都是 new 当时拿到的那个地址。
text
40081d: mov -0x18(%rbp),%rbx
400821: test %rbx,%rbx
400824: je 400836 <main+0x59>
400826: mov %rbx,%rdi
400829: callq 4008ba <A::~A()>
40082e: mov %rbx,%rdi
400831: callq 400690 <operator delete(void*)@plt>
这段汇编里有顺序也有判断,顺序指申请与构造谁在先,判断落在指针为空的时候。test %rbx,%rbx 加 je 让 delete 一个空指针时直接跳过后面两步,这也是 delete NULL 不会崩的原因;判断过了以后 mov %rbx,%rdi 先把地址交给 A::~A(),析构函数返回之后同一个地址再交给 operator delete(void*)@plt。归还这一步进到 libstdc++ 里就是一次 free,前面那几行 _ZdlPv 的反汇编已经看到过。
delete 不会把指针改成空,语句执行完之后变量里还是那个地址,再用它去访问就是野指针。想要避免这一类问题得在 delete 之后自己写上 p = NULL,或者干脆把指针放进一个负责释放的类里。
三行汇编对应 delete 表达式的三步,判断在第一步,析构在第二步,归还内存在第三步。空指针走到第一步就结束,所以 delete NULL 与 delete\[\] NULL 都安全,写这两句不需要额外的判断。
析构与归还是两件独立的事,析构函数里该做的事要做好,比如关掉文件、断开连接、把计数器减回去;operator delete 那边只管把字节还回去,它不知道对象里装了什么。
delete 与 delete\[\] 执行完之后,指针变量里仍然留着原来那个地址,把它置成空是调用方自己的事。工程里更常见的做法是不让裸指针到处跑,把申请与释放交给同一个类,构造时申请、析构时释放,中间那段代码不管从哪条路返回,释放都会发生。
八、new\[\] 与 delete\[\] 必须配对
数组的申请与释放必须各用各的写法,编译器为了记住数组里有几个元素,会在元素前面多放一块 8 字节的头部,delete\[\] 靠读回这块头部才知道要析构几次。五节的 operator new\[\] 收到 32 字节、首元素地址比它的返回地址高 8 字节,说的就是这块头部。
把 new B10 交到 delete 手上会同时丢掉两样东西:析构次数少了九个,归还的地址也错到了区块中间。
cpp
// 文件:/home/cocatrice/cpp06/demo/mismatch_del.cpp(节选)
B* p2 = new B[10];
printf("数组首元素的地址 %p\n", (void*)p2);
printf("delete p2 之前,已经构造了 %d 个\n", B::_count);
delete p2;
printf("delete 那一行执行完毕\n");
printf("到这里没有别的析构打印了\n");
class B 里只有一个 int 与一个静态计数器,构造函数把自己的编号与 this 都打印出来,这段日志正好把丢失的部分变得可见。
text
$ g++ -std=c++11 -Wall -O0 -o mismatch_del mismatch_del.cpp
$ ./mismatch_del
======== new B[10] 配 delete ========
B() 构造,_id = 0,this = 0x8d6c28
B() 构造,_id = 1,this = 0x8d6c2c
B() 构造,_id = 2,this = 0x8d6c30
B() 构造,_id = 3,this = 0x8d6c34
B() 构造,_id = 4,this = 0x8d6c38
B() 构造,_id = 5,this = 0x8d6c3c
B() 构造,_id = 6,this = 0x8d6c40
B() 构造,_id = 7,this = 0x8d6c44
B() 构造,_id = 8,this = 0x8d6c48
B() 构造,_id = 9,this = 0x8d6c4c
数组首元素的地址 0x8d6c28
delete p2 之前,已经构造了 10 个
~B() 析构,_id = 0,this = 0x8d6c28
*** Error in `./mismatch_del': munmap_chunk(): invalid pointer: 0x00000000008d6c28 ***
======= Backtrace: =========
/lib64/libc.so.6(+0x7f474)[0x7fbc5ebe3474]
./mismatch_del[0x40083d]
/lib64/libc.so.6(__libc_start_main+0xf5)[0x7fbc5eb86555]
./mismatch_del[0x4006d9]
======= Memory map: ========
/tmp/dsh_cpp06.sh: line 157: 24551 Aborted ./mismatch_del
退出码 134
十行构造只换来一行析构,这一行还只是第一个元素,后面九个的析构函数一次都没有跑。构造函数里 _id = 1 到 9 这九个对象带着自己的编号诞生,没有一个走到析构,这九个编号在日志里凭空消失。地址那一栏也出了问题,glibc 收到的是 0x8d6c28,而当初 malloc 交出来的是带头部的那个更低的地址,归还没归到块的起点,于是 munmap_chunk(): invalid pointer 直接把进程结束掉,退出码 134。
valgrind 在这种场合给出的是更细的证据,它不但指出哪一次释放不合法,还把区块的大小一并报出来。
text
$ valgrind --leak-check=full ./mismatch_del
==24552== Invalid free() / delete / delete[] / realloc()
==24552== at 0x4C2B51D: operator delete(void*) (vg_replace_malloc.c:586)
==24552== by 0x40083C: main (in /home/cocatrice/cpp06/demo/mismatch_del)
==24552== Address 0x5aafc88 is 8 bytes inside a block of size 48 alloc'd
==24552== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==24552== by 0x4007BF: main (in /home/cocatrice/cpp06/demo/mismatch_del)
delete 那一行执行完毕
到这里没有别的析构打印了
==24552==
==24552== HEAP SUMMARY:
==24552== in use at exit: 48 bytes in 1 blocks
==24552== total heap usage: 2 allocs, 2 frees, 72,752 bytes allocated
==24552==
==24552== 48 bytes in 1 blocks are definitely lost in loss record 1 of 1
==24552== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==24552== by 0x4007BF: main (in /home/cocatrice/cpp06/demo/mismatch_del)
==24552==
==24552== LEAK SUMMARY:
==24552== definitely lost: 48 bytes in 1 blocks
==24552== indirectly lost: 0 bytes in 0 blocks
==24552== possibly lost: 0 bytes in 0 blocks
==24552== still reachable: 0 bytes in 0 blocks
==24552== suppressed: 0 bytes in 0 blocks
==24552==
==24552== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
48 这个数字本身就是一处证据,十个元素的 B 一共 40 字节,加上 8 字节头部正好 48,而释放用的地址落在这 48 字节往里数第 8 个字节的位置上,所以 valgrind 说 8 bytes inside a block of size 48。多出来的那个 8 正是头部,delete 不知道它的存在,越过它去交出地址,glibc 认不出这个位置属于哪一块。
方向调过来同样不成立,delete\[\] 收到一个不是数组的指针时会先往后退 8 字节去读头部,那 8 字节本来属于别的东西,读到的数字被当成元素个数用,后面就全乱了。
text
$ valgrind -q ./err_delarr
A() 构造,this = 0x5aafc80
delete[] 一个不是数组的指针
==25056== Invalid read of size 8
==25056== at 0x40081E: main (in /home/cocatrice/cpp06/demo/err_delarr)
==25056== Address 0x5aafc78 is 8 bytes before a block of size 1 alloc'd
==25056== at 0x4C2A593: operator new(unsigned long) (vg_replace_malloc.c:344)
==25056== by 0x4007F5: main (in /home/cocatrice/cpp06/demo/err_delarr)
==25056==
==25056== Invalid free() / delete / delete[] / realloc()
==25056== at 0x4C2BB8F: operator delete[](void*) (vg_replace_malloc.c:651)
==25056== by 0x40084C: main (in /home/cocatrice/cpp06/demo/err_delarr)
==25056== Address 0x5aafc78 is 8 bytes before a block of size 1 alloc'd
==25056== at 0x4C2A593: operator new(unsigned long) (vg_replace_malloc.c:344)
==25056== by 0x4007F5: main (in /home/cocatrice/cpp06/demo/err_delarr)
A() 构造,this = 0x5aafcd8
A() 构造,this = 0x5aafcd9
A() 构造,this = 0x5aafcda
delete 一个数组的指针
~A() 析构,this = 0x5aafcd8
==25056== Invalid free() / delete / delete[] / realloc()
==25056== at 0x4C2B51D: operator delete(void*) (vg_replace_malloc.c:586)
==25056== by 0x4008B0: main (in /home/cocatrice/cpp06/demo/err_delarr)
==25056== Address 0x5aafcd8 is 8 bytes inside a block of size 11 alloc'd
==25056== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==25056== by 0x400856: main (in /home/cocatrice/cpp06/demo/err_delarr)
==25056==
三段日志里藏着三种不同的错法,认出哪一条就能定位到对应的那一处代码。delete\[\] 收到单个对象的指针时,valgrind 先报 Invalid read of size 8,读的位置在区块起点往前 8 字节,正是它想读的头部;接着报 Invalid free,因为它把往前挪过的地址交了出去。delete 收到数组指针时只有一次析构,析构的是 0x5aafcd8 这个元素,而那一块的起点在它前面 8 字节,区块总大小 11 字节则是三个元素加头部。
数组与单个对象的写法必须严格对着来,new 配 delete,new\[\] 配 delete\[\],两边错开一次就是上面这些结果。

图 7 数组多出来的那个 8 字节
cookie 是编译器加上的,跟 glibc 的堆管理数据结构无关,这也是为什么重载 operator new\[\] 时看到的 size 已经含了这 8 字节,而跟踪 malloc 调用时看到的字节数正是这个值。zeronew.cpp 把 new int0 与 new A0 放进同一个程序,收到的 size 一个是 0 一个是 8,差别就落在这个 cookie 上。
九、定位 new
定位 new 的写法是在 new 后面带一对括号写上地址,比如 new (raw) A(7),它不申请任何空间,只在给定的地址上调用构造函数。用它需要包含 头文件,漏掉这一行会得到一条没有匹配函数的编译错误。
第一个例子在 malloc 出来的空间上构造对象,定位 new 的返回值与 malloc 的返回值是同一个地址。
cpp
// 文件:/home/cocatrice/cpp06/demo/placement.cpp(节选)
void* raw = malloc(sizeof(A));
printf("malloc 返回的空间 %p,一共 %d 字节\n", raw, (int)sizeof(A));
A* p = new (raw) A(7);
printf("定位 new 之后 p = %p,与 raw %s\n", (void*)p, p == raw ? "是同一个地址" : "不是同一个地址");
printf("p->_a = %d\n", p->_a);
p->~A();
free(raw);
text
$ ./placement
======== 在 malloc 出来的空间上构造 ========
malloc 返回的空间 0x12e6c20,一共 4 字节
A::A(7) 构造,this = 0x12e6c20
定位 new 之后 p = 0x12e6c20,与 raw 是同一个地址
p->_a = 7
A::~A() 析构,this = 0x12e6c20
======== 在栈上的字节数组里构造 ========
buf 的地址 0x7ffc12b53e70
A::A(9) 构造,this = 0x7ffc12b53e70
q 的地址 0x7ffc12b53e70,q->_a = 9
A::~A() 析构,this = 0x7ffc12b53e70
======== 分两步写出 new 表达式做的事 ========
第一步 operator new(4) 拿到 0x12e6c20
A::A(11) 构造,this = 0x12e6c20
第二步定位 new 构造出 r = 0x12e6c20
A::~A() 析构,this = 0x12e6c20
第三步手动调析构
第四步 operator delete 归还空间
构造函数收到的 this 与 malloc 的返回值一致,说明这块空间没有换过位置,只是里面的字节被构造函数改成了对象该有的样子。第二段把空间放在栈上的字节数组里,alignas(A) 让数组按 A 的对齐要求摆放,构造地址与数组地址相同,这段的好处是既没有走堆,也没有忘记析构,出作用域之后数组自己回收。第三段把 new A(11) 拆成四步,operator new 申请空间、定位 new 构造、手动调析构、operator delete 归还,四步合起来就是一句 new 与一句 delete 做的事,中间多出来的部分由编译器补齐。
定位 new 的另一处用途是把申请空间这件事从循环里拿出去。下面这份程序先申请一块能放五十万个 Node 的池子,之后每次要对象都在池子里挪一个位置出来,池子里的位置用完就整体复位,从头再来。
text
$ g++ -std=c++11 -Wall -O2 -o composite composite.cpp && ./composite
======== 一、在池子上构造四个对象 ========
池子一次申请 64 字节,起始地址 0x808c20
n1 0x808c20 n2 0x808c30 n3 0x808c40 n4 0x808c50
sizeof(Node) = 16,相邻两个对象相差 16 字节
值分别是 11 22 33 44
这一步之后 operator new 被调用了 0 次
======== 二、手动调用析构再复位 ========
四次析构调用完,operator delete 被调用了 0 次
======== 三、五十万次申请释放的耗时 ========
new 配 delete 500000 次: 8.4 毫秒,每次 16.7 纳秒,operator new 增加 500000 次
池子一次申请 8000000 字节,起始地址 0x7fe336af4010
池子配定位 new 500000 次: 1.1 毫秒,每次 2.2 纳秒,operator new 增加 0 次
======== 四、汇总 ========
operator new 一共被调用 500000 次,累计申请 8000000 字节
operator delete 一共被调用 500000 次
读到的值累计 249999500000,防止两段循环被优化掉
两段循环做的是同一件事,构造一个 Node 读一次值再销毁,第一段每次都用 new 配 delete,第二段在池子上用定位 new 加手动析构。第一段累计向 operator new 要了五十万次,一共 8000000 字节,每次 16.7 纳秒;第二段只在开头要过一次池子,之后五十万次申请与释放没有再多要一个字节,每次 2.2 纳秒。
同一份源码用 -O0 编译之后两个数字都变慢,对照关系没有变。
text
$ g++ -std=c++11 -Wall -O0 -o composite0 composite.cpp && ./composite0
======== 三、五十万次申请释放的耗时 ========
new 配 delete 500000 次: 10.5 毫秒,每次 20.9 纳秒,operator new 增加 500000 次
池子一次申请 8000000 字节,起始地址 0x7fd39bd54010
池子配定位 new 500000 次: 4.9 毫秒,每次 9.8 纳秒,operator new 增加 0 次
优化开关影响的是循环里的指令条数,池子那一侧省掉的是每次都得走一趟 malloc 与 free 的开销,这个开销在 -O2 下占了十六点七纳秒里的绝大部分。读到的值累计 249999500000 是故意留在程序里的,累加用的变量带 volatile,两段循环都不会被编译器删掉,这个数字等于 0 加到 499999 的和。
定位 new 不负责释放,对象的析构要自己调用,这一点在池子这段代码里看得很清楚:四个对象的值都读出来了,析构也是自己一行一行写的,池子最后只 free 一次。少写一行析构就是一次泄漏,跑掉的除了内存还有对象里的资源。

图 8 五十万次申请释放的耗时对照
池子这种写法有几处要注意:申请到的内存要按对象类型对齐,栈上的缓冲区用 alignas 说明,堆上的地址由 malloc 保证;池子的容量要算够,用完之后整块还给系统;每个位置上构造过的对象都要手动析构,析构的次数与构造的次数对上才算干净。
十、三种申请方式的对照
拿到内存的路子有三种,一层套着一层:写 new 表达式由编译器包办申请与构造,直接调 operator new 只做申请那一半,调 malloc 连头文件都换成 C 的那一套,失败时的约定也跟着换。三者的差别落在下面的表里,申请方式与释放方式一一对应。
| 对照项 | new 表达式 | 直接调 operator new | malloc |
|---|---|---|---|
| 拿到的结果 | 构造完成的对象指针,类型直接可用 | void* 原始内存 | void* 原始内存 |
| 大小怎么写 | 写类型与元素个数,编译器算 | 自己算好字节数 | 自己算好字节数 |
| 构造函数 | 编译器自动调用 | 不调用,要配定位 new | 不调用 |
| 析构函数 | delete 时自动调用 | 要手动调用 | 要手动调用 |
| 怎么归还 | delete 与 delete\[\] | operator delete | free |
| 失败怎么办 | 抛出 std::bad_alloc | 抛出 std::bad_alloc | 返回 NULL 并设 errno |
| 底层动作 | 调 operator new | libstdc++ 里调 malloc | 向系统要新页或切分空闲块 |
最后两行是这三种写法的分水岭,申请失败时 new 表达式与 operator new 都抛异常,malloc 只把 NULL 交回调用方。把地址空间压小到 400000 字节再跑同一份程序,三种写法的表现一次看全。
cpp
// 文件:/home/cocatrice/cpp06/demo/bad_alloc.cpp(节选)
try
{
int* p = new int[1000000000];
printf("申请成功,地址 %p\n", (void*)p);
delete[] p;
}
catch (const std::bad_alloc& e)
{
printf("抓到异常,what() 返回 %s\n", e.what());
}
int* q = (int*)malloc(sizeof(int) * 1000000000UL);
if (q == NULL)
{
printf("malloc 返回 NULL\n");
}
int* r = new (std::nothrow) int[1000000000];
if (r == NULL)
{
printf("new (std::nothrow) 返回 NULL\n");
}
int* s = new int[1000000000];
printf("这一行不会执行,s = %p\n", (void*)s);
delete[] s;
text
$ g++ -std=c++11 -Wall -O0 -o bad_alloc bad_alloc.cpp
$ ulimit -v 400000 && ./bad_alloc
======== new 失败:抛异常 ========
抓到异常,what() 返回 std::bad_alloc
======== malloc 失败:返回 NULL ========
malloc 返回 NULL
======== new 加 nothrow:返回 NULL ========
new (std::nothrow) 返回 NULL
======== 不接异常会怎样 ========
terminate called after throwing an instance of 'std::bad_alloc'
what(): std::bad_alloc
/tmp/dsh_cpp06.sh: line 74: 24435 Aborted ./bad_alloc
退出码 134
前三段各归各位,第四段把两边放在一起比。new int1000000000 要的是四十亿字节,给的地址空间只有四百兆,异常里带的类型就是 std::bad_alloc,what() 返回的那串字符与之相同。malloc 那边只看返回值,NULL 就是它给出的唯一信号,需要细节的话再去读 errno 与 strerror。new (std::nothrow) 是在 new 与括号之间写上 std::nothrow,用法上等于把抛异常换成返回空指针,写完仍然要判空。
第四段是这一节最该记住的一处,前一段的 try 只包住了第一条 new,第四条 new 落在 try 外面,异常没人接,运行时直接调 terminate,程序打出 terminate called after throwing an instance of 'std::bad_alloc' 之后以 134 结束。同样一句 new,写在 try 里面是一条输出,写在 try 外面是一次崩溃。
不写 try 也不想到处判空的做法是换一个 new_handler,用 set_new_handler 注册一个函数,申请失败时 operator new 会反复调用它,处理器里能腾出内存就再试一次,腾不出来可以自己抛异常或者结束进程。五节那段 _Znwm 的反汇编里 5f1a0 到 5f1b6 这几行就是这个循环,处理器为空时它才走 __cxa_throw 那条路。
三种方式各有各的失败约定,写代码的时候要跟上一层对齐:用 new 表达式的人靠异常,用 operator new 的人要么自己抛异常要么返回空,用 malloc 的人只看返回值。同一个工程里混着几种约定并不稀奇,关键是每一处都把失败路径写清楚,不要指望上一层替自己兜住。
十一、本节的四类错误
第一类是内存泄漏,申请了没有释放,程序跑再久也不会报错。这类错误在程序运行期间没有任何报错,退出码也是 0,valgrind 不带参数的时候连它都不报。
text
$ valgrind -q ./err_leak
p[99] = 99,函数要返回了,p 没有人接
退出码 0
$ valgrind -q --leak-check=full ./err_leak
p[99] = 99,函数要返回了,p 没有人接
==1554== 400 bytes in 1 blocks are definitely lost in loss record 1 of 1
==1554== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==1554== by 0x4004ED: main (in /home/cocatrice/cpp06/demo/err_leak)
两份输出只差 --leak-check=full 这一个参数。不带参数时 valgrind 一个字都不提泄漏,只把程序自己的输出原样放过去;带上参数之后它报出 400 bytes in 1 blocks are definitely lost,位置落在 err_leak 的 operator new\[\] 上,而 400 字节正是那个函数申请的 100 个 int。把 -q 当成开了检查的话,这一类错误会一直留在程序里。
第二类是重复释放,同一块内存交出去两次。不带 valgrind 时 glibc 自己会拦住它,代价是进程直接结束。
text
$ valgrind -q ./err_double
p = 0x5aafc80,*p = 3
第一次 delete 之后
==25053== Invalid free() / delete / delete[] / realloc()
==25053== at 0x4C2B51D: operator delete(void*) (vg_replace_malloc.c:586)
==25053== by 0x4006B6: main (in /home/cocatrice/cpp06/demo/err_double)
==25053== Address 0x5aafc80 is 0 bytes inside a block of size 4 free'd
==25053== at 0x4C2B51D: operator delete(void*) (vg_replace_malloc.c:586)
==25053== by 0x4006A0: main (in /home/cocatrice/cpp06/demo/err_double)
==25053== Block was alloc'd at
==25053== at 0x4C2A593: operator new(unsigned long) (vg_replace_malloc.c:344)
==25053== by 0x40066E: main (in /home/cocatrice/cpp06/demo/err_double)
第二次 delete 之后
$ ./err_double;不带 valgrind 直接跑
p = 0x2304c20,*p = 3
第一次 delete 之后
*** Error in `./err_double': double free or corruption (fasttop): 0x0000000002304c20 ***
退出码 134
valgrind 这份输出比前面几段都长,因为它把三次动作串了起来:第二次 delete 的位置、第一次释放的位置、最初申请的位置,三个地址都是 0x5aafc80,一块 4 字节的内存被释放两遍。不用 valgrind 的时候 glibc 报的是 double free or corruption (fasttop),括号里的 fasttop 说明它在空闲链表的头部发现了同一块内存,这一句后面同样跟着 134 的退出码。
第三类是越界读写,申请了五个 int 却动到第六个位置。这一类的特点是顺利跑完,不带任何检查时连报错都没有。
text
$ valgrind -q ./err_bounds
==25050== Invalid write of size 4
==25050== at 0x400653: main (in /home/cocatrice/cpp06/demo/err_bounds)
==25050== Address 0x5aafc94 is 0 bytes after a block of size 20 alloc'd
==25050== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==25050== by 0x40062E: main (in /home/cocatrice/cpp06/demo/err_bounds)
==25050==
==25050== Invalid read of size 4
==25050== at 0x400667: main (in /home/cocatrice/cpp06/demo/err_bounds)
==25050== Address 0x5aafc94 is 0 bytes after a block of size 20 alloc'd
==25050== at 0x4C2AC38: operator new[](unsigned long) (vg_replace_malloc.c:433)
==25050== by 0x40062E: main (in /home/cocatrice/cpp06/demo/err_bounds)
==25050==
写完了,p[5] = 5
$ ./err_bounds;不带 valgrind 直接跑
写完了,p[5] = 5
退出码 0
区块大小 20 字节与五个 int 对得上,出事的位置是 0 bytes after a block of size 20,也就是区块结束之后紧挨着的那一格。程序自己打印的 p5 = 5 看起来一切正常,不带 valgrind 时退出码也是 0,这一格内存可能属于堆里的管理数据,也可能属于别的对象,写坏之后要等很久才会以别的形式暴露出来。
第四类是释放方式不匹配,new 出来的交给 free,malloc 出来的交给 delete。这两种错法 valgrind 各给一条 Mismatched free 记录,两条记录里申请那一侧的调用栈也不一样。
text
$ valgrind -q ./err_mixswitch
==25055== Mismatched free() / delete / delete []
==25055== at 0x4C2B06D: free (vg_replace_malloc.c:540)
==25055== by 0x4006D4: main (in /home/cocatrice/cpp06/demo/err_mixswitch)
==25055== Address 0x5aafc80 is 0 bytes inside a block of size 4 alloc'd
==25055== at 0x4C2A593: operator new(unsigned long) (vg_replace_malloc.c:344)
==25055== by 0x4006BE: main (in /home/cocatrice/cpp06/demo/err_mixswitch)
new 出来的东西交给了 free
==25055== Mismatched free() / delete / delete []
==25055== at 0x4C2B51D: operator delete(void*) (vg_replace_malloc.c:586)
==25055== by 0x400702: main (in /home/cocatrice/cpp06/demo/err_mixswitch)
==25055== Address 0x5aafcd0 is 0 bytes inside a block of size 4 alloc'd
==25055== at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==25055== by 0x4006E8: main (in /home/cocatrice/cpp06/demo/err_mixswitch)
malloc 出来的东西交给了 delete
两条记录里的申请调用栈分别是 operator new 与 malloc,valgrind 靠这两行判断释放方式错在哪一侧。同一类里还有一处更隐蔽的搭配,就是八节里的数组错配,new\[\] 配 delete 与 new 配 delete\[\] 各自对应一条 Invalid free,那两种写法现在多了一个近亲,就是 free 与 delete 混着用。
四类错误放在一起看,泄漏是不报错的,越界也是不报错的,重复释放与错配会报错但要等到运行期,而它们全都是能靠工具提前发现的。跑一遍 valgrind 加一个 --leak-check=full 就能把这四类覆盖到,这也是数组与对象配对这件事值得单独练一次的原因。

图 9 四类错误各自留下的痕迹
实例代码
工程结构
所有实验放在同一个目录下,能编译能运行的一共 22 个源文件,另外 12 个是故意编不过的,专门放在 bad 子目录里,跟正常的源码分开。每个文件只做一件事,文件名就是它测的那件事,跑的时候一个程序对应一段输出,正文里引用的回显全部来自这批文件。
文件名的前缀按内容分了三类,new 与 delete 相关的放在 new_ 与 opnew_ 前缀下,运行期错误的放在 err_ 前缀下,剩下几个是兜底的边界实验。编译命令统一用 -std=c++11 加 -Wall -O2,只有测 -O0 耗时的那一次换成 -O0,其余命令都能照抄。
text
$ pwd && ls -1 *.cpp | sort
/home/cocatrice/cpp06/demo
address.cpp
bad_alloc.cpp
boundary.cpp
composite.cpp
err_bounds.cpp
err_delarr.cpp
err_double.cpp
err_leak.cpp
err_mixswitch.cpp
err_useafter.cpp
malloc_dirty.cpp
malloc_family.cpp
mapsd.cpp
mismatch_del.cpp
new_basic.cpp
new_big.cpp
new_expand.cpp
new_uninit.cpp
new_vs_malloc.cpp
opnew_count.cpp
placement.cpp
zeronew.cpp
$ ls -1 *.cpp | wc -l
22
$ ls -1 bad/*.cpp
bad/err_c01_malloc_cast.cpp
bad/err_c02_incomplete.cpp
bad/err_c03_rettype.cpp
bad/err_c04_firstparam.cpp
bad/err_c05_noctor.cpp
bad/err_c06_privdtor.cpp
bad/err_c07_placement_noinclude.cpp
bad/err_c08_delete_array.cpp
bad/err_c09_nullptr98.cpp
bad/err_c10_stack_delete.cpp
bad/err_c11_delete_object.cpp
bad/err_c12_delparam.cpp
$ ls -1 bad/*.cpp | wc -l
12
$ du -sh /home/cocatrice/cpp06
484K /home/cocatrice/cpp06
完整代码
下面四个源文件是这一篇的主体,其余文件围着它们做旁证。address.cpp 把各类名字的地址打出来,再去 /proc/self/maps 里查它们各自落在哪一段;new_basic.cpp 覆盖 new 与 delete 的四种写法、数组与初始化列表;placement.cpp 演示在指定地址上构造对象;composite.cpp 用一块池子加定位 new 跑五十万次申请释放,并与 new 配 delete 做对照。
cpp
// 文件:/home/cocatrice/cpp06/demo/address.cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int globalVar = 1;
static int staticGlobalVar = 1;
const char* g_pChar3 = "hello";
int g_arr[10];
void ShowRange(const char* tag, const void* p)
{
unsigned long addr = (unsigned long)p;
FILE* fp = fopen("/proc/self/maps", "r");
char line[512];
int hit = 0;
while (fp != NULL && fgets(line, sizeof(line), fp) != NULL)
{
unsigned long s = 0;
unsigned long e = 0;
char perm[8];
perm[0] = 0;
if (sscanf(line, "%lx-%lx %7s", &s, &e, perm) != 3)
continue;
if (addr >= s && addr < e)
{
char* nl = strchr(line, '\n');
if (nl != NULL)
*nl = 0;
printf("%-16s %14p %s\n", tag, p, line);
hit = 1;
break;
}
}
if (fp != NULL)
fclose(fp);
if (hit == 0)
printf("%-16s %14p (不在任何映射里)\n", tag, p);
}
void StackDown(int* outer)
{
int inner = 0;
printf("main 里的局部 %p\n", (void*)outer);
printf("被调函数里的局部 %p\n", (void*)&inner);
printf("两个地址相差 %ld 字节\n", (long)outer - (long)&inner);
}
void Test()
{
int localVar = 1;
static int staticVar = 1;
int num1[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
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);
int* ptr4 = (int*)malloc(sizeof(int) * 4);
printf("======== 各个名字的地址 ========\n");
printf("&globalVar %p\n", (void*)&globalVar);
printf("&staticGlobalVar %p\n", (void*)&staticGlobalVar);
printf("&g_pChar3 %p\n", (void*)&g_pChar3);
printf("&g_arr %p\n", (void*)&g_arr);
printf("&staticVar %p\n", (void*)&staticVar);
printf("&localVar %p\n", (void*)&localVar);
printf("&num1 %p\n", (void*)&num1);
printf("&char2 %p\n", (void*)&char2);
printf("&pChar3 %p\n", (void*)&pChar3);
printf("pChar3 指向的字面值 %p\n", (void*)pChar3);
printf("&ptr1 %p\n", (void*)&ptr1);
printf("ptr1 %p\n", (void*)ptr1);
printf("&Test %p\n", (void*)&Test);
printf("\n======== 谁落在哪个映射里 ========\n");
ShowRange("globalVar", &globalVar);
ShowRange("staticVar", &staticVar);
ShowRange("ptr1 指向的堆", ptr1);
ShowRange("ptr4 指向的堆", ptr4);
ShowRange("localVar", &localVar);
ShowRange("字符串字面值", pChar3);
ShowRange("函数 Test", (void*)&Test);
printf("\n======== 堆的两次申请 ========\n");
printf("ptr1 %p\n", (void*)ptr1);
printf("ptr2 %p\n", (void*)ptr2);
printf("ptr3 %p\n", (void*)ptr3);
printf("ptr4 %p\n", (void*)ptr4);
printf("\n======== 栈的两个局部变量 ========\n");
StackDown(&localVar);
free(ptr1);
free(ptr3);
free(ptr4);
}
int main()
{
Test();
return 0;
}
address.cpp 里的 ShowRange 每个名字查一次映射,读到哪一段就直接打印那一段,这样不必猜地址落在哪里。
cpp
// 文件:/home/cocatrice/cpp06/demo/new_basic.cpp
#include <stdio.h>
#include <stdlib.h>
class A
{
public:
A(int a1 = 0, int a2 = 0)
: _a1(a1), _a2(a2)
{
printf("A::A(%d, %d) 构造,this = %p\n", _a1, _a2, (void*)this);
}
A(const A& a)
: _a1(a._a1), _a2(a._a2)
{
printf("A::A(const A&) 拷贝构造,this = %p\n", (void*)this);
}
A& operator=(const A& a)
{
_a1 = a._a1;
_a2 = a._a2;
printf("A::operator= 赋值,this = %p\n", (void*)this);
return *this;
}
~A()
{
printf("A::~A() 析构,this = %p\n", (void*)this);
}
int _a1;
int _a2;
};
int main()
{
printf("======== 内置类型 ========\n");
int* p1 = new int;
int* p2 = new int(10);
int* p3 = new int[5];
int* p4 = new int[5]();
printf("new int -> %d(没有初值)\n", *p1);
printf("new int(10) -> %d\n", *p2);
printf("new int[5] -> %d %d %d %d %d(没有初值)\n", p3[0], p3[1], p3[2], p3[3], p3[4]);
printf("new int[5]() -> %d %d %d %d %d(值初始化)\n", p4[0], p4[1], p4[2], p4[3], p4[4]);
delete p1;
delete p2;
delete[] p3;
delete[] p4;
printf("\n======== 自定义类型,单个对象 ========\n");
A* pa = new A(1, 2);
printf("pa 的地址 %p\n", (void*)pa);
delete pa;
printf("\n======== 自定义类型,数组 ========\n");
A* parr = new A[3];
printf("parr 的地址 %p\n", (void*)parr);
delete[] parr;
printf("\n======== 自定义类型,数组加初始化列表 ========\n");
A aa1(11, 12);
A aa2(21, 22);
A aa3(31, 32);
A* parr2 = new A[3]{aa1, aa2, aa3};
printf("parr2 的地址 %p\n", (void*)parr2);
delete[] parr2;
printf("\n======== 数组里每个元素的地址 ========\n");
A* parr3 = new A[3];
for (int i = 0; i < 3; ++i)
printf("parr3[%d] 的地址 %p\n", i, (void*)(parr3 + i));
printf("sizeof(A) = %d\n", (int)sizeof(A));
delete[] parr3;
return 0;
}
new_basic.cpp 里的 class A 有四个函数会打印 this,构造函数、拷贝构造函数、赋值重载与析构函数,谁被调用了几次从日志里数就能知道。
cpp
// 文件:/home/cocatrice/cpp06/demo/placement.cpp
#include <stdio.h>
#include <stdlib.h>
#include <new>
class A
{
public:
A(int a)
: _a(a)
{
printf("A::A(%d) 构造,this = %p\n", _a, (void*)this);
}
~A()
{
printf("A::~A() 析构,this = %p\n", (void*)this);
}
int _a;
};
int main()
{
printf("======== 在 malloc 出来的空间上构造 ========\n");
void* raw = malloc(sizeof(A));
printf("malloc 返回的空间 %p,一共 %d 字节\n", raw, (int)sizeof(A));
A* p = new (raw) A(7);
printf("定位 new 之后 p = %p,与 raw %s\n", (void*)p, p == raw ? "是同一个地址" : "不是同一个地址");
printf("p->_a = %d\n", p->_a);
p->~A();
free(raw);
printf("\n======== 在栈上的字节数组里构造 ========\n");
{
alignas(A) char buf[sizeof(A)];
printf("buf 的地址 %p\n", (void*)buf);
A* q = new (buf) A(9);
printf("q 的地址 %p,q->_a = %d\n", (void*)q, q->_a);
q->~A();
}
printf("\n======== 分两步写出 new 表达式做的事 ========\n");
void* mem = operator new(sizeof(A));
printf("第一步 operator new(%d) 拿到 %p\n", (int)sizeof(A), mem);
A* r = new (mem) A(11);
printf("第二步定位 new 构造出 r = %p\n", (void*)r);
r->~A();
printf("第三步手动调析构\n");
operator delete(mem);
printf("第四步 operator delete 归还空间\n");
return 0;
}
placement.cpp 第三段刻意把一句 new A(11) 与一句 delete r 拆成四步,第几步做什么在打印里都能看到。
cpp
// 文件:/home/cocatrice/cpp06/demo/composite.cpp
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <new>
static int g_new_count = 0;
static unsigned long g_new_bytes = 0;
static int g_del_count = 0;
static volatile long g_sum = 0;
void* operator new(size_t size)
{
void* p = malloc(size);
++g_new_count;
g_new_bytes += size;
return p;
}
void operator delete(void* p)
{
if (p != NULL)
++g_del_count;
free(p);
}
double NowMs()
{
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
class Node
{
public:
Node()
: _value(0), _next(NULL)
{
}
explicit Node(int v)
: _value(v), _next(NULL)
{
}
~Node()
{
_next = NULL;
}
int Value() const
{
return _value;
}
private:
int _value;
Node* _next;
};
class NodePool
{
public:
NodePool(size_t count)
: _count(count), _used(0)
{
_buf = (char*)malloc(sizeof(Node) * count);
printf("池子一次申请 %lu 字节,起始地址 %p\n",
(unsigned long)(sizeof(Node) * count), (void*)_buf);
}
~NodePool()
{
free(_buf);
}
Node* Alloc(int v)
{
char* slot = _buf + _used * sizeof(Node);
++_used;
return new (slot) Node(v);
}
void Reset()
{
_used = 0;
}
private:
NodePool(const NodePool&);
NodePool& operator=(const NodePool&);
char* _buf;
size_t _count;
size_t _used;
};
int main()
{
printf("======== 一、在池子上构造四个对象 ========\n");
NodePool pool(4);
Node* n1 = pool.Alloc(11);
Node* n2 = pool.Alloc(22);
Node* n3 = pool.Alloc(33);
Node* n4 = pool.Alloc(44);
printf("n1 %p n2 %p n3 %p n4 %p\n", (void*)n1, (void*)n2, (void*)n3, (void*)n4);
printf("sizeof(Node) = %lu,相邻两个对象相差 %ld 字节\n",
(unsigned long)sizeof(Node), (long)((char*)n2 - (char*)n1));
printf("值分别是 %d %d %d %d\n", n1->Value(), n2->Value(), n3->Value(), n4->Value());
printf("这一步之后 operator new 被调用了 %d 次\n", g_new_count);
printf("\n======== 二、手动调用析构再复位 ========\n");
n1->~Node();
n2->~Node();
n3->~Node();
n4->~Node();
printf("四次析构调用完,operator delete 被调用了 %d 次\n", g_del_count);
pool.Reset();
printf("\n======== 三、五十万次申请释放的耗时 ========\n");
const int kRound = 500000;
int before1 = g_new_count;
double t1 = NowMs();
for (int i = 0; i < kRound; ++i)
{
Node* p = new Node(i);
g_sum += p->Value();
delete p;
}
double t2 = NowMs();
printf("new 配 delete %d 次:%8.1f 毫秒,每次 %6.1f 纳秒,operator new 增加 %d 次\n",
kRound, t2 - t1, (t2 - t1) * 1000000.0 / kRound, g_new_count - before1);
NodePool pool2(kRound);
int before2 = g_new_count;
double t3 = NowMs();
for (int i = 0; i < kRound; ++i)
{
Node* p = pool2.Alloc(i);
g_sum += p->Value();
p->~Node();
pool2.Reset();
}
double t4 = NowMs();
printf("池子配定位 new %d 次:%8.1f 毫秒,每次 %6.1f 纳秒,operator new 增加 %d 次\n",
kRound, t4 - t3, (t4 - t3) * 1000000.0 / kRound, g_new_count - before2);
printf("\n======== 四、汇总 ========\n");
printf("operator new 一共被调用 %d 次,累计申请 %lu 字节\n", g_new_count, g_new_bytes);
printf("operator delete 一共被调用 %d 次\n", g_del_count);
printf("读到的值累计 %ld,防止两段循环被优化掉\n", g_sum);
return 0;
}
composite.cpp 里的 NodePool 只管一块内存与一个位置下标,Alloc 在当前位置上构造对象,Reset 把下标清零,析构函数里只 free 一次池子。main 最后打印的累计值带 volatile,两段循环都不会被优化掉。
四个文件里只有 composite.cpp 带业务形状,前面三个都是把机制拆开看的小程序,按顺序读下来会顺一些。如果只打算挑一个文件跑,那就选 composite.cpp,它把池子、定位 new、手动析构与耗时统计连成了一条完整的链。
正常情况
先把 22 个文件整体编一遍,再用 -Wall 打开全部警告,没有一个文件报警告,也没有一个编不过。
text
$ for f in *.cpp; do g++ -std=c++11 -Wall -O2 -o ${f%.cpp} $f 2>/dev/null && echo "编过 ${f%.cpp}" || echo "编不过 ${f%.cpp}"; done
编过 address
编过 bad_alloc
编过 boundary
编过 composite
编过 err_bounds
编过 err_delarr
编过 err_double
编过 err_leak
编过 err_mixswitch
编过 err_useafter
编过 malloc_dirty
编过 malloc_family
编过 mapsd
编过 mismatch_del
编过 new_basic
编过 new_big
编过 new_expand
编过 new_uninit
编过 new_vs_malloc
编过 opnew_count
编过 placement
编过 zeronew
挨个运行一遍,每个程序都以 0 结束,下面挑几个把开头几行贴出来。
text
$ ./address
退出码 0
======== 各个名字的地址 ========
&globalVar 0x60209c
&staticGlobalVar 0x602098
......
$ ./malloc_family
退出码 0
======== malloc 与 calloc 的初值 ========
malloc 出来的 地址 0x22bfc20 : 0 0 0 0
calloc 出来的 地址 0x22bfc40 : 0 0 0 0
......
$ ./new_basic
退出码 0
======== 内置类型 ========
new int -> 0(没有初值)
new int(10) -> 10
......
$ ./opnew_count
退出码 0
======== new int(10) ========
[operator new] 第 1 次调用,size = 4,malloc 返回 0x1b11c20
*p1 = 10
......
$ ./boundary
退出码 0
======== new int[0] ========
返回 0xe86c20,不是空指针
......
正常释放的程序在 valgrind 下一句话都不说。把 new_basic 放在 valgrind 里跑,输出里只有程序自己的析构打印,没有 error 也没有 leak 的段落。
text
$ valgrind -q --leak-check=full ./new_basic 2>&1 | tail -8
parr3[2] 的地址 0x5aaff08
sizeof(A) = 8
A::~A() 析构,this = 0x5aaff08
A::~A() 析构,this = 0x5aaff00
A::~A() 析构,this = 0x5aafef8
A::~A() 析构,this = 0x1fff0003f0
A::~A() 析构,this = 0x1fff000400
A::~A() 析构,this = 0x1fff000410
收尾这三行析构的地址带 0x1fff 开头,是 valgrind 自己的内存分配器给出的地址,跟平时看到的 0x1598 开头不一样,这属于工具换了底层分配器之后的正常表现。日志里三行逆序析构说明 delete\[\] 在跟着对象个数走,一个不少。
编译期把警告全部打开这一步也要留在流程里,-Wall 能挡下的东西比想象中多,比如 delete 写在一个数组名上只给一条警告,程序照样能跑下去。
一次干净运行的作用是给后面留一个对照,同一个程序放在 valgrind 下也应该没有任何输出,有输出的那一次才是要查的那一次。
常见报错
把编不过的程序放在 bad 子目录里是刻意安排的,正常目录里只要有一个文件编不过,整个流程都不好跑。下面每一组的命令行原样贴出来,报错行也原样贴,行号与列号都是编译器的真实输出。
编译期的报错分四组,第一组是强转与类型,malloc 的返回值赋给 int* 在 C++ 里必须写强转,漏掉就是一条错误。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c01_malloc_cast.cpp: In function 'int main()':
err_c01_malloc_cast.cpp:6:33: error: invalid conversion from 'void*' to 'int*' [-fpermissive]
int* p = malloc(sizeof(int) * 4);
^
第二组是类型没写全,只写一行前置声明就 new 它。只写一行前置声明就 new 它,编译器不知道这个类型有多大。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c02_incomplete.cpp: In function 'int main()':
err_c02_incomplete.cpp:6:13: error: invalid use of incomplete type 'class B'
B* p = new B;
^
err_c02_incomplete.cpp:2:7: error: forward declaration of 'class B'
class B;
^
第三组是重载 operator new 与 operator delete 时签名写错,返回值、第一个参数、以及 delete 的第一个参数各有一条对应的错误。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c03_rettype.cpp:5:30: error: 'operator new' must return type 'void*'
void operator new(size_t size)
^
err_c03_rettype.cpp: In function 'void* operator new(size_t)':
err_c03_rettype.cpp:8:1: warning: no return statement in function returning non-void [-Wreturn-type]
}
^
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c04_firstparam.cpp:4:37: error: 'operator new' takes type 'size_t' ('long unsigned int') as first parameter [-fpermissive]
void* operator new(unsigned int size)
^
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c12_delparam.cpp:5:28: error: 'operator delete' takes type 'void*' as first parameter
void operator delete(int* p)
^
err_c03 那条报告后面还跟着一条 warning,原因是返回值类型写成 void 之后函数体里没有 return,编译器顺着往下又报了一处。
第四组是构造与析构上的限制,两处都跟权限与签名有关。类里写了带参构造函数之后默认构造就没了,new A 找不到能用的构造函数;析构函数写成私有之后,外部的 delete 进不去。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c05_noctor.cpp: In function 'int main()':
err_c05_noctor.cpp:16:13: error: no matching function for call to 'A::A()'
A* p = new A;
^
err_c05_noctor.cpp:16:13: note: candidates are:
err_c05_noctor.cpp:5:2: note: A::A(int)
A(int a)
^
err_c05_noctor.cpp:5:2: note: candidate expects 1 argument, 0 provided
err_c05_noctor.cpp:2:7: note: constexpr A::A(const A&)
class A
^
err_c05_noctor.cpp:2:7: note: candidate expects 1 argument, 0 provided
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c06_privdtor.cpp: In function 'int main()':
err_c06_privdtor.cpp:8:2: error: 'A::~A()' is private
~A() {}
^
err_c06_privdtor.cpp:14:9: error: within this context
delete p;
^
第五组与数组、空指针有关,报出来的东西一半是错误一半是警告。delete 写在一个数组名上只给一条警告,程序照样跑,但只析构一个元素;delete 交给一个对象而不是指针直接是错误;nullptr 在 -std=c++98 下不存在,换成 -std=c++11 同一份源码编得过。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c08_delete_array.cpp: In function 'int main()':
err_c08_delete_array.cpp:5:9: warning: deleting array 'a' [enabled by default]
delete a;
^
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c11_delete_object.cpp: In function 'int main()':
err_c11_delete_object.cpp:11:9: error: type 'class A' argument given to 'delete', expected pointer
delete a;
^
$ g++ -std=c++98 -Wall err_c09_nullptr98.cpp -o /dev/null
err_c09_nullptr98.cpp:4:2: warning: identifier 'nullptr' is a keyword in C++11 [-Wc++0x-compat]
int* p = nullptr;
^
err_c09_nullptr98.cpp: In function 'int main()':
err_c09_nullptr98.cpp:4:11: error: 'nullptr' was not declared in this scope
int* p = nullptr;
^
$ g++ -std=c++11 -Wall err_c09_nullptr98.cpp -o /dev/null
$ 上面这条编得过,c++11 认得 nullptr
定位 new 那一组只有一条错误,漏掉 头文件之后,编译器不知道带两个参数的 operator new 是什么。
text
$ g++ -std=c++11 -Wall $f.cpp -o /dev/null
err_c07_placement_noinclude.cpp: In function 'int main()':
err_c07_placement_noinclude.cpp:15:22: error: no matching function for call to 'operator new(sizetype, void*&)'
A* p = new (buf) A(1);
^
err_c07_placement_noinclude.cpp:15:22: note: candidate is:
<built-in>:0:0: note: void* operator new(long unsigned int)
<built-in>:0:0: note: candidate expects 1 argument, 2 provided
还有一处值得单独提出来,delete 一个栈上变量的地址能编过,报错要等到运行时才来,glibc 会在那一刻认出这个地址不属于堆。
边界情况
零长度的数组在两个编译器上都不会返回空指针,new int0 拿到的是一块能用的地址,new A0 拿到的地址比 operator new\[\] 返回的地址高 8 字节,多出来的那 8 字节就是 cookie。
text
$ g++ -std=c++11 -Wall -O0 -o boundary boundary.cpp
$ ./boundary
======== new int[0] ========
返回 0xbd1c20,不是空指针
======== new A[0] ========
返回 0xbd1c28
======== new char[0] 传给 operator new 的字节数 ========
地址 0xbd1c20
======== delete NULL 与 delete[] NULL ========
两个都执行完了,没有崩
======== free(NULL) 与 realloc(p, 0) ========
free(NULL) 执行完了
realloc(p, 0) 返回 (nil)
======== malloc(0) ========
malloc(0) 返回 0xbd1c20
重载了 operator new\[\] 之后能看清楚这两个 0 长度的数组各自要了多少字节。new int0 要的是 0 字节,glibc 拿到 0 也会给一个最小的块,new A0 要的是 8 字节,正好是一个 cookie 的宽度,一个元素都没占。
text
$ g++ -std=c++11 -Wall -O0 -o zeronew zeronew.cpp
$ ./zeronew
======== new int[0] ========
[operator new[]] 收到的 size = 0,返回 0x11bcc20
拿到的指针 0x11bcc20
======== new A[0] ========
[operator new[]] 收到的 size = 8,返回 0x11bcc20
拿到的指针 0x11bcc28
======== new char[1] 与 new char[4] 作对照 ========
[operator new[]] 收到的 size = 1,返回 0x11bcc20
[operator new[]] 收到的 size = 4,返回 0x11bcc20
======== new int(不给初值)与 new int(3) ========
[operator new] 收到的 size = 4,返回 0x11bcc20
[operator new] 收到的 size = 4,返回 0x11bcc20
*n2 = 3
new char1 与 new char4 这两行的地址相同,是因为前一次的块被释放之后 glibc 又把同一块交给了下一次申请,跟 malloc 那一节的现象一致。new int 与 new int(3) 的 size 都是 4,两者的区别只在有没有写那次初始化,申请到的字节数完全一样。
零长度这些写法在工程里并不常见,却在容器类与自定义分配器里出现得很多,判断空指针与判断长度为 0 是两件不同的事。把这些边界值放在一份程序里跑一遍,往后再遇到返回 NULL 或者抛异常的接口,手上就有一份现成的对照。
实测数据
第一张表是 address.cpp 里各类名字的实际地址与它们所在的段,段的范围来自 /proc/self/maps。
| 名字 | 地址 | 落在哪一段 | nm 类型 |
|---|---|---|---|
| int globalVar | 0x602088 | 00602000 起的可执行文件可读写段 | D |
| static int staticGlobalVar | 0x60208c | 同上 | d |
| char* g_pChar3 | 0x602090 | 同上 | D |
| Test 里的 static int staticVar | 0x602098 | 同上 | d |
| int g_arr100 | 0x6020c0 | 同一段里未初始化的部分 | B |
| 字符串字面值 | 0x400e3c | 00400000 起的只读段 | 无 |
| 函数 Test | 0x4009f5 | 同一只读段 | T |
| int localVar | 0x7ffc6d21c6d4 | stack 7ffc6d1fd000 到 7ffc6d21e000,两个局部相差 104 字节 | 无 |
| int* ptr1 | 0x22b1c20 | heap 022a0000 到 022d2000 | 无 |
size 给出的三段大小是 text 4088、data 700、bss 72,readelf 给出的段偏移是 .text 0x400740、.rodata 0x400d70、.data 0x602080、.bss 0x6020a0,两张表对得上:可读写的东西落在 0x602080 之后,只读的东西落在 0x400740 之后。
第二张表是重载 operator new 与 operator new\[\] 时收到的字节数,以及构造函数被调用的次数。
| 写法 | operator new 收到的 size | 构造与析构 |
|---|---|---|
| new int(10) | 4 | 无 |
| new int10 | 40 | 无 |
| new A(1, 2) | 8 | 构造 1 次,析构 1 次 |
| new A3 | 32 | 构造 3 次,delete\[\] 逆序析构 3 次 |
| new A3{aa1, aa2, aa3} | 32 | 拷贝构造 3 次 |
| new int0 | 0 | 无 |
| new A0 | 8 | 无,8 字节全是 cookie |
这一轮 operator new 一共被调用 4 次共 84 字节,operator delete 被调用 4 次,4 加 40 加 8 加 32 正好是 84。
第三张表是申请失败与各种零长度、空指针的返回。
| 表达式 | 结果 |
|---|---|
| malloc((size_t)-1) | 返回 (nil),errno = 12 |
| 压小地址空间后 new 一块大内存 | 抛 std::bad_alloc,what() 是 std::bad_alloc |
| 同样条件下 new (std::nothrow) | 返回空指针,不抛异常 |
| 同样条件下 malloc | 返回 NULL |
| malloc(0) | 返回 0x23aec40 |
| new int0 | 返回 0xbd1c20 |
| new A0 | 返回 0xbd1c28 |
| delete NULL 与 delete\[\] NULL | 都执行完了,没有崩 |
| free(NULL) | 执行完了 |
| realloc(p, 0) | 返回 (nil) |
第四张表是五十万次申请释放的耗时,同一个程序分别用 -O2 与 -O0 编一遍,池子只申请一次共 8000000 字节。
| 编译档 | 写法 | 总耗时 | 每次耗时 | operator new 增量 |
|---|---|---|---|---|
| -O2 | new 配 delete | 8.4 毫秒 | 16.7 纳秒 | 500000 次 |
| -O2 | 池子配定位 new | 1.1 毫秒 | 2.2 纳秒 | 0 次 |
| -O0 | new 配 delete | 10.5 毫秒 | 20.9 纳秒 | 500000 次 |
| -O0 | 池子配定位 new | 4.9 毫秒 | 9.8 纳秒 | 0 次 |
实例
这一篇的七个实测项都能在同一个目录里跑出来,每一项对应一个程序,读数如下。
| 实测项 | 程序 | 关键读数 |
|---|---|---|
| 打印各类地址对照内存分布 | address.cpp | globalVar 0x602088、localVar 0x7ffc6d21c6d4、ptr1 0x22b1c20 |
| objdump -d 看 new 展开 | new_expand.cpp | callq operator new(unsigned long)@plt 后面跟 callq A::A(int),删除侧先 test %rbx,%rbx |
| new\[\] 配 delete 的 valgrind 输出 | mismatch_del.cpp | munmap_chunk(): invalid pointer 与 8 bytes inside a block of size 48,ERROR SUMMARY: 2 errors |
| 定位 new 在 malloc 空间上构造并手动析构 | placement.cpp | 定位 new 前后指针相同、p->_a = 7、手动析构一次 |
| 重载 operator new 统计次数与字节数 | opnew_count.cpp | operator new 4 次共 84 字节,operator delete 4 次 |
| 压小地址空间捕捉 bad_alloc | bad_alloc.cpp | 异常 what() 是 std::bad_alloc,不接异常时退出码 134 |
| /proc/PID/maps 对照段划分 | mapsd.cpp | VmPeak 15280 kB、g_big 0x6010a0 属 00602000 到 00702000、堆地址 0x7fcbe69a6010 |
把这批程序当成一套自检流程的模板也行:新写一段跟内存打交道的代码,先按 address.cpp 的办法把地址打出来,确认它落在该落的段里;再按 opnew_count.cpp 的办法把申请次数与字节数记下来,看看有没有多申请;最后按 err_bounds.cpp 到 err_delarr.cpp 这六个程序的跑法,把同一段代码放在 valgrind 里过一遍,越界、重复释放、释放方式不匹配、泄漏四类问题各有各的报法,看到哪一类就回去改哪一处。
指针从哪来的在文本里分得清,就不用靠工具去猜。new 出来的交给 delete,new\[\] 出来的交给 delete\[\],malloc 出来的交给 free,三对关系一旦在代码里混了,运行时那一刻就会有人替你指出来,glibc 报的错误行里连块大小与地址都带着。
三段自检的顺序可以固定下来,先看地址落在哪一段,再看申请与释放的配对数,最后看工具报什么。这套办法对自定义分配器同样适用,池子越复杂越需要把申请次数与析构次数分别记账,两边对不上就说明有对象漏了释放。
踩坑点
- delete 与 delete\[\] 要跟 new 与 new\[\] 分别配对,写反了 glibc 会在释放那一刻认出来
- new B10 配 delete p 只析构第一个元素,剩下 9 个的析构函数一次都不会被调用
- malloc 的返回值赋给指针在 C++ 里必须强转,漏掉强转就是一条编译错误
- new 出来的内置类型没有初值,new int 读到的是那块内存上一任使用者留下的内容
- 类里写了带参数的构造函数,默认构造就没了,new A 会找不到能用的构造函数
- 析构函数写成私有,外部的 delete 进不去,编译期就报一条 private
- 重载 operator new 只能返回 void*,第一个参数必须是 size_t
- 定位 new 需要包含 new 头文件,少写之后报找不到匹配的 operator new
- new int0 与 new A0 都不返回空指针,用判断空指针的办法挡不住零长度数组
- delete NULL 与 delete\[\] NULL 是安全的,free(NULL) 也是安全的
- realloc(p, 0) 的行为由实现决定,把返回值当空指针处理更稳
- 自定义 operator new 接管之后,malloc 返回 NULL 不会自动变成异常,要自己抛
- 数组版的 cookie 由编译器加上,重载 operator new\[\] 看到的 size 已经把它算进去
- 在栈上变量的地址上 delete 能编过,要等运行时 glibc 才把它报出来
- 内存泄漏不带工具看不出来,valgrind 默认也不报,要加 --leak-check=full
本篇总结(模拟面试问题)
问:new 与 malloc 的差别在哪几处?
核心要点:返回值类型、大小怎么算、失败怎么办、会不会调构造函数这四处。malloc 返回 void* 要强转,只认字节数,失败返回 NULL 并留下 errno,不碰构造函数;new 返回具体类型,大小按元素个数算,失败抛 std::bad_alloc,先调 operator new 再调构造函数。
问:一句 new A(7) 展开成几步?
核心要点:先调 operator new(sizeof(A)) 拿到一块裸内存,再在这块内存上跑 A::A(7),两步都成功才把指针交出来。汇编里就是 callq operator new(unsigned long)@plt 后面跟一句 callq A::A(int)。
问:delete p 展开成几步?
核心要点:先判断指针是不是空,不是空就先调 A::~A(),再调 operator delete(void*) 把内存还掉。汇编里在调析构之前有一条 test %rbx,%rbx。
问:new\[\] 与 delete\[\] 为什么必须配对?
核心要点:编译器在数组前面多放了 8 字节 cookie,里面是元素个数。delete\[\] 先往后退 8 字节把它读回来,按这个数字依次析构;delete 读不到它,只析构第一个元素,取地址给 free 的时候也对不上。
问:operator new 与 new 表达式是什么关系?
核心要点:operator new 只负责要内存,跟 malloc 在同一层;new 表达式负责要内存加构造。前者可以重载,后者不能重载。
问:申请不到内存的时候会怎么样?
核心要点:默认抛 std::bad_alloc,new (std::nothrow) 改成返回空指针。libstdc++ 拿到空指针之后先取 new_handler,调一次再重试 malloc,还是失败才构造异常对象抛出去。
问:定位 new 用在什么场景?
核心要点:在一块已经到手的内存上构造对象,内存从哪来它不管。池子加定位 new 的写法把一次申请摊成很多次构造,五十万次实验里每次耗时从 16.7 纳秒降到 2.2 纳秒。
问:内存出错的时候该看什么?
核心要点:越界读写、重复释放、释放方式不匹配这三类 valgrind 都能指出地址与块大小,泄漏要加 --leak-check=full 才报。不带工具时 glibc 只在堆结构被破坏的那一刻报一句。
问:这一节的四类错误分别留下什么痕迹?
核心要点:泄漏是 LEAK SUMMARY,重复释放是 double free or corruption,越界读写是 Invalid write of size 4,释放方式不匹配是 Mismatched free() / delete / delete \[\]。
参考
- new 表达式的完整过程与开销:https://en.cppreference.com/w/cpp/language/new
- delete 表达式的完整过程:https://en.cppreference.com/w/cpp/language/delete
- operator new 的各个重载:https://en.cppreference.com/w/cpp/memory/new/operator_new
- operator delete 的各个重载:https://en.cppreference.com/w/cpp/memory/new/operator_delete
- nothrow 版本的声明与语义:https://en.cppreference.com/w/cpp/memory/new/nothrow
- std::bad_alloc 的类型说明:https://en.cppreference.com/w/cpp/memory/new/bad_alloc
- std::bad_alloc::what 的返回值:https://en.cppreference.com/w/cpp/memory/new/bad_alloc/what
- set_new_handler 与 new_handler 的约定:https://en.cppreference.com/w/cpp/memory/new/set_new_handler
- malloc 的返回值、errno 与零长度行为:https://en.cppreference.com/w/cpp/memory/c/malloc
- calloc 的零初始化:https://en.cppreference.com/w/cpp/memory/c/calloc
- realloc 的扩容与搬移规则:https://en.cppreference.com/w/cpp/memory/c/realloc
- free 对空指针的行为:https://en.cppreference.com/w/cpp/memory/c/free
- 对象生命周期的起点与终点:https://en.cppreference.com/w/cpp/language/lifetime
- 存储期与 static 与自动变量的区别:https://en.cppreference.com/w/cpp/language/storage_duration
- 对象、数组与子对象的概念:https://en.cppreference.com/w/cpp/language/objects
- 析构函数的调用时机:https://en.cppreference.com/w/cpp/language/destructor
- alignas 的写法:https://en.cppreference.com/w/cpp/language/alignas
- valgrind 内存检查手册:https://valgrind.org/docs/manual/mc-manual.html
- malloc(3) 的手册页:https://man7.org/linux/man-pages/man3/malloc.3.html