动态内存:new、delete 及其危险
学习动态分配如何通过 new 和 delete 工作,了解内存泄漏、双重释放和释放后使用的危险,并发现为什么现代 C++ 应该包含零个裸 new/delete。
使用 new 和 delete 进行动态分配
栈快速但大小和作用域有限。当你需要超出其创建作用域的对象,或其大小在编译时未知时,你需要在堆(自由存储区)上分配。
new T(args) 为一个 T 分配内存,调用其构造函数,并返回 T*。delete p 调用析构函数并释放内存。对于数组:new T[n] 和 delete[] p。将 new 与 delete[] 或 new[] 与 delete 混用是未定义行为 ── 编译器不会捕获这个错误。
new、delete 及其数组变体
这是原始分配 API。学习它以便你能在遗留代码中识别它,但永远不要在新代码中这样写 ── 使用智能指针代替。
#include <iostream>
#include <cstring>
struct Widget {
int id;
Widget(int i) : id(i) { std::cout << "Widget " << id << " created\n"; }
~Widget() { std::cout << "Widget " << id << " destroyed\n"; }
};
int main() {
// Single object
Widget* w = new Widget(1);
std::cout << "Using widget " << w->id << '\n';
delete w; // destructor called, memory freed
// Array
Widget* arr = new Widget[3]{{10}, {20}, {30}};
for (int i = 0; i < 3; ++i)
std::cout << arr[i].id << ' ';
std::cout << '\n';
delete[] arr; // MUST use delete[] for new[]
// UNDEFINED BEHAVIOR — don't do these:
// delete arr; // wrong: used delete instead of delete[]
// delete[] w; // wrong: used delete[] instead of delete
}定位 new
定位 new在特定内存地址构造对象而不进行分配。它被分配器、内存池和容器(如 std::vector)内部使用。你必须显式调用析构函数 ── 对定位 new 创建的对象不能使用 delete。
#include <iostream>
#include <new> // required for placement new
#include <cstddef> // std::byte
#include <memory> // std::align
struct Sensor {
int id;
double reading;
Sensor(int i, double r) : id(i), reading(r) {
std::cout << "Sensor " << id << " init\n";
}
~Sensor() { std::cout << "Sensor " << id << " shutdown\n"; }
};
int main() {
// Pre-allocate raw storage (aligned)
alignas(Sensor) std::byte buffer[sizeof(Sensor)];
// Construct in pre-allocated memory
Sensor* s = new (buffer) Sensor(42, 3.14);
std::cout << "Reading: " << s->reading << '\n';
// Must destroy explicitly — do NOT use delete
s->~Sensor(); // explicit destructor call
// delete s; // WRONG — memory was not allocated by new
}三大致命内存 bug
裸 new/delete 使你暴露于三类 bug:
1. 内存泄漏 ── 使用 new 分配但从未调用 delete。内存永远不会返回给操作系统(直到进程退出)。在长时间运行的服务器中,这最终会耗尽所有可用 RAM。
2. 双重释放 ── 对同一个指针调用 delete 两次。第二次 delete 会破坏堆分配器的内部数据结构,通常导致在无关代码中很久之后才崩溃。这极难调试。
3. 释放后使用 ── 在 delete 之后访问内存。该内存可能已被重新用于其他分配,因此你可能在读写另一个对象的数据。这是一个安全漏洞(CVE 级别的 bug)。
考虑:f(new A(), new B())。编译器可能以任何顺序求值参数,并且可能交错它们:
1. 为 A 分配内存
2. 为 B 分配内存
3. 构造 A
4. 构造 B
如果第 4 步抛出异常,A 就泄漏了 ── 没有人调用 delete。即使 f(std::shared_ptr(new A()), std::shared_ptr(new B())) 在 C++17 之前也有这个问题(C++17 保证每个参数在下一个之前被完全求值)。修复方法:始终使用 std::make_unique 或 std::make_shared ── 它们将分配和构造作为一个单一的、异常安全的操作执行。
自定义分配器概述
默认的 new/delete 使用全局分配器(通常是 malloc/free),它是通用的但对所有模式来说并非最优。自定义分配器提供:
- 区域/线性分配器 ── 递增指针,一次性全部释放。适合游戏中的逐帧数据。
- 池分配器 ── 预分配固定大小的块。O(1) 分配/释放,无碎片化。
- 栈分配器 ── 后进先出分配,极其快速。
C++ 容器接受分配器模板参数:std::vector。C++17 中的 std::pmr(多态内存资源)命名空间提供了一个运行时多态的分配器框架,避免了模板参数的问题。
在现代 C++(C++14 及以后)中,编写良好的应用代码应该包含零个裸 new 和 delete:
- 独占所有权:使用 std::make_unique
- 共享所有权:使用 std::make_shared
- 动态数组:使用 std::vector
- 字符串:使用 std::string
- 可选对象:使用 std::optional
裸 new/delete 可能出现在库代码中(分配器、自定义容器),但应用代码不应该需要它们。如果你在代码审查中看到 new,请问为什么 ── 几乎总有更安全的替代方案。
- 混用
new/delete[]或new[]/delete是未定义行为 - 内存泄漏、双重释放和释放后使用是三大致命内存 bug
- 如果构造函数抛出异常,
f(new A(), new B())会泄漏(C++17 之前即使使用 shared_ptr 包装也是如此) - 定位 new 在给定地址构造对象 ── 手动销毁,永远不要使用
delete - 现代 C++ 应用代码应该有零个裸
new/delete── 使用智能指针和容器
测验 — 检验你的知识
(15 经验值)1. 如果对 `new[]` 分配的内存使用 `delete` 会怎样?
2. 为什么 `f(new A(), new B())` 会泄漏内存?
3. 如何销毁使用定位 new 创建的对象?