33. 内存泄漏常见模式:异常路径中的泄漏
异常路径中的内存泄漏,说白了就是「代码跑偏了,资源没回收」。
我见过太多项目,正常流程跑得稳稳当当,一遇到异常就崩。更可怕的是——不崩,但内存一点一点漏掉。等你发现的时候,服务器已经吃了好几个G。
异常路径泄漏的本质
先想一个问题:代码中哪些地方最容易忘记释放资源?
不是正常流程,正常流程大家都会写delete。真正出问题的是那些「意外分支」——函数提前返回、异常抛出、条件判断跳转。这些路径上,程序员往往只关注了业务逻辑,忘了收拾内存的烂摊子。
核心观点:异常路径泄漏 = 正常流程的delete没被执行 + 异常发生时资源无人接管
典型场景一:提前返回忘记释放
这是最基础的坑。我刚开始写C++时也踩过。
void processData() {
char* buffer = new char[1024];
if (!validateInput()) {
// 哎呀,忘了 delete[] buffer
return; // 泄漏!
}
// 正常处理...
delete[] buffer;
}
你看,validateInput返回false时,buffer就没人管了。这种代码在code review时很容易被漏掉,因为reviewer的注意力都在正常逻辑上。
我的习惯:只要看到new,立刻写对应的delete。哪怕还没想好逻辑,先把架子搭好。这叫「配对编码法」。
典型场景二:异常抛出时的泄漏
这个更隐蔽。C++的异常机制会让函数调用栈展开,但栈展开只负责析构局部对象,不负责释放原始指针。
void riskyFunction() {
int* data = new int[100];
try {
doSomethingRisky(); // 可能抛异常
// 如果抛异常,下面的delete就没了
delete[] data;
} catch (...) {
// 这里只处理了异常,没释放data
throw; // 重新抛出,data泄漏
}
}
为什么会这样?因为异常发生时,程序直接跳到了catch块,delete语句被跳过了。而data是原始指针,不是RAII对象,所以没有自动析构。
我曾经在一个交易系统中遇到过这个问题。某个网络异常导致内存泄漏,每次重连就漏几KB。系统跑了三天后,内存占用从200MB涨到了2GB,最后OOM被kill了。查了两天才找到原因——就是catch块里忘了释放一个缓冲区。
典型场景三:构造函数中的异常
这个场景很多人不知道。C++标准规定:构造函数抛出异常时,对象的析构函数不会被调用。因为对象还没构造完成,C++认为它「不存在」。
class ResourceHolder {
int* buffer1;
int* buffer2;
public:
ResourceHolder() {
buffer1 = new int[100];
// 假设这里抛异常
throw std::bad_alloc();
buffer2 = new int[100]; // 永远不会执行
}
~ResourceHolder() {
delete[] buffer1; // 也不会执行!
delete[] buffer2;
}
};
buffer1已经分配了内存,但析构函数不会被调用,所以buffer1泄漏了。这就是所谓的「部分构造」问题。
| 构造阶段 | 异常发生 | 析构是否调用 | 内存状态 |
|---|---|---|---|
| 成员变量构造前 | 是 | 否(对象未开始构造) | 安全 |
| 成员变量构造中 | 是 | 仅析构已构造的成员 | 已构造成员安全 |
| 构造函数体内 | 是 | 否(对象未完全构造) | 已分配内存泄漏 |
解决方案:RAII是王道
说了这么多问题,怎么解决?其实C++早就给了答案——RAII(资源获取即初始化)。
你想想看,如果不用原始指针,而是用智能指针或者容器,这些问题是不是都不存在了?
class SafeHolder {
std::vector<int> buffer1; // 自动管理
std::unique_ptr<int[]> buffer2;
public:
SafeHolder()
: buffer1(100)
, buffer2(std::make_unique<int[]>(100))
{
// 即使这里抛异常,buffer1和buffer2也会自动释放
throw std::runtime_error("oops");
}
// 不需要手动析构!
};
vector和unique_ptr都是RAII对象。它们的析构函数会在栈展开时被自动调用。异常路径?提前返回?统统不用操心。
我建议:团队可以定一条硬性规则——禁止在异常可能发生的路径上使用原始指针管理资源。代码审查时重点检查这一点。
实战中的避坑指南
根据我的经验,以下几个场景最容易出现异常路径泄漏:
- 多层嵌套的try-catch:内层catch处理了异常但没释放资源,外层catch又不知道内层分配了什么
- 回调函数中分配的资源:回调执行到一半抛异常,调用方不知道资源的存在
- 第三方库的异常:库内部抛异常,你的代码没catch住,资源就悬空了
- 移动语义中的异常:移动操作抛异常时,源对象的状态可能不完整
嗯,这里要注意:不是所有异常都需要catch。有时候让异常继续传播反而更安全——前提是你的资源都是RAII管理的。
知识体系图
下面这张图总结了异常路径泄漏的常见模式与解决方案:
总结
异常路径泄漏,说白了就是「人算不如天算」。你写代码时觉得所有路径都覆盖了,但异常一出现,执行流就拐到了你没想到的地方。
我个人经验是:别跟自己的记忆力较劲。用RAII把资源管理交给编译器,比你自己手写delete靠谱一万倍。现代C++里,90%以上的原始指针都可以被智能指针替代。剩下的10%,用ScopeGuard或者自定义RAII包装一下。
记住一句话:如果一段代码可能因为异常而跳过资源释放,那它迟早会出问题。
最后一条建议:写代码时,先假设所有可能出错的地方都会出错。然后问自己——如果这里抛异常,我的资源安全吗?如果答案是否定的,那就用RAII重构它。