写这篇文章的起因有点不好意思说——上周我在 code review 里看到同事写了一个delete p;紧接着下一行还惦记着用p去做判空。我当时叹了口气C 江湖里 new 和 delete 这对组合看起来就俩关键字实际上暗坑比想象的多。今天不打算讲高深的内存池或者 allocator也不想只停留在“new 了就要 delete”这种正确废话上而是想从我这些年实际踩过的坑、看过的崩溃堆栈出发把 new 和 delete 的底细和常见翻车方式掰开揉碎讲清楚。如果你已经写了几年 C但偶尔还是要靠运气来决定程序崩不崩这篇适合你如果你是刚入行的新手这篇也能帮你建立一套比“语法正确”更重要的内存管理直觉。其实很多语言里都有newJava 里的new Scanner(System.in)JavaScript 里的new Date()但 C 的new是其中最特别的一个它在堆上分配内存把内存和对象的生命周期控制权直接交到你手上。这份“自由”用不好就是崩给你看的未定义行为。这篇文章里说的所有内容也都是围绕这份自由展开的——怎么用、怎么辨析以及怎么在自己失控之前及时收手。1. new 和 delete 的表面功夫与真实流程1.1 从一段最朴素的用法说起先看一段几乎所有 C 教程都会写的代码class Config { public: Config() { version 1; std::cout Config constructed std::endl; } ~Config() { std::cout Config destroyed std::endl; } int version; }; Config* cfg new Config(); // 打印 Config constructed // ... 使用 cfg ... delete cfg; // 打印 Config destroyed这段代码语法上没有任何问题。但我想让你停下来想想一个问题new Config()到底做了什么如果只说“在堆上分配了一块内存、然后调用了构造函数”那确实是对的但这是九十年代末的理解水平。现代 C 里这一句背后至少拆成三步operator new分配足够的原始内存然后在那个内存地址上调用构造函数最后返回一个类型化的指针。delete则是反向的两步先调用析构函数再调用operator delete释放原始内存。为什么要拆成两步因为构造函数和内存分配本质上是两件事。内存分配可以由全局operator new完成也可以由类内部的operator new重载完成构造函数则负责把这块“还没人管的生内存”变成一个有完整不变量invariant的对象。反过来析构函数要把对象内部持有的资源文件句柄、锁、堆指针全部清理干净然后才轮到operator delete把这块内存还给堆管理器。理解这两步分解是后面所有排查工作的起点。1.2 new 背后究竟干了什么很多人会问new和malloc到底什么关系简单说C 的operator new内部通常就是调用malloc来完成实际的内存分配但它和malloc有两个本质差异malloc只分配裸内存不会构造任何对象new会在分配内存成功后立即构造对象如果构造过程中抛出了异常它会自动调用operator delete把已经分配的内存释放掉防止构造失败导致内存泄漏。这个“构造失败自动释放”的行为值得多说一句。假如Config的构造函数在执行到一半时抛了异常那new表达式不会返回指针给你它会负责把分配器已经拿到的内存块还回去。很多人不知道这一点喜欢在自己代码里手动try { p new T; } catch (...) { 手动释放; }其实全局operator new已经帮你兜底了。当然如果类对象内部还分配了别的资源构造函数里就要自己做好回滚那是 RAII 的范畴后面再展开。从分配器的视角看new一次涉及的类型信息也比malloc多。malloc(1024)只告诉你给我 1024 字节new int[10]不仅隐含了10 * sizeof(int)的字节数还隐含了对每个int做默认初始化的语义。这就是为什么int* a new int;和int* b new int();不一样——前者是未初始化后者是值初始化为 0。这种差异在日常业务代码里经常被忽略一旦开了-Wuninitialized或者用 MemorySanitizer就是一堆报错。1.3 delete 的反向流程delete p;看起来是个动作其实也分两步先调用p-~T()把所有可能需要清理的东西清掉然后调用operator delete归还内存。注意这里的前提是p必须来自new T。如果你把栈对象的地址拿去delete第一步“调用析构函数”可能还能侥幸完成第二步“释放内存”几乎肯定会炸——因为malloc分配的堆块管理信息和栈地址完全对不上。我见过最经典的误用是在某个回调函数里把this指针拿去delete了。有些老的异步框架确实会设计成“对象自己销毁自己”即delete this但这要求你非常清楚当前对象的生命周期这个对象必须是new出来的、当前代码路径是它最后一个使用者、析构函数里不能访问任何可能已经被其他线程释放的资源。delete this本身合法但你得保证没有任何人还在用这个指针。但凡有个回调晚了一拍就是悬空指针。还有一点经常被忽略delete一个经过static_cast或reinterpret_cast转出来的指针行为绝大多数情况下是未定义的。原因是类型转换可能改变指针的数值多重继承、虚继承下尤其如此而delete需要把原始指针值传回给operator delete。如果给delete传了一个偏移后的地址堆管理器会直接把这块内存判死刑。所以日常开发里谁分配的就由谁用同一个指针身份来释放是一条铁律。2. new[] 和 delete[]数组形式最容易埋雷的一对2.1 为什么 delete[i] 不行不是 delete[] 必须配对数组版本的new[]和delete[]是 C 初学阶段最著名的暗坑。你可能会想new int[10]分配了 10 个 int释放的时候为什么一定要写delete[] arr直接delete arr不行吗答案是不行因为编译器需要在运行时知道“这块内存到底是个对象还是对象数组”。如果是对象数组它还需要知道“数组里到底有几个元素”这样才能逐个调用析构函数。这个元素个数信息存在哪通常存在分配的内存块头部附近也就是所谓的数组长度 cookiearray cookie。new[]实际申请的内存大小往往是数组元素占用空间 额外记录的 cookie 空间。delete[]会去读那个 cookie拿到元素个数n然后对每个元素调用析构函数最后再释放整块内存。而delete没有方括号根本不会去读 cookie它默认这块内存是一个单一对象。如果你的类型有非平凡的析构函数delete可能只调用第一个元素的析构函数剩下 9 个元素的析构函数全部漏掉然后整块内存被释放。更致命的是有些编译器把 cookie 放在返回指针的前面delete把arr指针直接传给free时cookie 区域根本不在堆管理器的预期位置于是崩溃、堆损坏就来了。2.2 不配对会怎样从“侥幸没崩”到“线上事故”你可能会问为什么有时候new[]配delete没崩因为对于int、double、char这种平凡可析构类型trivially destructible编译器根本不生成析构调用cookie 也未必记录。此时delete[]和delete在二进制层面可能等效于是你侥幸跑了很多天。但一旦把元素类型换成std::string或者换了编译器、开启了调试堆问题就会在某个深夜爆发。我参与过一个 Windows 桌面的项目某个模块把delete[]写成了delete而且元素类型恰好是个自定义结构体里面带std::vector成员。本地 debug 跑半天没事结果客户机器上打开某个导出报表就随机崩溃。用 WinDbg 看崩溃堆栈经常是在free()内部后面跟的分配调用栈却显示这块内存来自new[]。当时定位法很简单——把所有delete和delete[]全部替换成匹配版本再跑压力测试崩溃就消失了。这种问题你光看代码逻辑是看不出来的因为它已经是堆布局层面的损坏。顺带提醒也有人喜欢用typedef int MyArray[10];然后new MyArray这种用法本质上是new int[10]释放时同样要delete[]别被类型别名骗了。2.3 谁在替你管数组长度实测验证如果你好奇 cookie 到底长什么样可以在自己的环境里做个测试。下面这段伪代码展示的是大多数编译器的实现思路不同平台细节有差异但逻辑大体如此// 以 MSVC 为例伪码非标准规定 void* operator new[](size_t count) { size_t* header (size_t*)malloc(count sizeof(size_t)); *header count; // 记录分配的字节数或元素个数 return header 1; } void operator delete[](void* p) noexcept { size_t* header (size_t*)p - 1; free(header); }从这里就能理解为什么delete[]必须拿到原始返回指针因为 cookie 在返回指针的前面偏移一位就是。如果你对数组指针做了arr 5再拿这个地址去delete[]读取 cookie 时位置错乱整个堆管理器的元数据都可能被破坏。所以delete[]操作的目标必须是指向起始元素的同一个指针这和delete单对象版本的要求是一致的原始指针身份不能变。3. 悬空指针、内存泄漏与双重释放三个最常见的翻车现场3.1 悬空指针delete 之后的“定时炸弹”悬空指针指的是“指针指向的对象已经被销毁但指针本身还保留着那个地址”。它的危险性在于你不再拥有这块内存可地址上的内容也许还没被覆盖代码看起来还能继续工作一阵子等堆管理器把这块内存又分给了别的对象你的“死指针”就会开始踩踏别人的数据。很多人有个误解以为delete p; p nullptr;就安全了。置空确实能阻止你对p本身发起第二次delete也能让if (p ! nullptr)的检查“起作用”但它无法阻止另一个早就拷贝了同一地址的指针q继续为非作歹。也就是说delete之后除了立刻置空还得保证没有其他指针副本流传在外。这一点在成员函数里尤其危险你delete this之后再去访问成员变量编译器不报错运行起来就是地狱。悬空指针的另一个来源是“作用域逃逸”。比如一个局部函数里new了一个对象把地址塞进全局容器函数结束前delete了它容器里却还留着这个地址。我的建议是不要在函数内部把“分配”和“释放”分到两个完全无关联的模块里。如果你必须这样做请立刻使用智能指针标记所有权让编译器帮你追踪生命周期。3.2 内存泄漏new 了不 delete 的慢性病内存泄漏比悬空指针隐蔽得多。程序不会立刻崩只是在某个循环里每次new都不delete内存占用像体温计一样缓缓上升直到 OOM 被系统杀掉。业务服务的表现通常是“运行几天后越来越慢重启后恢复”这种问题最容易被人归咎于“网络抖动”或者“服务器状态异常”。我见过最离谱的泄漏场景是每解析一条消息就new一个临时对象放到std::list里但代码只有在满足某个业务条件时才会把它pop并delete。正常情况下这个条件总能满足于是线上一直稳定某天上游改了一个字段格式条件不再成立list 无限增长。最后用 valgrind 一查泄漏点非常清晰但根因是“业务分支里漏了释放”。所以应对泄漏不能只靠“记得 delete”而是要尽量让分配和释放出现在同一个函数、同一个类、同一个 RAII 生命周期内让结构保证正确而不是靠人肉记忆。3.3 双重释放所有未定义行为里的“代表人物”双重释放比悬空指针更刺激。它是两个指针指向同一块堆内存第一次delete把内存还给了堆管理器第二次delete又拿同一地址去释放。此时堆管理器的空闲链表已经被改动过第二次释放会把正在使用的元数据再次标记为空闲破坏整个堆的完整性。崩溃往往不在第二次delete那行而在稍后某个毫不相干的malloc或new上。双重释放的经典场景是“浅拷贝”class Buffer { public: Buffer(int size) : data(new char[size]), length(size) {} ~Buffer() { delete[] data; } // 没写拷贝构造函数 / 拷贝赋值 char* data; int length; }; Buffer a(1024); Buffer b a; // b.data 和 a.data 指向同一块内存 // a 和 b 析构时对同一块内存调用两次 delete[]直接双重释放解决办法无非三选一写正确的拷贝构造函数做深拷贝、把拷贝构造函数删除改成只能移动、或者直接用std::vectorchar。但很多人在项目里面对这种类第一反应是“我加个拷贝就行”结果陷入了手动管理new/delete的泥潭。这就是为什么现代 C 社区如此强调值语义和容器——大多数情况下你根本不需要自己写new。4. 从裸指针到现代替代方案什么时候不该用 new4.1 这些年我们用 new 解决的问题其实有更稳的答案我最早写 C 是拿它当带类的 C 用到处都是new和delete。后来代码量上来发现 80% 的new都是可以避免的。比如“在堆上创建一个对象并传到别处”你可以直接返回一个值让返回值优化帮你把拷贝省掉比如“创建一个动态数组”std::vector就是现成的答案它自己管理new[]/delete[]的生命周期再比如“保存一批状态对象”std::map、std::string这类容器已经把资源管理封装好了根本不需要你亲手写释放逻辑。这里我建议形成一个思维习惯默认值语义而不是指针语义。一个对象能直接在栈上声明就直接Config cfg;用需要在容器里存就存vectorConfig而不是vectorConfig*。值语义的好处是生命周期完全由作用域决定作用域结束就析构不需要人去追。只有在对象很大、需要避免频繁拷贝或者所有权需要跨作用域转移的时候才引入指针而且优先std::unique_ptr。4.2 unique_ptr 和 shared_ptr 的正确打开方式现代 C 里裸new的替代方案主要就两个std::unique_ptr和std::shared_ptr。前者表达“唯一所有权”——这块内存只有一个主人主人析构它就释放后者表达“共享所有权”——通过引用计数决定何时释放最后一个持有者析构时才释放。从实操角度我建议默认用std::unique_ptr。它的开销和裸指针几乎一样语义清晰而且你可以用std::move明确地转移所有权std::unique_ptrConfig createConfig() { auto cfg std::make_uniqueConfig(); cfg-version 2; return cfg; // 移动转移所有权不会发生拷贝 } auto c createConfig(); // 不需要手动 deletec 离开作用域时自动释放std::shared_ptr的引用计数本身是线程安全的但指向的对象的线程安全还是得你自己保证另外循环引用会让引用计数万年不为零内存照样泄漏这种情况常常需要std::weak_ptr打破环路。我要特别提醒的是别因为shared_ptr写起来省事就到处用。在不需要共享所有权的地方叠一个shared_ptr会造成“谁都不能确定自己该不该负责销毁”的局面反而让生命周期变得更模糊。先想清楚所有权模型再选智能指针顺序不能反。4.3 少数真正需要 new 的场景说“别用裸 new”并不代表new就该被扫进垃圾堆。我目前还会写裸new的场景主要有三种嵌入某个生命周期管理框架里框架明确要求指针由它接管。比如一些 GUI 框架的addChild(new Button(...))父子关系决定了析构顺序框架会负责释放你只需要遵守约定。性能敏感、需要自己实现内存池或对象池的地方。此时你往往重载了类的operator new或者分配的对象数量巨大、需要精确控制分配时机。和 C 库或老代码交互对方接口就是返回裸指针或者要求传裸指针你只能在最外层接管这层原始语义。即便在这些场景里我的做法也是让new和对应的delete出现在同一个模块甚至同一个函数里或者用自定义的 RAII 包装类挡在前面。裸new可以存在但它不该裸露在业务逻辑的每个角落。5. 排查 new/delete 问题的实用工具与工作习惯5.1 工欲善其事valgrind 与 AddressSanitizer排查new/delete相关的崩溃和泄漏最省力的工具是 AddressSanitizerASan和 valgrind。ASan 是编译期插桩的工具GCC/Clang 都支持用法一般是编译时加-fsanitizeaddress然后直接跑测试。它对“栈缓冲区越界、堆越界、use-after-free、双重释放”这类问题的报告非常精确能直接告诉你崩溃发生在哪个文件哪一行以及哪个位置曾经释放过这块内存。我过去定位一个悬空指针花了两天上 ASan 后五分钟就找到根因。valgrind 的memcheck工具也是经典选择优势是不需要重新编译缺点是程序运行会慢 10 到 50 倍。我的经验是平时单元测试直接开 ASan它快、准、和 CI 兼容性好遇到线上 core dump 或者那些无法轻易复现的诡异问题再上 valgrind 跑一套完整回归看“definitely lost”分段重点过滤“still reachable”——后者通常是全局单例等合法持有不一定真泄漏。工具输出的关键信息是第一看“这是 read/write 还是 invalid free”第二看分配调用栈和释放调用栈。越界读写的崩溃地址不一定是当前指针的地址ASan 会把“被访问的内存属于哪个分配块、该分配块在哪分配的”都快照出来。我在看这类报告时从来不会只看崩溃当前栈因为大部分内存错误都是“作案”和“案发”分离的。5.2 Windows 下的 VS 调试技巧Windows 上 Visual Studio 自带一套 CRT 调试堆机制new/delete对应的malloc/free都会在 debug 构建下多带上调试填字节信息。最常用的几个开关#define _CRTDBG_MAP_ALLOC #include crtdbg.h int main() { _CrtSetBreakAlloc(123); // 在编号为 123 的分配处下断点 _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDOUT); // ... 正常逻辑 ... _CrtDumpMemoryLeaks(); // 程序快结束前调用输出泄漏报告 }当你发现泄漏报告里有一堆{123}这样编号的分配块把_CrtSetBreakAlloc(123)放到分配发生之前然后重新跑调试器会在那一行分配处停下你就能看到完整的调用栈而不是只看到一个指向crtdbg.h内部函数的长堆栈。这是 Windows 平台上定位泄漏最直接的手段前提是工程里的new大多走的是 CRT 的operator new别在项目里偷偷替换了全局new否则报告会失真。5.3 让 bug 少一半的编码习惯工具再强也只是补救。真正减少new/delete问题的是一些朴素的编码习惯我自己已经坚持了很多年new和delete写在同一个可见范围。如果做不到那释放部分必须被一个名字清晰的函数封装并在调用处留下注释。delete 之后立刻把指针置 nullptr然后不要再复制这个指针。更好的做法是直接让指针离开作用域。构造函数里new的成员基类析构函数必须声明为virtual否则你拿着Base*去delete一个Derived对象时派生类的析构函数根本不会被调用资源就漏了。我特别想说最后一个习惯。很多 C 教程会演示“基类析构函数要 virtual”但没解释它在new/delete上的必要。delete一个Base*时如果析构函数非虚编译器按静态类型调~Base()派生类部分的析构逻辑被跳过。这在内存上可能只是漏了一两个delete或者unique_ptr的析构但结果就是资源泄漏而不是崩溃非常阴险。凡是继承体系里的对象分配在堆上基类析构函数就得 virtual没有例外。最后再补一个小经验当你看到valgrind或 ASan 报告里出现大量 “Address 0x... is N bytes inside a block of size ... freed” 这样的字样不要急着去怀疑“编译器是不是有问题”。99% 的情况是你的程序提前释放了这块内存或者释放后又访问了它。把报告里的分配栈和释放栈拼在一起顺着时间线读一遍基本上就能还原案发现场。我排查过的内存问题里几乎每一次都是自己的代码逻辑漏洞没有一次是工具环境的问题。所以心态要放平——先相信工具再排查自己这样才能用最短的时间把问题摁住。