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管理的。

知识体系图

下面这张图总结了异常路径泄漏的常见模式与解决方案:

异常路径内存泄漏:模式与对策 模式一:提前返回 函数中途return delete被跳过 模式二:异常抛出 catch块未释放资源 栈展开不析构原始指针 模式三:构造异常 构造函数抛异常 析构函数不被调用 核心问题:正常路径的delete未被执行 解决方案:RAII + 智能指针 unique_ptr / shared_ptr vector / string 等容器 ScopeGuard / finally

总结

异常路径泄漏,说白了就是「人算不如天算」。你写代码时觉得所有路径都覆盖了,但异常一出现,执行流就拐到了你没想到的地方。

我个人经验是:别跟自己的记忆力较劲。用RAII把资源管理交给编译器,比你自己手写delete靠谱一万倍。现代C++里,90%以上的原始指针都可以被智能指针替代。剩下的10%,用ScopeGuard或者自定义RAII包装一下。

记住一句话:如果一段代码可能因为异常而跳过资源释放,那它迟早会出问题

最后一条建议:写代码时,先假设所有可能出错的地方都会出错。然后问自己——如果这里抛异常,我的资源安全吗?如果答案是否定的,那就用RAII重构它。

专注资料整理