new[] 配 delete 为何会崩?深入解析 C++ 数组释放的未定义行为
1. 先还原现场一段看着没问题却把堆炸掉的代码一个项目里最容易出现的“我知道它不对但不知道为什么不对”的问题new[]搭配delete绝对排得上号。很多 C 开发者在背规则的时候都听过“new[]必须配delete[]”但真到了接手一段老旧代码看到一个数组指针被普通delete释放第一反应往往不是直接改而是会犹豫编译器也没报错测试也跑过了它凭什么会崩我前阵子复查一个交接项目时就遇到类似写法。代码大概是这个样子struct Gadget { int id; Gadget() : id(nextId) {} ~Gadget() { std::cout destroy id \n; } static int nextId; }; int Gadget::nextId 0; Gadget* arr new Gadget[8]; delete arr; // 问题就出在这一行这段代码在 Release 版的小样本测试里可能跑得好好的Debug 版一跑立刻弹窗报错常见的输出是HEAP CORRUPTION DETECTED或者直接在一次free调用后崩溃。如果项目里还开了一些内存检查选项那么崩溃点甚至会直接定位到operator delete内部让人摸不着头脑。先说结论delete arr这个写法是未定义行为。它可能崩溃可能不崩也可能只是悄悄漏掉 7 个析构函数和一个堆块头。这个问题值得写一篇文章聊清楚因为只有理解了底层的分配布局你才能在遇到类似代码时果断动手而不是靠“试一下”判断对错。本文适合两类读者一类是刚接触 C 内存模型、想知道new[]背后发生了什么的人另一类是真的被堆损坏 bug 折磨过、想找出一条稳定排查路径的人。下面我会从分配机制、释放路径、相近的坑、以及实际排查手段一步步拆开讲。2. new[] 表达式偷偷做了两件事很多人以为new Gadget[8]只是“申请了 8 个 Gadget 大小的内存”返回一个指向第一个元素的指针。这句话在语义上没错但它忽略了实现层面的一个重要动作编译器可能在你拿到的指针前面额外藏了一份“小抄”。2.1 从内存申请到返回指针中间多了一张“记账小抄”为什么需要小抄因为delete[]必须知道这个数组到底有多少个元素。你可以在new的时候写下来但数组长度本身是运行时值编译器不能把它编码在代码里。它只能把元素个数记录在某块内存里等delete[]执行时再去读取。主流实现选择的位置是在真正数组元素之前的一小段空间里。这段空间在 C 社区通常叫 array cookie翻译成大白话就是“记账小抄”。内存布局大致是下面这个样子operator new[] 返回的内存基址 ------------------------------------ | cookie: n 8 | - 这段空间对用户代码不可见 ------------------------------------ | Gadget[0] | - new[] 最终返回给用户的指针 | Gadget[1] | | ... | | Gadget[7] | ------------------------------------也就是说new[]表达式最终交到你手里的指针并不一定是堆分配函数返回的原始基址。如果类型需要 cookie编译器会在原始基址上偏移一段再把偏移后的地址交给你。这个过程非常隐蔽你在源码里只看到一行Gadget* arr new Gadget[8];它背后却暗含了“申请总大小 cookie 大小 8 * sizeof(Gadget)”这件事。这也是为什么这类 bug 很难靠肉眼从崩溃堆栈里直接看出来。2.2 为什么只有带析构函数的类型才需要小抄看到这里你可能会问那new int[100]也有 cookie 吗通常没有。对于一个类型来说delete[]需要做的事情可以粗略拆成两部分一是对每个元素调用析构函数二是把内存还给堆管理器。对于int、double、指针这类内置类型析构是 trivial 的编译器根本不需要在delete[]时去调用什么代码元素个数也就不是必须记录的。真正的分水岭是“析构函数是否 trivial”。只要类有用户自定义的析构函数或者它的成员里有需要手动释放的资源那delete[]就必须知道你申请了多少个对象不然它没法决定该调用多少次析构。这个数量就是 cookie 存在的意义。一个常见的误解是“类里有析构函数delete 单个对象时也需要 cookie”。不是的单对象delete p只析构一次次数是写死在代码里的自然不需要在堆上额外记录。只有数组场景才会有“次数不确定”的问题。2.3 不同工具链下的 cookie 摆放并不统一既然 cookie 是实现细节不同平台的“小抄”位置和大小就会不一样。在常见的 64 位工具链上size_t通常是 8 字节cookie 经常就放在返回指针往前 8 字节的位置32 位下则可能是 4 字节。但你不要把“往前 8 字节”当成通用协议直接写进业务代码。有些元素类型自身有更强的对齐要求编译器可能还要在天头地脚上额外留 padding以保证第一个元素的起始地址满足对齐。也就是说cookie 到元素指针之间的偏移量不一定恰好等于sizeof(size_t)。如果网上某篇文章教你去读((size_t*)p)[-1]那只是为了验证原理不是让你在正式代码里这么写。标准只保证new[]配delete[]是正确的从没承诺 cookie 的摆放位置。试图主动去碰 cookie反而会把本来能跑的代码变成新的未定义行为。3. delete 和 delete[] 是两条完全不同的“拆建流水线”cookie 只解释了“指针偏移”的问题。真正让程序崩溃的是delete和delete[]在运行时走了完全不同的执行序列。它们不是同一个函数也不是同一个逻辑的两种叫法。3.1 delete p 的执行序列delete p用于释放单个对象时编译器生成的逻辑大致是// 概念伪代码不是真实源码 p-~T(); // 调用一次析构函数 operator delete(p); // 把 p 交给标量释放函数这里的关键是operator delete(p)收到的地址必须是operator new曾经返回的原始分配基址。堆管理器的账本上记着的是“从哪个地址开始、这一块多大、内存块头部在哪”。你交给它的地址一旦偏移过它就找不到对应的块头于是要么直接崩溃要么把别的块头当成这一块的头部造成堆损坏。你可能会说delete p里不是先调用了p-~T()吗如果p偏移了那析构函数不是也调在错误地址上了并不会。因为p确实指向第一个元素析构函数重载不会发现任何异常它正常销毁了第一个对象。问题是从operator delete(p)这一步开始错的。3.2 delete[] p 的执行序列delete[] p的逻辑就完全不一样了。编译器知道这是一个数组删除它需要先把小抄读出来拿到元素个数再从后往前依次析构最后释放原始基址。// 概念伪代码不是真实源码 size_t n cookie_before(p); // 读 p 前方的 cookie for (size_t i n; i 0; --i) p[i - 1].~T(); // 从尾到头调用析构 operator delete[]((char*)p - cookieSize); // 释放原始基址请注意两点。第一析构顺序是倒序的这和栈上数组的析构顺序一致是为了让后构造的对象先析构。第二传给operator delete[]的地址是“原始基址”不是p。因为只有原始基址才是分配器认识的那个地址。3.3 一次匹配错误的后果链当我们把new[]和delete配在一起时发生的事情就是两套流水线硬生生接错new[] 申请“cookie N 个元素”返回偏移后的地址 p delete调用 p 的析构一次再向 operator delete 传 p结果可以拆成三笔账一个是析构次数不对。数组里 N 个对象只有第一个被正常析构剩下 N-1 个如果持有文件句柄、锁、动态资源全部泄漏。这个泄漏比崩溃更隐蔽因为它不会立刻体现出来。第二个是释放地址不对。delete拿到的p不是堆块的原始基址直接传给堆管理器崩溃往往就在这一步发生。Debug 堆检查会非常敏感能立刻喊出“无效释放”Release 下可能撑到下一次分配时才发现相邻块头已经被写坏。第三个是释放函数也可能不匹配。数组形式有单独的operator delete[]普通形式有operator delete。如果代码里重载过这些全局分配释放函数哪怕你的类型没有 cookie、指针没有偏移两条链路的匹配错误也可能造成严重问题。下面是执行序列对比能看得更清楚写法析构调用次数交给释放函数的地址结果new T[N]delete p1 次p偏移后的地址堆错误 / 资源泄漏new T[N]delete[] pN 次原始分配基址正确new Tdelete[] p读取错误 cookie错误地址同样未定义行为3.4 那为什么它有时不崩这是这类 bug 最迷惑人的地方代码有时候真的能跑甚至跑很久都不崩。原因通常和 cookie 有关。如果数组元素类型是 trivial 析构的比如int数组编译器大概率不会在数组前放 cookie。此时new int[N]返回的指针和operator new分配回来的原始基址是同一个delete p传给堆管理器的地址没有偏移于是侥幸对上了。再加上标准库默认的operator delete[]往往内部会转调普通operator delete所以标量和数组两种释放函数在这种场景下路径一致代码就“运行正常”了。但运行正常不代表语义正常。C 标准明确把这种写法判为未定义行为。你今天测试的是int明天改成std::string后天有人重载了全局operator new[]同一个位置立刻炸出新问题。所以记住不崩是运气崩溃是必然换成谁来看都不该把运气当依据。4. 同样危险的近亲坑不是只有 new[] 配 delete 才会破事把new[]和delete这个问题讲清楚之后你会发现周围还有一串长得非常像的坑。它们不一定是直接写了new[]配delete但底层逻辑完全是一家人。我挑几个最常踩的出来。4.1 内置类型数组侥幸成功不是协议先看这个int* data new int[1024]; delete data; // 实测可能不崩但仍是未定义行为很多同学会觉得int又没有析构函数new[]和new区别不大我用delete释放怎么了问题在于new[]在形式上调用的是operator new[]delete在形式上调用的是operator delete二者只是在默认实现里可能落到同一个free上而已。如果把operator new[]或operator delete[]替换成自定义版本比如在分配返回时再加一层自己的头部信息那么delete data就完全对不上号。退一步说即使今天对你手上的工具链来说“没坏”这种代码也会在别人读代码时种下一颗雷后人不一定知道这里原本是数组分配很可能继续用普通delete释放其他对象。4.2 基类指针 delete[] 派生类数组虚析构也救不了另一个更隐蔽的写法是class Animal { public: virtual ~Animal() {} }; class Dog : public Animal { public: ~Dog() {} }; Animal* pets new Dog[3]; delete[] pets; // 仍然是未定义行为有些人觉得反正Animal有虚析构函数delete[] pets应该能正确调用Dog的析构吧很遗憾数组删除不支持像单对象删除那样做动态类型分派。delete[]处理的是“静态类型的数组”它按Animal的维度去处理元素大小和析构行为而数组元素的实际类型却是Dog两者对不上。这也解释了为什么“用基类指针管理一组派生类对象”的正确姿势永远是容器加智能指针比如std::vectorstd::unique_ptrAnimal。每个元素是堆上独立的派生类对象容器的元素只是一些指针删除时按指针指向的真实类型正确析构。4.3 placement new[] 的隐藏记账连 buffer 都可能不够还有一种场景代码里没有直接写new[]用的是 placement new照样踩中 cookie 的暗坑void* buffer operator new(sizeof(Gadget) * 3); Gadget* arr new (buffer) Gadget[3]; // 隐患当Gadget有非平凡析构函数、实现需要在数组前放 cookie 时placement new 表达式同样会试图在 buffer 前写入 cookie。可是你分配的 buffer 只留了“3 个 Gadget”的空间cookie 没地方放于是多写出来的几字节就溢出了缓冲区。这种越界往往检测工具都很难当场抓住等到堆块被重新分配时才爆雷。更稳妥的做法是放弃 placement new 数组改成逐元素 placement new并在结束时逐个析构Gadget* arr new (buffer) Gadget[0]; // 不这么写 // 正确做法手动循环构造 for (int i 0; i 3; i) { new (buffer i * sizeof(Gadget)) Gadget(); } // 结束时倒序析构 for (int i 2; i 0; --i) { arr[i].~Gadget(); }如果只是因为数组动态长度直接用std::vectorGadget会干净得多。4.4 RAII 与智能指针也可能放大错误很多人以为用了智能指针就不会踩new[]的坑其实不一定。看这两个写法std::unique_ptrGadget bad(new Gadget[8]); // 默认 deleter 会 delete std::unique_ptrGadget[] good(new Gadget[8]); // 正确第一行的std::unique_ptrGadget在析构时默认调用delete它根本不知道指针来自new[]。问题被 RAII 遮住了但从崩溃逻辑上看和手写delete p完全一样。如果你用std::shared_ptr接管裸数组就得显式给出正确的删除器std::shared_ptrGadget sp(new Gadget[8], [](Gadget* p) { delete[] p; });但更推荐的做法是动态数组尽量直接交给std::vector。它内部自己负责new[]/delete[]的匹配不会给你手滑的机会。5. 从崩溃到修复我的排查链路和最终建议最后这部分写给正在排查堆崩溃的人。如果有一天你也遇到报错定位在释放函数里、但业务代码看起来“很正常”的情况可以按下面这条链路走别一上来就乱加delete[]。5.1 第一步先确认数组来源和删除形式拿到崩溃调用栈后先不要急着改代码。用 IDE 的全局搜索或 grep 找到这个指针的分配点重点看两件事。第一分配处是不是new T[...]。如果是那对应的删除必须是delete[]这是一个硬约束。第二这个指针有没有经过二次包装。比如裸指针被塞进shared_ptr、自定义 RAII 类、或者某个工厂函数返回后又被reinterpret_cast都会让“分配”和“释放”看起来不像一组配对。我在实际排查中发现很多new[]配delete的问题不是同一行代码写出来的而是经过了中间层的“传承”。某个工厂函数返回了一个数组指针调用方不知道内部是数组随手delete掉了。这时候光看崩溃行永远想不通返回去看工厂函数才能看到真相。5.2 第二步给 operator new[] 装一个临时“GPS”如果怀疑是分配基址和返回指针不一致有一个非常直观的验证方法临时重载全局的operator new[]和operator delete[]把原始基址打出来。只用于排查千万别留在正式代码里。#include cstdlib #include iostream void* operator new[](size_t n) { void* p std::malloc(n); std::cerr new[] base p size n \n; return p; } void operator delete[](void* p) noexcept { std::cerr delete[] base p \n; std::free(p); }然后在你怀疑的代码前后打印用户拿到的指针Gadget* arr new Gadget[3]; std::cerr arr arr \n; delete arr; // 或者你正在执行的删除形式如果arr的地址比new[] base大且刚好差了一个 cookie 大小的数量那真相就很明显了释放函数收到的是偏移后的地址堆管理器自然认不出它。这一步不需要读反汇编也能把这个 bug 锁死。5.3 第三步用检测工具和安全模式交叉验证在 Windows 平台的原生工具链下打开 CRT 调试堆检查通常能更快暴露问题。开检查后堆块前后会被放入保护区域任何偏移释放或越界写都会在离真正出错位置更近的地方报警。类 Unix 环境下可以开地址消毒检测或者用内存检测工具跑一遍。这类工具对“无效释放”“分配到任意指针”“堆缓冲区溢出”非常敏感只要你的复现路径能跑到错误代码它大概率会直接报出invalid free并附带分配栈和释放栈。拿到两个栈之后是不是new[]配delete就一目了然了。如果还想往底层确认可以看反汇编。在new[]表达式返回前你会看到一条类似“往raw - cookieSize写入元素数量”的指令在delete[]调用前则会有“读取 cookie 从后往前析构”的循环。看到这些汇编指令就不会再觉得内存管理是玄学了。5.4 根治方案把裸数组的生存周期交给更安全的容器排查完成之后修复思路不是“把这里的 delete 改成 delete[]”就结束了。我不建议在一堆裸指针代码里修一个是一个因为下一次可能还有另一处漏网。现代 C 的默认答案应该始终是容器和智能指针。如果数组大小是运行期才知道的首选std::vectorstd::vectorGadget gadgets(8);如果你需要和 C 接口交互也不要掏个裸数组过去用vector.data()就行std::vectorint data(n); some_c_api(data.data(), n);如果确实要管理一个固定大小的动态数组用std::unique_ptrT[]也比裸数组安全auto arr std::make_uniqueGadget[](8);它天然知道用delete[]释放不需要你在每个析构路径上小心维护。真到了必须裸内存的地步也请在同一个模块里写清楚“分配方式是 new[]释放方式必须是 delete[]”最好封装成一个独立的 RAII 类让别人没有机会写错。我个人在实际操作中的体会是这类内存 bug 最可怕的不是崩溃而是“偶尔不崩”。不崩的时候你会产生一种错觉觉得规则没那么严格结果在代码里留下越来越多不可控的假设。把裸数组交给容器之后程序不仅稳定了Review 代码时也不用再提心吊胆去找每一行new[]的搭档是谁了。