写 C++ 有个绕不开的话题,那就是内存。很多刚入门的朋友觉得 C++ 难,难在哪?有一半的"劝退感"其实都来自内存管理:你要记得申请、记得释放、记得配对,一个不小心就内存泄漏,再多写几行就段错误。相比之下,Java 有垃圾回收,Python 有引用计数,你基本不用操心内存安全问题。而 C++ 选择把这份"自由"交还给你——交给你,同时也把这副"责任"压在你肩上。这就是 C++ 的一大魅力,也是它绕不过去的一道坎。
这篇文章我们就把 C++ 的内存管理彻底讲透。从程序跑起来之后内存到底分成哪几块讲起,再到 new/delete 和 malloc/free 的本质区别、operator new 底层做了什么、new[]/delete[] 里的那个天坑、定位 new 到底怎么用,最后落到内存泄漏的检测与防范。你放心,整个过程我们不堆概念,每讲一个点就用一小段能直接编译的代码验证给你看。建议你手边开着编辑器,跟着敲一敲,比光看强得多。
不过在深入之前,我们先得打好两块地基。第一块是指针。我之前在讲数组的时候提过,指针就是"存放地址的变量",它本身可能在栈上,但它指向的那块内存可能在天上地下任意一个位置。指针、地址、解引用,这些概念不明白,后面的内存讲得再细也是空中楼阁。如果你对指针还有点含糊,建议先回头再看看指针那篇。第二块地基是构造函数和析构函数。构造函数是对象创建那一刻自动执行的初始化函数,析构函数是对象生命结束那一刻自动执行"善后"的函数。这俩是内存管理和 C++ 里最容易纠缠在一起的概念,因为 new/delete 和 malloc/free 最本质的分水岭就在它们身上。我们先放着,等到它们真正登场的时候再说。
我还要先给你透个底:把内存这章学完,你收获的其实是一种"定位思维"。以后每次遇到崩溃、卡顿、内存越涨越高的 bug,你的第一反应不该是"哪里出错了",而该是"这块内存属于哪个区、是谁申请的、该由谁、用哪种方式偿还"。这个思维一旦建立,后面很多疑难杂症都会变得有迹可循。这篇文章就是帮你把这套"案件侦查"的地图铺开。
C/C++ 程序内存区域划分
一个 C/C++ 程序编译链接成可执行文件、跑起来的瞬间,操作系统会为这个进程划出一块连续的虚拟地址空间,并按用途把它切分成几个区域。理解这几个区域,你就掌握了"每个变量到底住在哪里",这对后面理解生命周期、理解误用后果至关重要。
先说清楚一个词:虚拟地址空间。现代操作系统都会给每个进程分配一套"看起来连续、实际上由内存管理单元(MMU)和页表帮你映射"的地址。你写的 &变量 拿到的地址,是进程视角的"虚拟地址",并不是物理内存里的真实地址。操作系统负责在这套虚拟地址和真实物理内存之间做翻译,还要配合页换出(换到磁盘)等技术,让"进程以为内存无限大"。这一层你现在不需要深究,但要知道"地址"这俩字在 C++ 里默认指进程的虚拟地址。后面你观察到的所有内存布局,都是在这个虚拟空间里的。
大致可以分成下面几块(不同操作系统细节略有差异,但思想一致):
- 栈(Stack):装非静态局部变量、函数参数、返回值这些东西。它的特点是编译期就确定大小、自动分配自动释放、非常快。还有两个你必须记住的性质:栈从高地址向低地址向下增长,以及栈空间通常有限(Windows 默认约 1MB,Linux 用
ulimit -s查看,常见 8MB),所以不能往栈上塞超大数组,多了会爆栈(Stack Overflow)。栈为什么向下增长?这只是 CPU/ABI(应用二进制接口,规定了函数调用、参数传递、栈帧布局的规则)约定俗成的方向——从高地址往低地址,正好让栈"顶着"内存高端的边界,和向上长的堆在中间错开,互不打架。 - 堆(Heap):程序运行时用
malloc/new动态申请的内存都来自这里。堆从低地址向高地址向上增长,空间大很多,但申请和释放都要你做主。注意堆的地址通常比栈小,因为栈在高地址往低生长,二者像两块相亲相爱的积木,从两端往中间长。堆空间大的原因是它能跨很多页、还能借住虚拟内存;但"大"不等于"无限",申请太多照样失败。 - 数据段(静态存储区 / 全局区):存放全局变量和静态变量(包括函数里带
static的局部变量),程序启动时分配,程序结束才释放。细分下去,已初始化的全局/静态变量放在.data(已初始化数据区),未初始化(或初始化为 0)的放在.bss(未初始化数据区)——.bss在可执行文件里不占磁盘空间,加载时才在内存里开出一块清零的区。 - 内存映射段(MMAP):用于高效的 I/O 映射,加载共享动态库、做进程间通信(比如
mmap系统调用)都用到它。初学阶段你只需要知道有这回事,等学到操作系统那一章再深挖。 - 代码段(常量化):存放可执行的机器指令,以及只读常量(比如字符串字面量)。这一块通常是只读的,你往里面写字节会直接触发段错误——这是编译器和 CPU 在物理层面用页权限帮你兜底。
把这张"户口本"画成一张图,你会更直观(下面用 text 画,地址低在下面、高在上面):
(高地址) 0x7fff ffffffff 附近
+------------------------------+ ← 栈顶
| 栈 | 局部变量/参数/返回值,向低地址增长 ↓
| ↓ ↓ ↓ | 空间有限,爆栈 = Stack Overflow
+------------------------------+ ← 栈底
| …空区… |
+------------------------------+ ← 内存映射段 MMAP(共享库、文件映射)
| ↑ ↑ ↑ 堆 | malloc / new 动态内存,向高地址增长 ↑
+------------------------------+ ← 堆底(program break,程序断点)
| .bss 未初始化数据 |
+------------------------------+
| .data 已初始化数据 |
+------------------------------+
| 代码段 text + 只读常量 | 机器指令 + .rodata(字符串字面量等)
+------------------------------+ (低地址) 0x400000 附近为什么我要花笔墨画这张图、讲 MMU?因为**"变量在哪"和"它指向的内存在哪"是两件完全不同的事**。指针变量本身在栈上,但它手里攥着的地址可以指到堆、指到数据段、甚至指到代码段。下面这段可编译代码就能用 printf 打印出各类东西的地址,让你亲眼看到它们的"领地"分布——注意栈上的 local 地址通常会明显大于堆上的 heap 地址,而数据段和代码段的地址又比堆小:
#include <iostream>
using namespace std;
int g = 0; // 已初始化全局变量 → 数据段 .data
static int s = 0; // 静态全局变量 → 数据段
int main()
{
int local = 0; // 非静态局部变量 → 栈
static int ls = 0; // 静态局部变量 → 数据段
int* heap = new int(0); // 新申请 → 堆
const char* lit = "abcd"; // 指向代码段 .rodata 里的字面量
cout << "栈上的 local : " << (void*)&local << endl;
cout << "数据段的 g : " << (void*)&g << endl;
cout << "数据段的 s : " << (void*)&s << endl;
cout << "数据段的 ls : " << (void*)&ls << endl;
cout << "堆上的 heap : " << (void*)heap << endl;
cout << "代码段的 lit : " << (const void*)lit << endl;
delete heap;
return 0;
}有几点提醒你。第一,lit 是指针,我为了打印它指向的地址,用的转型是 (const void*)lit,而不是 (void*)&lit——后者打印的是"指针变量 lit 自己的地址"(在栈上),前者才是"它指向的字符串字面量"的地址(在代码段)。这正是上节课那道经典题的题眼。第二,因为现代系统普遍开了 ASLR(地址空间布局随机化),你每次运行打印出的具体数值都会变,但相对次序(栈 > 堆 > 数据段 > 代码段)是稳定的。第三,最后一个字符变量版本,我建议你自己把课件里的 char ch2[] = "abcd" 和 const char* pCh3 = "abcd" 也都加进去打印一遍,对比 &ch2 与 (void*)ch2 的关系,你会更懂"数组名是首元素地址"这句话。
为了把这些"住在哪"配得上号,我们来看课件里的经典题目。先写这样一段代码:
#include <iostream>
#include <cstdlib> // malloc / free
using namespace std;
int g_val = 1; // 全局变量:住在数据段(静态区)
static int g_sv = 1; // 静态全局变量:也住在数据段
int main() // 程序入口
{
static int s_val = 1; // 静态局部变量:数据段,程序结束才释放
int local_val = 1; // 非静态局部变量:栈上
int num[10] = {1, 2, 3, 4}; // 局部数组:整体在栈上
char ch2[] = "abcd"; // 数组在栈上,拷贝了一份"abcd"
const char* pCh3 = "abcd"; // 指针pCh3在栈上
// 但它指向的字符串字面量在代码段(常量区)
int* p1 = (int*)malloc(sizeof(int) * 4); // 指针p1在栈上
// 它指向的空间在堆上
free(p1);
return 0;
}现在来对号入座,我带你一个个梳理,这种"变量 vs 它指向的内存"的区分是这道题的灵魂:
g_val和g_sv:全局变量 / 静态全局变量,数据段;s_val:静态局部变量,即使它在函数里面,只要带了static,就数据段,而且整个程序周期都存活;local_val和num:非静态局部变量和数组本体,栈;ch2:变量名ch2是栈上的数组,"abcd"被拷贝了一份放进这个数组(注意:这里是一份拷贝,不是指向字面量),所以ch2数组本体在栈上,*ch2(也就是数组里的第一个字符)也在栈上——它和下面pCh3那种"指向字面量"是两种完全不同的存在;pCh3:指针变量本身在栈上;而它的值指向的字符串字面量"abcd"放在代码段(常量区),所以*pCh3(它指向的那个只读字符)在常量区。正是这一点,导致你不该通过pCh3去修改字符串——那是写不可写区域,属于未定义行为;p1:指针变量在栈上;*p1(它指向的 4 个 int 空间)在堆上。
看清楚了吗?变量在哪,和你手动 new/malloc 出来的那部分内存在哪,是两码事。 指针变量在栈上,但它手里的地址可能指着堆。这道题里最容易搞错的就是 *pCh3 和 *p1 的归属——地址归栈,内容归常量区/堆。
这里我多提醒一句 ch2 与 pCh3 的区别,它是本节的隐藏考点:char ch2[] = "abcd" 是数组拷贝,"abcd" 这份数据被复制了两份(栈上一份、常量区一份原样),所以 ch2[0] = 'X' 合法;而 const char* pCh3 = "abcd" 是指针指过去,pCh3 只拿着一份常量区的地址,本质上 pCh3[0] = 'X' 是在改只读数据。为什么这道题题干特意给 pCh3 加了 const?因为它指向的确实是只读内容,加 const 是编译器在提醒你别乱改。
你可以用一句口诀辅助记忆:局部裸变量在栈,static 和全局在数据段,字面量字符串进常量区,程序员手动申请的去堆。
C 语言中的动态内存:malloc / calloc / realloc / free
在 C++ 出现之前,C 语言程序员想要的"运行时再决定要多少内存",靠的是四个函数:malloc、calloc、realloc 和 free。它们都声明在 <cstdlib>(C 风格的话是 <stdlib.h>)里。注意这里有个版本细节:在 C++ 里推荐写 <cstdlib>,标准确保其中的符号在 std 命名空间可用,而主流实现(MSVC、GCC、Clang)也把它们暴露到全局命名空间,所以这里 malloc 和 ::malloc 都能用。为了不依赖"实现恰好也暴露到全局"这一点、也对老编译器最稳妥,工程代码常统一写成 ::malloc/::free 强调走过全局命名空间。
malloc(n):申请n个字节的连续空间,返回void*,不初始化,里面是什么垃圾值都有可能。它只告诉你"我占这么多字节",完全不关心你要存几头大象还是几根针。calloc(n, size):申请n个大小为size的元素,并且把每个字节清零。一个细节:calloc(n, size)在内部会做"乘法溢出检查",如果n*size溢出,它返回空指针,这算它比裸malloc多一重保险的地方。realloc(ptr, n):在ptr原有空间基础上重新分配为n字节,会保留旧数据,扩容时多出来的部分不保证清零。free(ptr):把之前申请的动态内存还给系统,free一个空指针是安全且允许的(什么都不做)。
注意,这三个"申请"函数的关系常常被拎出来当面试题问。realloc 的逻辑分两派:如果原空间后面还有足够的连续空闲空间,它就原地扩展、返回原地址;如果后面不够连续,它会另找一块更大的空间,把旧数据一字不差地搬过去,再释放旧空间,然后返回新地址。这就解释了为什么 realloc 的返回值你必须接收——旧指针可能在扩容时已经失效了。搬到新家之后,你还攥着旧钥匙,指着的已经是别人家的墙。
#include <iostream>
#include <cstdlib>
using namespace std;
int main()
{
// malloc 申请 4 个 int 的空间,不初始化,里面的值是随机的
int* p1 = (int*)malloc(sizeof(int) * 4);
// calloc 申请 4 个 int 的空间,并全部清零
int* p2 = (int*)calloc(4, sizeof(int));
// realloc 把 p2 的 4 个 int 扩容到 10 个 int,
// 成功时返回新地址(可能和 p2 相同,也可能不同)
int* p3 = (int*)realloc(p2, sizeof(int) * 10);
cout << p1[0] << endl; // 未初始化的垃圾值,别依赖它
cout << p2[0] << endl; // 一定是 0,calloc 已清零
cout << p3[3] << endl; // 前 4 个元素保留了 p2 里拷贝过来的 0
// 注意:p2 的空间可能已被 realloc 接管(搬迁并释放),
// 因此这里只 free p3 和 p1,不能再 free p2
free(p1);
free(p3);
return 0;
}这里有个隐藏的坑,而且坑的名字叫"我好心没溢出、却没人告诉我别这么写":realloc 失败时会返回 NULL 并且不释放原指针。所以严谨的写法应该先把返回值存进临时指针判断一下,避免把原指针覆盖掉导致泄漏。上面这段是为了演示而简化了,真写工程千万别这么干。严谨的工程写法是这样:
#include <iostream>
#include <cstdlib>
using namespace std;
int main()
{
int* p = (int*)malloc(sizeof(int) * 4);
if (p == nullptr) // malloc 失败要判空
{
cout << "初始申请失败" << endl;
return 1;
}
// 用临时指针接返回值,扩容成功后旧指针 p 可能已失效
int* tmp = (int*)realloc(p, sizeof(int) * 10);
if (tmp == nullptr) // 扩容失败:原 p 依然是有效的!
{
cout << "扩容失败,p 仍然可用" << endl;
free(p); // 必须手动释放原内存,否则泄漏
return 1;
}
p = tmp; // 只有成功才更新 p
free(p); // 统一释放
return 0;
}这个例子也顺带解答了课件里的问题——realloc 之后到底要不要 free 旧指针 p2?答案通常是不要,因为旧空间要么被原地扩容、要么已被搬迁释放,无论哪种,p2 都不该再由你手动释放,否则就是二次释放(undefined behavior,未定义行为,即"标准没规定会发生什么,崩溃是运气,不崩是侥幸")。
接下来专治面不面(面试的面):malloc 的底层实现原理。课件面试题里特意写了"glibc 中 malloc 实现原理",这里给你讲透。你在 Linux 上用的 glibc 的 malloc,内核叫 ptmalloc(源于 dlmalloc)。它的整体思想是:小块内存向操作系统"大盘子"(heap/brk)蹭,大块内存直接用 mmap 单独要。具体拆开看有这么几层关键机制:
- 内存碎片:反复申请、释放小块,会让空闲空间被切成一段段不连续的小块,叫"外部碎片";每个分配块头部要存放"本块大小、前一块大小、是否空闲"等管理信息,这部分额外开销叫"内部碎片"。系统用一整套空闲链表(bin)来组织这些空闲块,申请时就近或按大小挑一块用,释放时装回去、并尝试把相邻空闲块合并成大块。
- 线程缓存(tcache,glibc 2.26 加入):每个线程拥有一个私有的小对象缓存,牺牲一点内存、极大减少多线程抢锁的竞争,让小对象分配快很多。这是 glibc 后期为了优化多核性能加的一层"前台便利店"。
- arena(分配区):主线程用主分配区配合
brk/sbrk(移动所谓的"程序断点"来管 heap),其他线程用各自的 arena 通过mmap申请非连续的内存段,避免多个线程去抢同一把锁。 - 大块阈值:超过
MMAP_THRESHOLD(默认约 128KB)的分配直接走mmap,这类内存在free时会立刻归还操作系统;而小块内存归还后多半还留在进程的 arena 空闲链表里(这意味着"我free了你,内存在 RSS 里不一定立刻降下去"——这是很多人测内存占用时被蒙住的地方)。 - 为什么 malloc 返回的地址是对齐的:分配器保证返回的指针按
max_align_t(至少 8 或 16 字节)对齐,所以你可以放心把它当各种类型的首地址用,这也间接回答了后面 new 为什么不用你管对齐。
理解这些,你就明白了两件事:第一,free 后指针的确"空了",但内存是否立刻还给操作系统,由分配器的策略决定,别拿 top 里的 RSS 数字直接断言"泄漏";第二,malloc 之所以快于 mmap 直取,是因为它先到空闲链表里"淘"现成的块,而不是每次都去找内核开系统调用。这套机制和你后面学的 new 在同一层之上,new 只是再套了个壳。
再有三个边界习惯你从今天起就要焊死:free(NULL) 是安全的(什么都不发生),free 一块已释放的地址(二次释放)是未定义行为(可能直接崩,也可能"碰巧"没崩,但绝不允许依赖),释放后继续解引用(use-after-free)同样未定义行为。它们跟 new/delete 一样,都属于"红线",靠运气是不成立的。
好,C 的内存管理就是这么个朴素的套路:申请 → 用 → 释放。可它有一堆不顺手的地方,C++ 正是从这里看到了改进的切入点。
new 与 delete 的引入:C++ 自己的动态内存
malloc/free 在 C++ 里能用吗?能用,语法上完全合法。但 C++ 很快发现它在很多场景下无能为力,尤其当你面对的是一个"有构造函数、有析构函数"的自定义类型对象时。为什么?因为 malloc 只负责"开一块儿空间",它完全不认识构造函数这回事——你用 malloc 开出一块适合放 A 对象的内存,但那块内存里并没有一个"活着的 A 对象",构造函数压根没被调用。打个比方:malloc 给你一间"毛坯房",水泥地、四面墙,但里面没人住、没通水电;你要的是一个"有人住、东西都摆好"的家,那得有人搬进去、开灯、布置家具——这一步 C++ 叫"构造"。
于是 C++ 提出了自己的动态内存管理方式:new 和 delete 这两个操作符。它们是操作符(不是函数),用法如下面代码所示。记住一条铁律:申请和释放单个元素用 new/delete,申请和释放连续空间用 new[]/delete[],必须配对匹配使用。
#include <iostream>
using namespace std;
int main()
{
// 申请一个 int 类型对象,不指定初值
// 注意:对内置类型,"不指定初值"意味着 int 是"默认初始化",
// 值是未确定的"垃圾值"(C++ 里叫不确定值)
int* p1 = new int;
// 申请一个 int 类型对象,并直接初始化(constructor 风格)为 10
int* p2 = new int(10);
// 申请连续 3 个 int 类型的空间(数组)
int* p3 = new int[3];
// 申请并在构造时指定初值(C++11 起支持,使用列表初始化)
int* p4 = new int{ 20 };
cout << *p2 << endl; // 输出 10,new 能带初值,malloc 做不到
cout << *p4 << endl; // 输出 20
// 一一配对释放:单个对象用 delete,数组用 delete[]
delete p1;
delete p2;
delete[] p3;
delete p4;
return 0;
}关于初始化方式,这里埋着一个很容易被忽视、却又常被面试官点名的细节,值得单独展开(注意这是内置类型的初值差异,C++11 起一切初始化形式都统一在这几种名下):
| 写法 | 初始化方式 | 结果 |
|---|---|---|
new int | 默认初始化 | int 是垃圾值(不确定) |
new int() | 值初始化 | 确定是 0 |
new int(10) | 直接初始化 | 是 10 |
new int{20} | 列表初始化(C++11) | 是 20 |
也就是说,想要一个"干净为零的整型对象",你得写 new int(),光写 new int 拿到的是看不透的旧值。对数组同理:new int[3] 三个元素都不确定,而 new int[3]() 三个都是 0。这个"追求确定初值就该加一对空括号/空花括号"的习惯,能省掉你后面一堆"为什么是乱码"的排查时间。
对内置类型(int、double、char 这些)来说,new/delete 和 malloc/free 的行为几乎一样——都是开空间、释放空间,唯一的差别是 new 能顺手初始化。但真正的分水岭在自定义类型上,这才是 new 存在的根本价值。看下面的代码:
#include <iostream>
using namespace std;
class A
{
public:
// 构造函数:对象创建时自动执行
A(int a = 0) : _a(a)
{
cout << "A() 构造,this = " << this << endl;
}
// 析构函数:对象销毁时自动执行
~A()
{
cout << "~A() 析构,this = " << this << endl;
}
private:
int _a;
};
int main()
{
cout << "--- 用 malloc 开空间 ---" << endl;
A* p1 = (A*)malloc(sizeof(A)); // 只开了空间,构造函数没有被调用
free(p1); // 只释放空间,析构函数没有被调用
cout << "--- 用 new 申请对象 ---" << endl;
A* p2 = new A(1); // 开空间 + 调用构造函数,两行输出里多了 A()
delete p2; // 调用析构函数 + 释放空间,多了 ~A()
return 0;
}你运行一下就会发现:申请 p1 的那两行,只有申请和释放,完全没有构造和析构的输出——因为 malloc/free 根本不碰构造函数。而 new A(1) 这行,在开空间之后立刻调用了 A(),输出 A() 构造...;delete p2 在释放之前先调用了 ~A(),输出 ~A() 析构...。
这就是 C++ 对动态内存管理给出的核心答案:new = 分配内存 + 调用构造函数;delete = 调用析构函数 + 释放内存。 对于一个需要初始化的对象、一个持有资源的对象(比如内部 new 了一个数组),这一步之差就是天壤之别——malloc 出来的空间里根本没有"对象",你调用它的成员函数就是往垃圾内存上操作。
我额外补一个极其重要、却少有人提的细节,帮你把"new 才够用"想通透:假如 A 内部 new 了一个资源(比如一个动态数组),那么用 malloc 造的"假 A"调用析构时会怎样?它连构造函数都没跑,内部指针根本没初始化,要么析构读到垃圾地址去 free 未知内存,要么连析构都不会被调用、资源直接烂在内存里。所以凡是有自定义析构函数、内部持资源的类型,都必须用 new/delete 来管理生命周期,malloc 对它们形同虚设。这一点,也是你后面理解 RAII、理解 unique_ptr 为什么强大的一颗种子。
new/delete 与 malloc/free 的对比
看到这,两者的差异其实已经呼之欲出了。这里我们把它整理成一个清晰的三段式对比,这也是面试官最爱问的题,值得背下来:
- 身份不同:
malloc/free是函数,new/delete是操作符(运算符)。正因为是操作符,编译器才知道该给你生成什么样的类型、要不要调构造析构——new的"类型信息"是编译期写死在生成代码里的; - 是否初始化:
malloc只是开空间,不会初始化;new可以在申请时初始化(new int(10)); - 申请时的写法:
malloc需要手动计算字节数并传进去,离开sizeof很痛苦;new直接写类型,数组只要new int[10],编译器帮你算大小; - 返回值:
malloc返回void*,必须强转;new返回的就是目标类型的指针,不用转; - 失败处理:
malloc失败返回NULL,你要判空;new失败会抛std::bad_alloc异常,你要捕获异常(而不是判空); - 对自定义类型:
malloc/free只开空间/释放空间,不调用构造和析构;new/delete会调用构造函数和析构函数。
有一种说法是"new/delete 就是 malloc/free 的升级版",其实不够准确——它俩是两套不同的机制(后续会看到 new 底层确实调用了 malloc,但决不等同)。我来用一个表格把这六点收拢一下,方便你对照记忆:
| 对比维度 | malloc / free | new / delete |
|---|---|---|
| 本质 | 函数 | 操作符(运算符) |
| 是否初始化 | 否 | 可以(如 new int(10)) |
| 空间大小 | 手动给字节数 | 写类型即可,数组给个数 |
| 返回类型 | void*,需强转 | 目标类型指针,无需强转 |
| 失败表现 | 返回 NULL,判空 | 抛 std::bad_alloc 异常 |
| 构造 / 析构 | 不调用 | 分别调用 |
关于第 5 点,我再延伸出两个容易"被时代落下"的细节。第一,new 其实还有一个"不抛异常"的表亲——nothrow 版 new:写成 new (std::nothrow) int,申请失败时它不抛异常,而是像 malloc 一样返回空指针。它需要 #include <new>。它在哪儿有用?在"禁止抛异常"的场合(比如某些嵌入式环境、无异常配置)或者在老式代码风格里。但请听我一句劝:既然用 C++,异常处理通常是更现代、更统一的姿势,除非有明确理由,否则别因为"嫌 try/catch 麻烦"就退回 nothrow,那不是简化,是自我阉割。第二,malloc 失败你只能判空,new 失败你要么捕获 std::bad_alloc,要么借助 nothrow 判空——两者千万不能混着用,用 malloc 的思维去判 new 的结果、或用异常思维去处理 malloc,都会写错。
operator new 与 operator delete:new 的底层秘密
你可能要问了:new 既然这么厉害,"分配内存 + 调用构造函数"这两步到底是谁干的?答案是:new 是一个操作符,编译器会在背后把它翻译成对全局函数 operator new 的调用(分配内存),而构造函数由编译器在拿到内存后自行安排调用。delete 对应的则是全局函数 operator delete(释放内存)。
也就是说:operator new 是分配内存的底层函数,operator delete 是释放内存的底层函数,它们和 new/delete 这个操作符是两码事。名字有点像,但别混淆——global operator new 通常实现成"不断尝试 malloc"。这里先把里头的关键词都点了名:operator new、operator delete 是全局可重载的函数,它们声明在 <new> 里;而 std::bad_alloc 是 new 分配失败时抛出的异常类型,也声明在 <new>;std::nothrow 是一个常量,用来标记"我要 nothrow 版本"。
课件里贴了 operator new 的典型实现(不同编译器略有差异)。为了让机制可编译、可跑起来看效果,我先把它的"灵魂"改写成一段能直接编译的独立函数给你看。它的本质就是:不断尝试 malloc,失败就看看用户是不是配了新分配处理器(new handler),配了就调用它再试,没配就抛 std::bad_alloc:
#include <iostream>
#include <new> // std::bad_alloc / std::new_handler / std::get_new_handler
#include <cstdlib> // ::malloc
using namespace std;
// 用可编译代码还原 global operator new 的核心算法(示意,非标准库原文)
void* naive_new(size_t size)
{
// 取当前"内存不足处理器";C++11 起可用 std::get_new_handler 拿到
std::new_handler h = std::get_new_handler();
void* p = nullptr;
while ((p = ::malloc(size)) == nullptr) // 先尝试用 malloc 开空间
{
if (h == nullptr) // 用户没有配置处理器
throw std::bad_alloc(); // 彻底失败,直接抛异常
h(); // 调用处理器尝试"腾"出内存(再循环一次)
}
return p; // 成功则返回这块空间
}
int main()
{
try
{
int* p = static_cast<int*>(naive_new(sizeof(int))); // 模拟 new int 的分配
*p = 42;
cout << *p << endl;
::free(p); // naive_new 用的是 malloc,就得用 free 还
}
catch (const std::bad_alloc&)
{
cout << "内存不足" << endl;
}
return 0;
}说明两点:上面这段不是为了让你在工程里替换标准库,而是为了把"operator new = 循环试 malloc + 失败抛异常"这个天机一眼看穿。第二,真实的标准库 operator new 声明是 void* operator new(std::size_t size);,它内部调用的失败兜底是 MSVC 的 _callnewh(size)(内部再去调用用户通过 std::set_new_handler 设置的处理器);gcc/clang libstdc++ 的实现逻辑也一致:能 malloc 就返回,否则调用 std::get_new_handler() 拿到的处理器,没有处理器就 throw std::bad_alloc()。总之那句结论不变——operator new 底层是用 malloc 申请的。
再看 operator delete 的典型实现:
// operator delete:本质就是调用 free 释放空间(可编译的理解版)
#include <new>
#include <cstdlib>
void operator_delete_wrapper(void* p)
{
if (p != nullptr) // 空指针释放是安全且允许的
::free(p); // 把空间还给系统
}
int main()
{
int* p = (int*)::malloc(sizeof(int)); // 配套用 malloc 申请
operator_delete_wrapper(p); // 调用上面的"释放"封装
return 0;
}看到这个你就明白了:operator new 底层是用 malloc 申请的,operator delete 底层是用 free 释放的。 换句话说,new 并没有发明一种新的取内存方式,它只是在 malloc 之上又盖了一层——把"申请失败返回 NULL"升级成"抛异常",再配合"申请成功后调用构造函数"。这是理解整篇文章的一把钥匙。
现在,既然 new 是操作符、operator new 是可重载的全局函数,那就自然衍生出一个高级但很实用的能力:类的专属 memory allocation。你可以在类内部重载 operator new/operator delete,让这个类的对象走你自己的分配策略(比如从自建内存池取值),而不影响其他类型。这也侧面证明了"new/delete 是操作符、分配是可插拔"的设计哲学。例子如下(可编译):
#include <iostream>
#include <new>
#include <cstdlib>
using namespace std;
class A
{
public:
A(int a = 0) : _a(a) { cout << "构造 A(" << _a << ")" << endl; }
~A() { cout << "析构 A(" << _a << ")" << endl; }
// 类专属 operator new:只有 A 对象的内存申请会走到这里
static void* operator new(size_t size)
{
cout << "类专属 operator new,size=" << size << endl;
return ::malloc(size); // 演示用,别在真实代码里裸 malloc
}
// 类专属 operator delete
static void operator delete(void* p) noexcept
{
cout << "类专属 operator delete" << endl;
::free(p);
}
private:
int _a;
};
int main()
{
A* p = new A(7); // 会打印 "类专属 operator new"
delete p; // 会打印 "类专属 operator delete"
return 0;
}关于 operator new/operator delete 家族的"人丁兴旺",我再澄清几句版本差异,避免你被不同的写法吓到(这些主要是"要不要知道"级别的知识,先混个脸熟):
- C++14 引入了 sized deallocation(带大小的释放):标准允许
operator delete(void*, size_t)这种带大小的版本,让分配器可以不为记录大小额外花一眼看不出的空间。用 C++14 及以上编译时,编译器可能生成带大小的 delete 调用。 - C++17 引入了 aligned new(对齐 new):为了支持
alignas指定苛刻对齐的类型,出现了operator new(size_t, std::align_val_t)等带"对齐参数"的重载。平时你不需要手写对齐相关的 new,知道有这个机制、并且明白"编译器负责按类型对齐要求选对版本"就够了。 - nothrow 版:
void* operator new(size_t, const std::nothrow_t&) noexcept,这就是前面说的new (std::nothrow) T背后的第二个重载,失败时不抛异常、返回空指针。
这些"多胞胎"都是在同一个核心(先分配、失败给反馈)之上做参数上的微调,你把最基础的 operator new(size_t) 吃透,其余都是它的变体。真正写代码时,你几乎遇不到、也通常不需要自己重载全局的 operator new——但看懂原理能让你读标准库源码、排查内存诡异问题时不至于两眼一黑。
我们甚至可以直接用手动的方式,把这套底层流程原样走一遍,看看对象是怎么"复活"的:
#include <iostream>
#include <new> // operator new / operator delete / placement new 都在这
#include <cstdlib>
using namespace std;
class A
{
public:
A(int a = 0) : _a(a) { cout << "构造" << endl; }
~A() { cout << "析构" << endl; }
private:
int _a;
};
int main()
{
// 第一步:operator new 分配内存(底层 malloc),此时还没有"对象"
A* p = (A*)operator new(sizeof(A));
// 第二步:定位 new 在已分配的内存上显式调用构造函数,这里要传参
new(p) A(10);
// 使用对象...
cout << "使用对象" << endl;
// 第三步:显式调用析构函数,清理对象资源
p->~A();
// 第四步:operator delete 释放内存(底层 free)
operator delete(p);
return 0;
}这四步手工操作,恰好拆解了 new A(10) / delete p 背后编译器替我们做的那两件事。你现在能直观感受到"分配内存"和"构造对象"是两个独立的动作——这也是后面定位 new 的用武之地。
new 与 delete 的实现原理
把上一条再往深挖一层,我们就得到了 new/delete 的完整执行收束。针对自定义类型,原理如下:
new T的原理分两步:- 调用
operator new申请一块能装下T的空间(底层是malloc); - 在这块空间上调用构造函数,完成对象的初始化,返回这块空间的地址。
- 调用
delete p的原理分两步:- 在
p指向的对象上调用析构函数,清理对象内部的资源; - 调用
operator delete释放这块空间(底层是free)。
- 在
new T[N]的原理:- 调用
operator new[],而operator new[]内部会调用operator new完成N个对象空间的整体申请; - 在这片空间上连续执行
N次构造函数。
- 调用
delete[] p的原理:- 连续执行
N次析构函数,逐一清理N个对象的资源; - 调用
operator delete[],其内部再调用operator delete释放整块空间。
- 连续执行
注意这个顺序不能反:delete 一定是先析构、后释放。如果先释放了空间,析构函数再访问对象内部的成员(比如释放 _data 指向的数组)就可能踩到已归还的内存上,属于灾难。理解这个顺序,其实也顺带解释了"为什么构造函数里一旦发生异常,编译器会自动调用 operator delete 把那块空间还掉"——因为对象没构造成功,摊子要收,但也绝不能去析构一个没构造出来或只构造了一半的对象,那同样危险。标准对这一块有严格规定:构造中抛出异常,就要保证没有任何完整构造的子对象去析构,同时已申请的内存要归还(这叫"异常安全"的一种)。这不是玄学,是标准倒逼编译器去做的善后。
关键的分水岭还是那句:new/delete 多出来的"构造/析构",malloc/free 一辈子都不干。 内置类型因为没有构造析构,所以两者对内置类型的表现几乎一致。我用一张表把"内置类型"这条线也归纳进来,你会发现它的"分水岭"全部来自构造/析构这一层:
| 操作 | 内置类型 | 自定义类型(非平凡构造/析构) |
|---|---|---|
new vs malloc | 几乎等价;new 可带初值 | new = 分配 + 构造;malloc 只分配 |
delete vs free | 几乎等价 | delete = 析构 + 释放;free 只释放 |
| 失败行为 | new 抛 bad_alloc / malloc 返 NULL | 同上 |
| 数组为什么不能混用 | 碰巧可能不崩(无析构) | 必须 new[]/delete[],否则 UB |
new[] 与 delete[]:括号匹配的天坑
现在到了一个特别容易踩坑的地方。请看这组代码,先别急着抄,动脑子想想哪里会出事:
#include <iostream>
using namespace std;
class A
{
public:
A() { cout << "构造" << endl; }
~A() { cout << "析构" << endl; }
};
int main()
{
// 申请 3 个 A 对象的数组空间,并逐个构造
A* p = new A[3];
// 错误示范:用 delete 而不是 delete[] 来释放
delete p; // 危险!这是未定义行为
return 0;
}问题出在哪?这里有个极其隐蔽的机制,也是本节真正值钱的地方。当 new T[N] 申请带非平凡析构函数的自定义类型数组时,编译器会在返回的指针前面偷偷多分配 4 个(或 8 个)字节,用来记录"这一片数组一共有几个元素"。为什么要记?因为 delete[] 得知道要调用几次析构函数——它不能从指针本身推断出数组长度(数组没有"长度"这个固有信息随身携带,不像别的语言)。
把"cookie"(数量前缀)讲得更细一点:这多出来的几字节就是我们常说的 cookie(也可以叫 array cookie / 数组头部)。具体的存放位置、存放几个字节(4 还是 8),是由特定编译器的 ABI 实现决定的——主流编译器(MSVC、GCC、Clang)都遵循这一通用做法,但标准并没有强制规定 cookie 必须长什么样,只是要求"delete[] 能正确反推次数"。在 GCC/Clang 的 Itanium ABI 上,对象数组若带了非平凡的析构函数,就会在返回的数据指针之前放一个 size_t 类型的 cookie 记录元素个数;MSVC 上则常是放在数据区前 4 或 8 字节的位置。对没有析构函数(析构平凡,比如内置类型或 POD 结构体)的类型,编译器不需要逐个析构,所以通常不加 cookie——这就是为什么 new int[3] 用 delete 释放经常"碰巧不出事"。
而这个"数量前缀"会带来两个连锁反应:
- 实际返回给你的指针
p,并不是整块内存真正开头的指针,而是偏移了那几个记录字节之后的地址; - 所以
delete和delete[]走的是两套不同的回收逻辑。delete p认为p是单个对象,不会去读那个前缀,也不会逐个析构,它拿一个"错误的起点"去释放——这块内存根本不是你operator new还回来的起点。结果就是未定义行为:轻则构造几次析构几次对不上号、资源没清理,重则直接崩。
反过来,如果把 new(单个)申请出来的空间用 delete[] 释放,delete[] 会去读那个本就不存在的前缀,读到垃圾计数,然后按垃圾次数"正经"地析构和释放——同样是雷。所以唯一正确的姿势就是严格配对:new 配 delete,new[] 配 delete[],谁也不许跨界。 内置类型数组(比如 new int[3])由于没有析构,编译器通常不会加前缀,误用 delete 也可能"碰巧"不出事——但那是侥幸,不是规范,别赌这个默契。
为了让你彻底信服"配对是性命攸关"而非"老师吓唬人",我再补一个小实验的思路(可编译,运行看构造/析构次数):分别写 new A[3]; delete p; 和 new A[3]; delete[] p;,对比输出的"构造三次、析构几次"。前者多半只析构一次甚至不析构,你就知道资源泄漏/二次释放是怎么发生的了。真正的工程规范是下面这样:
#include <iostream>
using namespace std;
class A
{
public:
A() { cout << "构造" << endl; }
~A() { cout << "析构" << endl; }
};
int main()
{
// 正确示范:申请数组用 new[],释放数组用 delete[],严格配对
A* p = new A[3]; // 三次构造
delete[] p; // 三次析构
// 再验证一下单个对象
A* q = new A; // 一次构造
delete q; // 一次析构
return 0;
}到这里,"申请与释放必须配对"这个习惯,已经从"规范"升级成了"性命攸关"的铁律。我把几条最容易犯的记在这里,你对照着自己的习惯自查:new 之后忘 delete、new[] 之后写了 delete、普通指针解引用一个已经被释放的指针、释放了然后又被释放一次。这些坑,越早养成配对意识,越少踩。
另外顺带一提,正因为 new[] 要在前面偷偷藏一个计数,所以通过 new 分配的内存和通过 new[] 分配的内存在布局上可能并不一样。这也是为什么把 new[] 得到的指针接给一个参数是 delete 的释放函数(或者在资源管理类里用错了释放器)会造成诡异崩溃——同一个指针,人家是按"带前缀的数组块"来管理的,你却按"裸对象"去还。现代 C++ 的解法是:数组统一交给 std::vector,单一对象统一交给智能指针,它们内部封装好的分配器天然满足"正确地配对释放",你根本不需要亲手选 new 还是 new[]。
定位 new(placement new):在既有空间上"造活对象"
现在回到刚才手工步骤里那个很关键的函数——定位 new,英文叫 placement new。它解决的问题是:我手上已经有一块内存,我想在它上面调用构造函数,把这块内存"变成"一个活的对象。
还记得我们发现 malloc 开出来的空间里没有活对象吗?定位 new 就是补上这一步的工具。它的语法长这样:
#include <new> // 定位 new 的声明在这
// new (place_address) 类型 // 在 place_address 处构造一个默认对象
// new (place_address) 类型(args) // 在 place_address 处构造并传参初始化其中 place_address 必须是一个指针,后面可以跟初始化参数。它不分配任何新内存——内存是现成的,它只负责"在原地调用构造函数"。而它也不负责任何释放,释放仍由你手动用 free 或 operator delete 完成(释放前别忘了先手动调用析构函数)。换句话说,定位 new 把"分配/释放"这半截交还给你,自己只管"构造/析构"那半截。
为什么标准库代码里要用 <new>,你几乎可以用下一条代码验证:new (p) A(10) 这个写法之所以合法,是因为 <new> 里声明了带指针参数的 operator new 的 placement 版本(它的实现就是一个返回原指针的空壳)。从这你也能反向理解:定位 new 走的是 operator new(size_t, void*) 这个重载(第一个参数是编译器隐式传入的"对象大小",第二个参数才是你写的那个 place_address),它的实现只是原样把地址返回,连真正的分配器都不碰。
定位 new 最常见的用武之地是内存池。内存池预先向系统申请一大块内存,之后对象就在这块池子里分配"格子",省去了反复走系统分配的开销。但池子的内存是"生"的、没有初始化,因此当我们要在池子里放自定义类型对象时,就得用定位 new 显式地调用它的构造函数来初始化它。这正好把"分配"和"构造"彻底解耦了。我写一个极简但完整可编译的内存池演示,让你直观看清"分配、构造、析构、释放"四次操作的先后:
#include <iostream>
#include <new> // placement new
#include <cstdlib> // malloc/free
using namespace std;
class A
{
public:
A(int a = 0) : _a(a) { cout << "构造 A(" << _a << ")" << endl; }
~A() { cout << "析构 A(" << _a << ")" << endl; }
private:
int _a;
};
int main()
{
// 第一步:一次 malloc 一整块"池子",这里能装 3 个 A
const int N = 3;
void* pool = ::malloc(sizeof(A) * N); // pool 只是一块"生"内存
A* objs = static_cast<A*>(pool);
// 第二步:在池子里逐个格子 placement new 构造对象
new (objs + 0) A(1);
new (objs + 1) A(2);
new (objs + 2) A(3);
// 使用对象(此处省略具体业务逻辑)
cout << "池子里的 3 个对象都已就绪" << endl;
// 第三步:回收前先逐个手动析构
for (int i = 0; i < N; ++i)
objs[i].~A();
// 第四步:把整块池子还给系统
::free(pool);
return 0;
}看到了吗,new (objs + 0) A(1) 这行做了且只做了一件事:在指定地址上显式调用构造函数。它不会跑到堆上去重新申请内存。这行代码的完整写法甚至还可以和 operator new 拼在一起用:A* p2 = (A*)operator new(sizeof(A)); new(p2) A(10);——这正是我们上一节手动走的那四步的忠实写照。
关于 placement new,还有几个值得你知道的边界和坑,一并讲透:
- 对齐问题。定位 new 只负责构造,不负责给你一块"对齐得当"的内存。换句话说,
place_address必须已经满足该类型要求的对齐(对 int 至少要 4 字节对齐,对 double 至少 8 字节,对alignas苛刻的类则要更大)。如果你拿一块不对齐的内存硬塞,是未定义行为。所以内存池实现里,通常会先算好对齐取整再切格子——这是内存池的高级话题,你现在知道"为什么"即可。 - 数组的 placement new 是个坑。
new (p) T[N]这个写法对"带非平凡析构的类型"实际上是没有官方规定、常见实现还不支持的操作(严格说,标准只对单个对象的 placement new 定义了语义;数组 placement new 的行为是实现定义的)。所以工程里要逐格 placement new 数组,就老老实实for循环一个个new (p+i) T(...),像上面内存池演示那样,别指望一行搞定。 - 一个"复活"的副作用。在同一块内存上反复 placement new,每次都会覆盖掉旧对象的状态,而且旧对象的析构函数不会自动调用——你得先
p->~T()再new (p) T(...),否则旧资源没人清理。也就是说,placement new 把"构造/析构的时机"完全交给你掌控,用好了灵活,用错了就是没析构的资源泄漏。 - 谁在用它。
std::vector扩容时,本质就是"先开够原始内存(只分配、不构造),再把旧元素拷贝/移动构造到新格子里,最后析构+释放旧格子"——中间那步"在未初始化内存上构造成员",用的正是 placement new。这也是定位 new 被称为"容器内部基石"的原因。理解了它,你读std::vector内部实现会异常轻松。
有一点需要你体谅:定位 new 这个"new"是个容易让人误解的名字,它并不分配内存。它的意义恰恰在于"内存我已经有了,我只想调用构造函数"。也正因如此它常出现在内存池、以及 std::vector 等容器的内部实现里(容器扩容时就是先开内存再逐格 placement new 构造元素)。了解了定位 new,你就拿到了"分配"与"构造"两个世界之间的那把钥匙。
内存泄漏与安全实践
前面讲了半天的 new/delete,如果只记住一句话,那就是:手动申请的内存,必须由你手动归还。 而"申请了却没归还",就叫内存泄漏(memory leak)。泄漏的后果并不立刻发作——程序不会一秒崩溃,而是随着泄漏的量一点点涨上去,可用内存越来越少,程序越来越慢,最后在某一次 new 的时候抛出 bad_alloc,"砰"地一声申请不到内存了。在服务端场景尤其致命:一个常驻进程如果每个请求泄漏几 KB,跑上几天几周,内存占用会像温水煮青蛙一样只涨不跌,最后 OOM(内存耗尽)被系统杀掉。
更可怕的是它的隐蔽性:内存泄漏不必体现在某个明确的报错上,它是一点一滴"漏"走的。尤其是在循环里反复 new 却忘了 delete、在分支里提前 return 跳过了 delete、把 new 的地址覆盖丢了找不回来(比如 p = new int; p = new int; 第二次覆盖了第一次的地址,第一块就再也找不回来、彻底泄漏了)——这些情形肉眼很难发现。这里我要点出与泄漏并称"内存三巨头"的另外两种病,因为它们的危害同样在"事后难察觉":
- 野指针 / 悬垂指针(dangling pointer):指向的内存已经被释放了,你还拿着这个地址去读或写。读可能读到旧值,写更危险,可能破坏到后来复用这块内存的数据。典型来源就是
free/delete之后没把指针置空。 - 二次释放(double free / double delete):同一块内存释放了两次。第一次把块还回去,第二次再还时,分配器可能已经把它合并或转给了别人,行为未定义,常导致堆损坏(heap corruption),症状诡异(可能不是当场崩,而是在后面的某处崩)。
这三种病有个共同解毒手法:彻底消灭"裸指针 + 手动配对"的脆弱模式,用 C++ 现代工具把"该不该释放、什么时候释放"交给对象去守护。这就是接下来要重点讲的 RAII 与智能指针。
先说一个预防层面的好习惯,它能从根源上消灭一大半泄漏。看这句"这里有个坑"级别的话:不要裸用 new/delete,优先交给 RAII(全称 Resource Acquisition Is Initialization,资源获取即初始化)。 这个概念说白了就是:让对象在构造时获取资源、在析构时释放资源,靠变量的作用域自动保证"有借有还"。你声明一个栈对象,不论函数体里怎么 return、怎么抛异常,析构函数都会在离开作用域时被保证调用——于是资源就永远不会漏。
RAII 为什么能"保证"析构一定调用?因为 C++ 对栈上对象的生命周期有硬性承诺:任何离开作用域的路径(正常走到 }、提前 return、抛出异常导致栈展开),编译器都会在那个时刻自动调用所有存活栈对象的析构函数。这就是"编译器替你擦屁股"的机制——你只要把"释放"写进析构函数,剩下的稳定收尾全部交给语言。看一个最直观的对比,同样是想在函数里动态申请一个数组:
// 写法一:裸指针手动管理,任何一个分支漏了释放点都是雷
#include <iostream>
using namespace std;
bool do_work() // 模拟一个可能提前返回的任务
{
return false; // 这里返回 false,模拟"任务提前失败"的路径
}
int manual_style()
{
int* data = new int[100];
if (!do_work()) // 这条路径提前 return,data 没人释放 → 泄漏
return -1;
delete[] data;
return 0;
}
int main()
{
manual_style();
return 0;
}// 写法二:智能指针与容器,让 RAII 兜底,任何一个分支都不用你操心
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
bool do_work()
{
return false; // 同样走"任务提前失败"的路径
}
int modern_style()
{
// unique_ptr 接管单个对象,离开作用域自动 delete
unique_ptr<int> data(new int(42));
// vector 接管数组,离开作用域自动 delete[]
vector<int> vec(100);
if (!do_work()) // 这里提前 return 也没关系,析构自动执行
return -1;
return 0;
}
int main()
{
modern_style();
return 0;
}现代 C++ 里这个模式已经内建到工具集里了:std::unique_ptr(独占所有权,不可拷贝、可移动,离开作用域自动 delete,用于取代裸 new)、std::shared_ptr(共享所有权,用引用计数决定何时 delete,用于多处共享同一对象)、std::weak_ptr(不增加引用计数的弱引用,专门破解 shared_ptr 循环引用),还有 std::vector、std::string 这些容器内部早就帮你管好了内存,绝大多数场景你根本不用写一个裸 new。串起来说:能用容器用容器,容器装不下的那点自定义所有权,交给智能指针;只有万不得已才亲手写 new/delete,且写了就要立刻想清楚"谁、在何时、用什么方式还"。 这套组合拳,就是内存安全的核心防感染源。
再说检测层面的工具。Leak 检测分两个世界,Windows 一套、类 Unix 一套,我两条线都给你铺开。
在 Visual Studio(MSVC) 的 Debug 模式下,编译器提供了一个现成的内存泄漏检测器——_CrtDumpMemoryLeaks。程序快结束的时候调用它,如果之前有泄漏,它会在输出窗口列出"某块内存在某文件某行申请、未被释放"的信息,方便你顺藤摸瓜。用法如下:
#include <iostream>
#include <crtdbg.h> // 需要这个头文件,里面声明了 _CrtDumpMemoryLeaks
using namespace std;
int main()
{
// 故意制造泄漏:申请了却没释放
int* leak = new int(100);
cout << *leak << endl;
// 注意:这里漏掉了 delete leak;
// 检测泄漏:把动态加载的内存块列出来(仅 Debug 构建有效)
_CrtDumpMemoryLeaks();
return 0;
}在 Debug 模式运行,输出窗口里会出现类似 Detected memory leaks! 加上一堆块号、大小、分配位置的信息——那就是泄漏证据。把 delete leak; 加回去再跑,就看不到这条提示了。另外,VS 的输出窗口还能直接双击那条泄漏信息跳到出错源码行,排错很省力。再给你补一个很管用的机关:_CrtSetBreakAlloc(块号)。当漏检报告里出现某块"泄漏块号 N"时,你可以把这个函数放在 main 开头,写法是 _CrtSetBreakAlloc(N);,然后 F5 调试——程序会在"第 N 号内存块申请的那一刻"自动断点停住,让你在分配现场抓到是谁 new 的,比事后猜强得多。要想一进 main 就自动开启漏检、连 _CrtDumpMemoryLeaks 都不用显式写,可以这样:
#include <iostream>
#include <crtdbg.h>
using namespace std;
int main()
{
// 只要设置这一句,程序退出时会自动把所有未释放块打印到输出窗口
_CrtSetDbgFlag(_CrtSetDbgFlag(_CRTDBG_REPORT_FLAG)
| _CRTDBG_LEAK_CHECK_DF);
// ... 正常业务代码 ...
return 0;
}在 Linux / macOS 上,最常用的检测工具是 Valgrind 的 memcheck 工具,跑一下 valgrind --leak-check=full ./你的程序,它会统计 definitively lost(确定泄漏)和 possibly lost(可能泄漏)各多少字节,还能告诉你每一块泄漏是在哪个文件哪一行 new 出来的。Valgrind 慢(常慢 10~30 倍),所以更适合"怀疑有泄漏时的专项体检",不适合常态跑。如果你想要更快、且跨平台的现代方案,推荐 AddressSanitizer(ASan)——它就是为抓内存错误而生的编译期工具,能同时抓越界、use-after-free、double-free,以及(借助内置的 LeakSanitizer)泄漏:
- GCC/Clang(Linux/macOS):编译时加
-fsanitize=address -g,链接时同样加-fsanitize=address,然后正常跑程序;程序退出时若检测到泄漏/越界,会打印详细报告并标注源码行。 - MSVC(Visual Studio 2019 16.9 起):项目属性里开启
/fsanitize=address,运行调试即可在输出窗口看到 ASan 报告。
以一个小例子说明 ASan 的用法(这段代码是普通可编译的,加速编译选项 -fsanitize=address 是在命令行加的,不属于代码):
#include <iostream>
using namespace std;
int main()
{
int* arr = new int[5];
arr[10] = 42; // 越界写:ASan 会当场报告 buffer-overflow
delete[] arr;
return 0;
}# Linux/macOS 编译运行(加 -fsanitize=address)
clang++ -g -fsanitize=address leak_demo.cpp -o leak_demo
./leak_demo # 运行时会打印 ASan 的越界报告
# Windows(MSVC)开启 ASan 后直接 F5 调试运行即可不过我要诚实地说一句:这些工具只能"事后发现",不能"事前预防"。真正的防线是养成规范。我把几条安全实践收拢在这里,供你贴在脑门上:
- 严格配对:
new配delete,new[]配delete[],谁也不会尊重你"图省事"的意图; - 优先智能指针与容器:能用
std::vector、std::string、std::unique_ptr就别裸new,让 RAII 替你兜底; - 单一所有权:明确"谁申请谁释放",不要多个指针指向同一块内存各让它"负责"释放,那会二次释放;
- 不要在申请与释放之间过早返回:
new之后、delete之前如果提前return,请确认这条路径上没有泄漏——最稳妥是把必须释放的资源交给智能指针/容器,让析构自己处理; - 别覆盖指针:
p = new int;之后在delete p之前不要用另一个new直接覆盖p,否则旧空间泄漏且找不回来; - nullptr 与判空:用
nullptr而不是裸0/NULL(C++11 引入);手动delete一个nullptr是安全的,但不要依赖它去"填补"两次释放的错误——"删空指针安全"不等于"能靠它掩盖二次释放",二次释放的本质是你对一块内存的所有权判断错了。
把这六条践行到位,你会发现自己写的代码内存相关错误显著变少——这不是玄学,而是纪律。
从今天起,把内存当成一种"借必还"的契约
我们走了很长一段路。从程序启动那一刻的内存分区(栈向下、堆向上、全局在数据段、字面量进常量区),到 C 的 malloc/calloc/realloc/free,再到 C++ 用 new/delete 替我们补齐了"构造与析构"这一层;我们掀开了 operator new/operator delete 的盖子,看到它们底层不过是包了一层 malloc/free(大大方方抛异常的那种);我们追到 new/delete 的两步走,也踩进过 new[]/delete[] 那个藏在指针前面的"数量前缀"的坑;我们学会用定位 new 在现成内存上"点石成金"式地构造对象,最后把目光落在内存泄漏的检测与如何靠纪律不让自己养成漏的习惯上。
现在回到开篇那句"C++ 把自由和责任都交给了你"。这份自由,就是你能精确掌控每一字节何时分配、何时释放、如何初始化;这份责任,就是要你记住那条贯穿全文的契约:借了必须还,且要用正确的方式还清。 好在 C++ 给了你足够多的帮手——有构造析构的成对机制、有 RAII,还有 std::unique_ptr、std::shared_ptr、std::vector 这些现代化工具,让你不必每次都亲手去管裸指针。学会"该省心时靠工具、该上手时靠纪律",你的 C++ 之路就能越走越稳。
如果你已经能熟练说出:内存四大区域的归属、new 与 malloc 的那六点区别、operator new 为什么最终还是 malloc、delete[] 为什么要先析构 N 次、定位 new 为什么"不分配内存"——那么恭喜,这块人人都说难啃的骨头,你已经啃下来大半了。下一回再遇到内存相关的 bug,别再猜,先想想那块内存归属在哪个区、是谁申请的、该由谁用哪种方式偿还。思路对了,问题往往就藏不住。
本篇思考题
学完这一篇,试着回答这几个问题,检验一下理解程度:
char ch2[] = "abcd";和const char* pCh3 = "abcd";里,ch2、*ch2、pCh3、*pCh3分别住在我讲的哪个内存区域?ch2[0] = 'X'合法吗?pCh3[0] = 'X'呢?new int、new int()、new int(10)、new int{20}四种写法的初值分别是什么?new int[3]()三个元素各是多少?- 为什么
realloc的返回值必须用一个临时指针接住再判断?它失败时会释放原指针吗? new/delete和malloc/free有哪六点区别?"new底层调用了malloc"这句话成立吗?成立的话,两者的行为为何看起来不一样?new A[3]配delete p为什么是未定义行为?"数量前缀(cookie)"是标准硬性规定还是实现决定?为什么内置类型数组经常"碰巧不崩"?- 定位 new 分配内存吗?
new (p) T[3]对非平凡析构的类型有什么风险?std::vector扩容时借用了哪个机制? - 举出至少三种能抓内存泄漏/越界的工具,并说明各自属于哪个平台、用法大致是什么。
参考答案与详解
1. ch2 / *ch2 / pCh3 / *pCh3 各住在哪个区?ch2[0] = 'X' 和 pCh3[0] = 'X' 合法吗?
这是本节那道"变量 vs 它指向的内存在哪"的翻版:
ch2:数组名在栈上。"abcd"被拷贝了一份放进数组,所以整个ch2数组住在栈上;*ch2:就是数组第一个字符'a',它是数组的一部分,所以也在栈上;pCh3:指针变量本身在栈上;*pCh3:是它指向的那个字符串字面量字符,字面量"abcd"放在代码段(常量区),所以*pCh3在常量区。
可写性结论非常关键:ch2[0] = 'X' 合法——你改的是栈上那份自己的拷贝,不影响其他东西;pCh3[0] = 'X' 不合法(严格说是未定义行为)——它写的是只读常量区,多数机器上会直接段错误。所以课件特意给 pCh3 加了 const,正是编译器的善意提醒。
2. new int、new int()、new int(10)、new int{20} 的初值分别是?new int[3]() 三个元素呢?
| 写法 | 初始化方式 | 结果 |
|---|---|---|
new int | 默认初始化 | 不确定的垃圾值 |
new int() | 值初始化 | 0 |
new int(10) | 直接初始化 | 10 |
new int{20} | 列表初始化(C++11 起) | 20 |
new int[3]() 走的是值初始化,三个元素全部是 0;而 new int[3](不带括号)三个都是垃圾值。记忆口诀就一句:内置类型想拿到干净的确定初值,就给它补一对空括号/空花括号。
3. 为什么 realloc 的返回值必须临时指针接住再判断?失败时会释放原指针吗?
因为 realloc 失败时返回 NULL,但它不会释放原指针——原内存仍然有效、还在指向原来的数据。如果贪省事直接 p = realloc(p, newsize):
- 成功:旧空间要么原地扩大、要么已被搬迁释放,
p更新为新地址,没问题; - 失败:
realloc返回NULL并把它赋给p,于是原指针的地址被覆盖丢失,原内存再也找不回来,既没法用也没法释放,当场泄漏。
所以正确姿势永远是先交给一个临时指针验一验:
int* tmp = (int*)realloc(p, newsize);
if (tmp) p = tmp; // 只有成功才更新 p
else /* 失败:p 仍指向原内存,可继续用或手动 free(p) */;另外注意:realloc 成功时旧指针不用也不能再手动 free(它要么已被原地管理、要么已被搬到新地址释放,再 free 就是 double free)——这正是课件里"要不要 free 旧 p2"那句提问的答案。
4. new/delete 与 malloc/free 的六点区别?"new 底层调用了 malloc"成立吗?
六点区别(正文已有表格,这里压缩成速记):① 函数 vs 操作符;② malloc 不初始化 vs new 可初始化;③ malloc 手动算字节数 vs new 写类型即可;④ malloc 返回 void* 需强转 vs new 直接返回目标类型指针;⑤ malloc 失败返回 NULL 需判空 vs new 抛 std::bad_alloc 需捕获;⑥ 对自定义类型 malloc/free 不调构造/析构 vs new/delete 分别调用。
"new 底层调用了 malloc"这句话成立:全局 operator new 的内部实现就是在反复尝试调用 malloc,成功直接返回、失败先走用户设置的 new handler、仍然不行就抛 bad_alloc;operator delete 内部则调用 free。两者行为看起来不一样,正是因为在 malloc/free 之上,new/delete 额外叠加了"算准类型大小 + 调用构造/析构 + 失败抛异常"这几层——malloc 只是个只会开空间和还空间的"搬运工"。
5. new A[3] 配 delete p 为什么是未定义行为?cookie 是标准硬性规定吗?为什么内置类型数组"碰巧不崩"?
delete[] 要干两件事:逐个析构 N 个对象、从正确的内存起点释放整块空间。而它凭什么知道 N 和正确起点?靠编译器在返回指针前面偷偷塞的"数量前缀 cookie"。当你写成 delete p:
delete根本不知道存在 cookie,也不知道数组有多长,它把p当单个对象处理:析构次数对不上,而且它会从一个"错误的起点"去释放(真正的起点比p靠前那几字节),整块空间边界错乱 → 未定义行为。
cookie 是不是标准硬性规定?不是。标准只要求"delete[] 能正确反推元素个数",至于 cookie 放哪、占 4 还是 8 字节,完全由编译器的 ABI 实现决定(GCC/Clang 的 Itanium ABI、MSVC 各有约定)。之所以内置类型数组(如 new int[3])用 delete"碰巧不崩",是因为内置类型没有非平凡析构,编译器不需要逐个析构,通常不加 cookie,此时 delete 和 delete[] 的行为几乎等价——但这是侥幸,不是规范。规范永远只有一条:new 配 delete,new[] 配 delete[],绝不跨界。
6. 定位 new 分配内存吗?new (p) T[3] 对非平凡析构的类型有什么风险?std::vector 扩容借用了哪个机制?
定位 new 不分配内存。它只在调用者提供的一块现成地址上调用构造函数——operator new(size_t, void*) 的 placement 重载实现就是一个"原样把地址返回"的空壳,连分配器都不碰。它只管"构造",与之配套的"析构"和"释放"仍要你手动做。
new (p) T[3] 对带非平凡析构的类型是个坑:标准只对"单对象的 placement new"定义了明确语义,数组的 placement new 是实现定义的、常见编译器并不支持——它会尝试写 cookie、而你给的起点又不一定匹配,行为不可控。正确做法是 for 循环逐格 new (p + i) T(...) 构造,用完再逐个 p[i].~T(),最后释放整块原始内存。
std::vector 扩容正是借用了 placement new 这套"分配与构造解耦"的机制:reallocate 时先开一块能装下新容量的原始内存(只分配、不构造),再把旧元素通过 placement new 逐个拷贝/移动构造到新格子里,最后析构并释放旧块。理解了 placement new,读 vector 内部实现会豁然开朗。
7. 至少三种能抓内存泄漏/越界的工具
- Windows + MSVC CRT 泄漏检测:在
<crtdbg.h>、main末尾调用_CrtDumpMemoryLeaks(),调试输出会列出泄漏的内存块;再用_CrtSetBreakAlloc(n)可让程序在分配第 n 块处断住,方便定位泄漏点。(Visual Studio 自带的"诊断工具 / 内存诊断"也是同族能力。) - Linux/macOS + Valgrind:
valgrind --leak-check=full ./程序,它的 memcheck 能报出内存泄漏、非法读/写、二次释放(double free)等,是 Linux 下最经典的内存检测器。 - 跨平台 + ASan(AddressSanitizer):编译时加
-fsanitize=address(g++/clang,MSVC 用/fsanitize=address),运行时自动检测堆越界、use-after-free、泄漏等,性能开销低、上手极快,现代工程默认建议。
补充一句"治本"思路:工具负责"抓住漏",但更重要的是用 RAII + 智能指针(
std::unique_ptr/std::shared_ptr)或std::vector这类自带生命周期管理的容器,从根本上消灭"裸指针 + 手动配对"这种最易出错的模式——工具抓漏是止损,工具选择对是治病。
小结
从 C/C++ 的内存区域划分入手,我们明确了"变量在哪、它指向的内存在哪"是两码事;接着审视了 C 语言的 malloc/calloc/realloc/free 及其 glibc 底层原理;再进入 C++ 的 new/delete,看清了"分配 + 构造、析构 + 释放"的两步走,对比了和 malloc/free 的本质差异;又掀开 operator new/delete 的盖子,追到 new[]/delete[] 的数量前缀天坑,学习了用定位 new 在现成内存上构造对象;最后落到内存泄漏的检测(Windows 的 CRT 漏检、Linux/macOS 的 Valgrind 与跨平台的 ASan)与 RAII/智能指针这套根治武器。现在闭上眼睛回想:栈向下、堆向上、cookie、placement new、bad_alloc、unique_ptr……这些关键词在你脑子里应该已经有了一张清晰的地图。剩下的路,就是用纪律和工具让它成为本能。
还没有评论 — 第一条由你来留。