前言
程序运行时使用的内存大致分为代码区、静态存储区、栈区和堆区。栈由编译器自动管理,函数返回即释放;堆(heap)由程序员显式申请和释放,生命周期可以跨越函数调用。C++ 标准文本里与new对应的是「自由存储区」(free store),它和 C 的malloc所用的堆在概念上分开讨论,多数实现底层共用同一套分配器。堆带来的灵活性,也带来了泄漏、重复释放、悬垂指针、碎片化这一整套问题。
围绕堆的误解非常多:认为new就是「带构造函数的malloc」;认为delete之后指针会自动变成空指针;认为malloc和new可以混用,只要大小对得上;认为进程退出时操作系统会回收,所以泄漏无所谓。前三条会直接导致未定义行为,最后一条只对「进程即将结束且不再需要该资源」成立,对长期运行的服务毫无帮助。
本文讲清 C 与 C++ 两套堆接口的对应关系、四种必须严格配对的分配/释放形式、常见生命周期错误的成因,以及现代 C++ 用 RAII 和智能指针收口资源的做法。示例以 C++17 为基准,涉及 C 接口的地方会单独说明。
一、两套接口:malloc/free 与 new/delete
C 的堆接口是函数:std::malloc(bytes)申请未初始化的字节块,返回void*,失败返回空指针;std::calloc(n, size)申请并零初始化;std::realloc(ptr, bytes)调整大小;std::free(ptr)释放。它们只关心字节数和对齐,不知道类型,也不会调用构造/析构函数。
C++ 的new是表达式,不是函数。new T(args)的语义分两步:先调用operator new(sizeof(T))分配原始内存,再在这块内存上构造T。如果构造抛异常,已分配的内存会被自动释放,所以new表达式本身不会因构造失败而泄漏。delete p同样分两步:先调用p->~T(),再调用operator delete(p)。
operator new/operator delete是可以被重载的全局或类成员函数,这是定制内存池的入口;而malloc/free是 C 库函数。二者属于不同的分配体系,不能交叉配对。
| 对比项 | malloc/free | new/delete |
|---|
| 本质 | 库函数 | 运算符表达式 |
| 参数 | 字节数 | 类型与构造参数 |
| 返回类型 | void*,需转换 | T*,类型安全 |
| 初始化 | 不初始化(calloc除外) | 调用构造函数 |
| 失败行为 | 返回空指针 | 默认抛std::bad_alloc |
| 释放 | free不调用析构 | delete调用析构 |
| 数组 | 自己算总字节 | new[]/delete[]成对 |
| 可重载 | 否 | 是(operator new) |
四种形式必须严格配对
堆内存的错误,一大半来自配对错误。标准规定了四组对应关系:
malloc/calloc/realloc申请的内存,用free释放。new T申请的对象,用delete p释放。new T[n]申请的数组,用delete[] p释放。- placement new 构造的对象,手动调用析构函数,内存由提供者管理(不调用
operator delete)。
new[]与delete混用、new与delete[]混用、malloc配delete、new配free,全部是未定义行为。它们看起来「有时能跑」,是因为底层分配器可能碰巧用同样的方式记账;标准不保证任何结果,运行时的表现可能是崩溃、静默破坏堆结构,或者在程序退出时才报错。
数组的new[]需要在某处记录元素个数,delete[]才能逐个调用析构函数。这个记录方式由实现决定,程序员不应依赖,也不能用delete去释放它。对没有析构函数且平凡的类型,某些实现可能让new[]与delete「碰巧」工作,但这依然是 UB,不是可移植写法。
二、堆对象的生命周期问题
泄漏(memory leak):分配后丢失所有指向它的指针,内存无法再被释放。常见于提前return、异常路径、new之后忘记保存指针。长期运行的程序里,泄漏会逐步推高内存占用。
重复释放(double free):同一块内存被delete或free两次。第二次释放时分配器的元数据已被改写,属于 UB。它常与「多个裸指针指向同一对象、各自以为拥有所有权」同时出现。
悬垂指针与释放后使用(use-after-free):delete p之后p的值仍然是原地址,解引用它就是访问生命周期已结束的对象,属于 UB。delete不会把指针置空,把它置空需要自己写p = nullptr。
异常安全:在两个new之间抛异常,前一个对象可能泄漏。例如先new A,再new B,第二个抛std::bad_alloc,第一个就没有人释放。用 RAII 把每个资源交给一个析构函数负责,才能自动覆盖异常路径。
现代 C++ 的基本策略是:让堆分配只发生在构造函数里,让释放只发生在析构函数里。std::unique_ptr<T>用delete释放单个对象,std::unique_ptr<T[]>用delete[]释放数组;std::shared_ptr<T>通过控制块记录删除器,引用计数归零时释放。它们把「配对」这件事从每个调用点收敛到类型定义里。
三、对齐、碎片与分配策略
malloc返回的指针对任何标准类型都满足对齐要求(实现保证max_align_t级别的对齐)。但从 C++17 起,可以用alignas(64)声明过对齐类型,普通operator new未必满足;此时编译器会调用带std::align_val_t参数的重载operator new(size_t, std::align_val_t),对应的delete也要匹配。手写内存池时若忽略对齐,就会产生 UB。
堆碎片分两类:外部碎片是空闲块总量够但分散,无法满足一次大请求;内部碎片是分配器为了对齐和管理元数据,实际占用大于请求。频繁申请释放大小不一的块会加剧碎片。常见缓解手段是按固定大小做对象池(pool)、按生命周期做区域分配(arena),或直接复用容器减少分配次数。
这些手段的效果取决于负载,不能笼统宣称「一定更快」。判断方法是记录分配次数、观察分配器统计、用std::chrono::steady_clock测端到端耗时,并在真实数据规模下比较。分配器缓存、线程本地缓存等实现细节因平台而异。
四、完整示例:从裸接口到 RAII
先看 C 风格的正确写法,重点是检查返回值、成对释放:
// 适用于 C++17
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <iostream>
int main() {
const std::size_t n = 16;
int* p = static_cast<int*>(std::malloc(n * sizeof(int)));
if (p == nullptr) { // malloc 失败返回空指针
std::cerr << "out of memory\n";
return 1;
}
std::memset(p, 0, n * sizeof(int)); // 仅对平凡类型这样初始化
p[0] = 42;
std::cout << p[0] << '\n';
std::free(p);
p = nullptr; // 避免悬垂,虽非必需但是好习惯
return 0;
}C++ 里同样的需求应当写成:
// 适用于 C++17
#include <iostream>
#include <memory>
#include <new>
struct Widget {
Widget() { std::cout << "ctor\n"; }
~Widget() { std::cout << "dtor\n"; }
int value = 0;
};
int main() {
auto one = std::make_unique<Widget>(); // 单个对象
one->value = 42;
auto many = std::make_unique<Widget[]>(3); // 数组版本,内部用 delete[]
many[1].value = 7;
alignas(Widget) unsigned char storage[sizeof(Widget)];
Widget* placed = new (storage) Widget(); // placement new:只构造
placed->~Widget(); // 手动析构;storage 由栈管理
return 0;
}std::make_unique<Widget[]>(3)从 C++14 起可用,它对数组调用new Widget[3]()并用delete[]释放。placement new 用于在已有内存上构造对象,常见于容器、内存池和共享内存;它不分配内存,所以绝不能对结果调用delete,只能显式调用析构函数。上面的storage是栈上的字节数组,alignas(Widget)保证对齐满足要求。
常见坑点
- ❌
int* p = new int[10]; delete p;。✅ 数组必须delete[] p;;更推荐std::vector<int>或std::unique_ptr<int[]>。 - ❌
Foo* p = (Foo*)malloc(sizeof(Foo));然后当普通对象用。✅malloc不调用构造函数,成员未初始化;用new Foo(...)或std::make_unique<Foo>(...)。 - ❌
new出来的对象用free释放。✅new/delete与malloc/free是两套体系,交叉配对是 UB。 - ❌
delete p;之后继续p->method()或再次delete p;。✅ 释放后立即置空并停止使用;所有权用智能指针表达。 - ❌
p = realloc(p, new_size);不检查返回值。✅realloc失败返回空指针且原块仍有效,直接覆盖会泄漏;先用临时指针接收。 - ❌ 构造函数里
new了资源,析构函数却忘记释放,或中间抛异常。✅ 用 RAII 成员(容器、智能指针),让析构自动完成。 - ❌ 对含
std::string等非平凡成员的对象memset或free。✅ 只对平凡可拷贝类型做字节级操作;非平凡类型走构造/析构。 - ❌ 在栈上声明超大数组(如几百 MB)试图「省掉堆分配」。✅ 栈空间通常有限,大块数据用
std::vector放到堆上。
总结
| 主题 | 要点 |
|---|
| 分配接口 | malloc按字节、不构造;new按类型、调用构造 |
| 释放接口 | free不析构;delete先析构再归还内存 |
| 配对规则 | new/delete、new[]/delete[]、malloc/free,不可交叉 |
| 失败处理 | malloc返回空指针;new默认抛bad_alloc |
| 生命周期 | 释放后指针不置空,访问即 UB;优先用智能指针 |
| 性能与碎片 | 取决于分配模式,用池/区域分配需自行测量 |
堆区的核心不是「怎么申请」,而是「谁拥有、何时释放、失败时怎么办」。把裸new/delete限制在极少数底层组件里,业务代码一律使用容器和智能指针,配对错误、异常泄漏、重复释放这些坑就失去了滋生的土壤。