26. 内存泄漏常见模式:忘记delete、异常安全
聊到内存泄漏,我脑子里立刻浮现出刚入行时的一段经历。那时候我写了一个图像处理模块,跑了一整天测试,结果内存占用从200MB一路飙到2GB,最后系统直接OOM了。排查了半天,发现就是少写了一个delete[]。嗯,这种错误,说白了就是「挖了坑忘了填」。
今天咱们就专门聊聊两种最常见的泄漏模式:忘记delete和异常安全。你想想看,这两个问题几乎覆盖了90%以上的手动内存管理事故现场。
模式一:忘记delete——最朴素的坑
先看一个最直接的例子:
void processData() {
int* buffer = new int[1024];
// ... 处理数据 ...
// 忘记 delete[] buffer;
}
这种代码,每次调用都会泄漏1024个int。跑一次两次没事,跑一万次呢?内存就炸了。
我个人习惯把这种泄漏分成三类:
| 泄漏类型 | 典型场景 | 后果 |
|---|---|---|
| 直接遗漏 | new后忘记写delete | 每次调用泄漏固定大小内存 |
| 分支遗漏 | 某些return路径没释放 | 特定条件下泄漏 |
| 覆盖遗漏 | 指针被重新赋值前没释放旧内存 | 旧内存永远丢失 |
分支遗漏特别坑。我在项目中遇到过这样的代码:
void handleRequest(const std::string& input) {
char* temp = new char[input.size() + 1];
strcpy(temp, input.c_str());
if (input.empty()) {
// 直接返回,忘记释放temp
return;
}
// ... 处理 ...
delete[] temp;
}
你看,input.empty()这个分支,temp就泄漏了。这种bug在代码审查时很容易漏掉,因为「正常路径」看起来是对的。
模式二:异常安全——C++特有的陷阱
这个模式,说白了就是「代码还没跑到delete,异常先来了」。C++的异常机制让内存管理变得复杂了很多。
看这个例子:
void riskyFunction() {
Resource* res = new Resource();
doSomethingThatMayThrow(); // 如果这里抛异常
delete res; // 这行永远不会执行
}
为什么会这样?因为C++的异常会沿着调用栈向上传播,delete res直接被跳过了。res指向的内存就成了孤儿。
我曾经在一个交易系统里遇到过这种问题。某个网络请求超时抛了异常,结果导致一批订单对象的内存泄漏。系统跑了三天,内存占用从正常值翻了三倍。排查时发现,每个异常路径都漏了delete。
解决异常安全问题的经典方案有三个:
- RAII(资源获取即初始化)——把资源绑定到栈对象的生命周期上
- 智能指针——让析构函数自动释放
- try-catch-finally——手动保证释放(不推荐,容易遗漏)
我个人最推荐RAII。你想想看,栈对象在异常发生时一定会被析构,这是C++给我们的保证。利用这个特性,就能从根本上避免异常安全导致的泄漏。
两种模式的对比
| 对比维度 | 忘记delete | 异常安全 |
|---|---|---|
| 根本原因 | 程序员疏忽 | 异常破坏执行流 |
| 触发条件 | 所有调用路径 | 仅异常路径 |
| 排查难度 | 中等(静态分析可发现) | 高(需要理解异常流) |
| 最佳对策 | 智能指针 / 代码审查 | RAII / 智能指针 |
一张图看懂内存泄漏的两种模式
下面这张SVG图,把两种泄漏模式的流程和解决方案画清楚了:
实战建议:如何避免这两种模式
说了这么多,到底怎么落地?我分享几个自己的做法:
- 写new的同时写delete——哪怕先写个空行占位,也比事后补强
- 优先用智能指针——
std::unique_ptr基本能覆盖90%的场景 - 异常路径要测试——专门写单元测试触发异常,看资源是否释放
- 用Valgrind或ASan定期跑——我每周至少跑一次内存检测
嗯,这两种模式说完了。其实核心就一句话:别让new和delete之间出现「意外」。要么用智能指针彻底消灭裸new,要么用RAII保证异常安全。你想想看,C++11之后智能指针已经这么成熟了,何必还跟手动delete较劲呢?