3.1 堆内存与动态分配


3.1 堆内存与动态分配

本节摘要:堆是程序运行期可以自由申请归还的内存池,C++ 用 new 与 delete 管理它——new 做两件事(分配加构造),delete 也做两件事(析构加归还)。本节拆解堆分配的内部机制、内存泄漏与双重释放的案发过程、new 与 malloc 的本质差异,以及用工具把堆事故堵在上游的实践清单。

你将学会

阅读完本节,你应当能够:

  1. 说出 new/delete 与 malloc/free 的两处本质差异;
  2. 描述内存泄漏、双重释放、堆越界的成因与症状;
  3. 解释 new 失败为何抛异常而不是返回空;
  4. 列出定位堆泄漏的三种工程手段。

一、为什么需要堆

栈的两条纪律在第 1 章已经领教:大小编译期定、生命周期随栈帧终结。可现实需求常常相反——容器要按数据量伸缩,对象要活过创建它的函数,对象本体要在运行期才能确定类型。这些需求统统指向堆:

int* make_buffer(std::size_t n) { int* p = new int[n]; // 运行期才知 n 栈上放不下 编译期更无从谈起 return p; // 堆对象活过本次调用 }

堆是一块由分配器管理的内存池,程序按需申请、用完归还。主流分配器(glibc 的 ptmalloc、Windows 的堆 API)的策略大同小异:小块切割自更大的内存块,空闲块按尺寸分级缓存复用,目的是让频繁的申请归还不至于每次都陷入系统调用。理解到这一层就够用了——分配不是免费的,一次堆申请常在百纳秒量级,比栈上挪个指针贵两个数量级,这也是短字符串优化、容量预留等设计的动机。

图:一次 new int[4] 在堆分配器眼中的样子

图:一次 new int[4] 在堆分配器眼中的样子

二、new/delete 对 malloc/free:多出的那半步

malloc 只分配字节,不认识类型;new 是 C++ 的运算符,做件事:

struct Sensor { Sensor() { /* 构造逻辑 */ } int calibration{0}; }; Sensor* s = new Sensor; // 1 分配 sizeof(Sensor) 字节 2 调用构造函数 delete s; // 3 调用析构函数 4 归还内存 Sensor* arr = new Sensor[3]; // 数组版本额外记录元素个数 delete[] arr; // 必须配对 否则只析构第一个

配对纪律是硬性的:newdeletenew[]delete[]mallocfree,混用是未定义行为——delete 不知道数组头里藏着元素个数,free 不会调析构。new 失败不返回空,抛 std::bad_alloc 异常(这是与 malloc 返回 NULL 最大的行为差异);确实想要空指针语义用 new (std::nothrow),但更现代的态度是:让异常路径统一走 try(3.6 节)。

三、三起事故的现场复原

内存泄漏:申请了、用完了、忘了还。泄漏不会立刻崩溃,它像慢性的内存贫血——进程占用量只升不降,跑几天的服务最终被系统杀死。最普通的泄漏形态不是"忘了写 delete",而是"delete 写了却执行不到":

void process() { auto* data = new std::vector<int>(1000000); bool ok = do_work(*data); if (!ok) return; // 提前返回 delete 被跳过 泄漏 delete data; }

早退、异常、循环里的 continue,都能让写在尾部的 delete 形同虚设。这就是为什么"手工 new/delete"被现代 C++ 判了缓刑——3.2 的 RAII 与 3.3 的智能指针才是正刑。

双重释放:同一块内存还两次,分配器的空闲链表被打成死结,症状常常是"随后某次不相干的 new 崩溃"——崩溃点与案发点相距十万八千里,是排查成本最高的一类。两个所有者共享一个裸指针是它的标准起因(2.1 节的 BadBuffer 就是雏形)。

越界写堆:图里画的块头被踩坏。写数组越一个元素,改写的是分配器的元数据,等下次相邻块归还时才引爆。规律:堆上越界崩溃必晚于案发,与栈上越界(常在附近)正好相反。

四、把事故堵在上游的工具箱

编译期:编译器警告 -Wall -Wextra 能抓一部分配对问题;把裸 new 圈禁在封装层(管理类的私有角落),公开接口只出智能指针。

运行期排查:地址消毒剂(AddressSanitizer,主流编译器 -fsanitize=address 一键开启)把堆块周围布上毒药字节,越界、use-after-free、double-free 当场抓获,开销约两倍,测试环境常开;Valgrind 的 memcheck 无需重编译即可抓泄漏,代价是慢十倍以上。

泄漏定位三步法:先看增长曲线确认是"真泄漏"还是"碎片化";再用 ASan 的泄漏报告拿分配栈;最后按调用栈归类到代码路径。长期驻留的服务还可以周期性打印自定义分配统计(重载 operator new 计数是侵入最小的方式)。

顺带说清一个常被误解的词:内存碎片不是泄漏。反复申请释放不同尺寸的块之后,堆里空闲总量充足却没有一块够大的连续区,大申请宣告失败。缓解手段是预留容量、对象池、按尺寸分级的分配策略——第 4 章容器章节会看到 reserve 在这方面的作用。

⚠️ 常见坑:delete 后不置空、把对象地址交给两个持有者、在多线程下跨线程 delete——三者的现代解法分别是不再裸持指针、明确唯一所有者(下一节)、智能指针定所有权(3.3)。

本节要点回顾

  • new 是两步动作:分配加构造、析构加归还,配对纪律 new/new[] 各随其 delete/delete[];失败抛 bad_alloc。
  • 分配不免费:堆申请比栈慢两个数量级,频繁小分配要靠缓存、预留、池化摊薄。
  • 三案特征:泄漏是慢性贫血、双释放崩溃在远处、堆越界崩溃在更远处——排查工具 ASan 与 Valgrind 必备。
  • 裸 new 的原罪:提前返回与异常让尾部 delete 形同虚设,正解是让释放成为析构的影子。

下一节正式立法定案:RAII 与五法则,C++ 资源管理的第一性原理。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U