C++ 中 new[] 和 delete[] 必须配对使用:原理与深层原因
一、引言:一个看似简单却致命的规则
在 C++ 中,有一条铁律:new必须配对delete,new[]必须配对delete[]。违反这一规则会导致未定义行为(Undefined Behavior)——程序可能崩溃、内存泄漏、数据损坏,或者更糟:表面上正常运行但埋下了定时炸弹。
很多开发者知道这条规则,却不理解背后的原因。为什么编译器生成的默认代码不能正确处理这种错配?delete和delete[]在底层到底做了什么不同的事情?本文将深入 C++ 的对象模型和内存管理机制,彻底解释这一规则的根本原因。
二、核心结论速览
| 配对方式 | 结果 | 原因 |
| --- | --- | --- |
| new + delete | ✓ 正确 | 调用 1 次析构函数,释放 1 个对象大小的内存 |
| new[] + delete[] | ✓ 正确 | 调用 N 次析构函数,释放整块数组内存 |
| new[] + delete | ❌ 未定义行为 | 可能只调用 1 次析构、释放错误大小的内存 |
| new + delete[] | ❌ 未定义行为 | 可能调用多次析构、读取不存在的数组大小信息 |
三、new 和 new[] 在底层做了什么
3.1 单个对象:new 的工作流程
cpp复制下载
class MyClass { public: int data_; MyClass() { std::cout << "Constructor" << std::endl; } ~MyClass() { std::cout << "Destructor" << std::endl; } }; MyClass* p = new MyClass(); // 底层执行了两个步骤: // 1. void* raw = operator new(sizeof(MyClass)); // 分配内存 // 2. MyClass* p = new(raw) MyClass(); // 在分配的内存上构造对象3.2 对象数组:new[] 的工作流程
cpp复制下载
MyClass* arr = new MyClass[5]; // 底层执行了更多步骤: // 1. void* raw = operator new[](5 * sizeof(MyClass) + 数组开销); // 2. 在前几个字节存储数组元素个数(通常为 size_t,8 字节) // 3. 对每个元素依次调用构造函数图表代码下载全屏关键点:new[]返回的指针指向第一个元素,而不是内存块的起始位置。在这之前,通常有一个隐藏的头部存储了数组元素的数量。
3.3 数组开销的验证
cpp复制下载
#include <iostream> class Counter { static int alive; public: Counter() { ++alive; } ~Counter() { --alive; } static int count() { return alive; } }; int Counter::alive = 0; int main() { // 分配数组 Counter* arr = new Counter[10]; // 查看实际分配的内存大小 // arr 前面的 8 字节存储了数组长度 size_t* countPtr = reinterpret_cast<size_t*>(arr) - 1; std::cout << "Array element count: " << *countPtr << std::endl; // 10 std::cout << "sizeof(Counter): " << sizeof(Counter) << std::endl; // 1 delete[] arr; std::cout << "Alive after delete[]: " << Counter::count() << std::endl; // 0 }四、delete 和 delete[] 在底层做了什么
4.1 delete 的工作流程
cpp复制下载
delete p; // 底层执行了两个步骤: // 1. p->~MyClass(); // 调用析构函数(1 次) // 2. operator delete(p); // 释放内存(sizeof(MyClass) 字节)4.2 delete[] 的工作流程
cpp复制下载
delete[] arr; // 底层执行了更多步骤: // 1. 读取 arr 前面的数组长度 N // 2. 从后向前依次调用析构函数(N 次) // for (size_t i = N; i > 0; --i) { // arr[i - 1].~MyClass(); // } // 3. operator delete[](真实起始地址); // 释放整块内存图表代码下载全屏
五、错配导致的灾难性后果
5.1 用 delete 释放 new[] 分配的内存
cpp复制下载
#include <iostream> class ResourceHolder { int* data_; public: ResourceHolder() : data_(new int[100]) { std::cout << "Constructor: allocated 100 ints" << std::endl; } ~ResourceHolder() { delete[] data_; std::cout << "Destructor: freed 100 ints" << std::endl; } }; int main() { std::cout << "=== 正确配对 ===" << std::endl; ResourceHolder* arr = new ResourceHolder[3]; delete[] arr; // ✓ 正确:调用 3 次析构,释放完整内存块 // 输出:每个 ResourceHolder 都正确释放了其内部的 100 个 int std::cout << "\n=== 错误配对 ===" << std::endl; ResourceHolder* arr2 = new ResourceHolder[3]; delete arr2; // ❌ 错误!只调用 1 次析构,内存泄漏 + 可能崩溃 // 后果: // 1. 只调用了 arr2[0].~ResourceHolder()(arr2[1] 和 arr2[2] 未析构) // 2. 每个 ResourceHolder 内部分配的 100 个 int 有两个泄漏 // 3. operator delete 可能释放了错误大小的内存 // 4. 程序可能在此处崩溃(或更晚,难以排查) }5.2 用 delete[] 释放 new 分配的内存
cpp复制下载
MyClass* p = new MyClass(); delete[] p; // ❌ 错误! // delete[] 会: // 1. 读取 p 前面的 8 字节,当作数组长度 // → 这是一块未定义的内存,可能是任意值(比如 999999) // 2. 尝试对 p[0] 到 p[999998] 调用析构函数 // → 大量越界析构,几乎必然崩溃 // 3. 计算错误的内存块起始地址 // → 释放了不属于它的内存5.3 错配后果总结
图表代码下载全屏
六、为什么有平凡析构函数的类型可能“碰巧”工作
cpp复制下载
// 内置类型:析构函数是平凡的(什么都不做) int* arr = new int[100]; delete arr; // 技术上未定义行为,但“可能”不崩溃 // 原因: // 1. int 没有析构函数,delete 不会做任何析构操作 // 2. 在这种情况下,delete 和 delete[] 在释放内存方面的行为碰巧一致 // (在大多数实现中) // 但这是未定义行为!依赖它是危险的: // - 不同编译器实现可能不同 // - 即使当前不崩溃,未定义行为会破坏编译器的优化假设 // - 未来编译器版本可能改变行为重要原则:即使对内置类型,也必须正确配对。这是正确性保证,不是可选优化。
七、自定义 operator new[] 中的配对验证
cpp复制下载
#include <iostream> #include <new> class Tracked { int id_; static int nextId_; public: Tracked() : id_(++nextId_) { std::cout << "Tracked(" << id_ << ") constructed" << std::endl; } ~Tracked() { std::cout << "Tracked(" << id_ << ") destroyed" << std::endl; } // 自定义 operator new[]:记录分配信息 static void* operator new[](size_t size) { std::cout << "operator new[]: allocating " << size << " bytes" << std::endl; void* ptr = ::operator new[](size); std::cout << " (includes " << sizeof(size_t) << " bytes for array count)" << std::endl; return ptr; } static void operator delete[](void* ptr) { std::cout << "operator delete[]: freeing array" << std::endl; ::operator delete[](ptr); } }; int Tracked::nextId_ = 0; int main() { std::cout << "sizeof(Tracked) = " << sizeof(Tracked) << std::endl; std::cout << std::endl; Tracked* arr = new Tracked[5]; // 输出: operator new[]: allocating 28 bytes // (8 bytes count + 5 * 4 bytes Tracked) std::cout << std::endl; delete[] arr; // 正确:调用 5 次析构 + operator delete[] // 如果用 delete arr: // 1. 只调用 1 次析构 // 2. 调用 operator delete(不是 operator delete[]) // 3. 传递的指针是元素地址,不是原始分配地址 → 严重错误 }八、最佳实践
8.1 现代 C++ 中避免手动 new/delete
cpp复制下载
// 不好的实践:手动管理数组 void oldStyle() { MyClass* arr = new MyClass[100]; // ... 使用 arr ... // 如果这里抛出异常,arr 泄漏! delete[] arr; } // 好的实践:使用智能指针和容器 #include <vector> #include <memory> void modernStyle() { // 使用 vector std::vector<MyClass> vec(100); // 或智能指针管理动态数组(C++14) auto arr = std::make_unique<MyClass[]>(100); // 自动管理生命周期,无需手动 delete[] }8.2 强制配对检查的编码规范
cpp复制下载
// 技巧一:在分配处立即配对 auto* arr = new MyClass[count]; // 一眼看到 new[] 和 count // ... delete[] arr; // 明确配对 delete[],与分配代码在同一个函数内 // 技巧二:使用 RAII 包装 template<typename T> class ArrayGuard { T* ptr_; public: explicit ArrayGuard(size_t n) : ptr_(new T[n]) { } ~ArrayGuard() { delete[] ptr_; } T* get() { return ptr_; } // 禁止拷贝 ArrayGuard(const ArrayGuard&) = delete; ArrayGuard& operator=(const ArrayGuard&) = delete; };九、总结
new[]和delete[]必须配对使用的根本原因可以归结为:
- 内存布局不同:
new[]在返回的元素指针之前存储了数组长度的元数据(通常为size_t,8 字节)。delete[]需要读取这个长度信息来知道要析构多少个元素。new分配的内存没有这个元数据。 - 析构调用次数不同:
delete[]读取数组长度后,会对每个元素调用析构函数(从后向前)。delete只调用一次析构函数。错配会导致析构次数不对——要么少析构(资源泄漏),要么多析构(未定义行为)。 - 释放的内存地址不同:
delete[]需要将指针回退到内存块的真正起始地址(包含元数据),然后释放整个块。delete直接释放传入的地址。错配会导致释放错误的地址或错误的大小。 - 即使对内置类型碰巧“工作”,也是未定义行为:依赖未定义行为会破坏编译器的优化假设,可能在看似无关的代码修改后崩溃。
- 现代 C++ 的最佳实践:使用
std::vector管理动态数组,使用std::make_unique<T[]>()管理动态数组智能指针。这些工具在内部正确处理了new[]/delete[]的配对,让你永远不需要手动写这对操作符。