实战:内存泄漏检测(重载new/delete+栈回溯)

内存泄漏,说白了就是程序申请了内存,但忘了还回去。你想想看,如果每次申请都不释放,日积月累,内存就被吃光了。我刚开始写C++那会儿,就吃过这个亏——一个后台服务跑了三天,内存从200MB飙到了2GB,最后直接OOM被系统杀掉。从那以后,我就养成了给代码加内存检测的习惯。

今天咱们就来手写一个轻量级的内存泄漏检测工具。核心思路就两条:重载全局的new和delete,再加上栈回溯定位泄漏点。

基本原理

C++允许我们重载全局的operator newoperator delete。我们可以在分配内存时,把地址、大小、调用栈等信息记录下来;释放时再把这些记录删掉。程序退出时,如果还有没释放的记录,那就是泄漏了。

嗯,这里要注意:重载全局new/delete会影响整个程序,包括STL容器。所以实现时要特别小心,避免递归调用。

核心数据结构

我们需要一个线程安全的哈希表,来管理所有分配记录。我个人习惯用std::unordered_map加互斥锁。

#include <mutex>
#include <unordered_map>
#include <string>
#include <cstdlib>
#include <cstring>
#include <execinfo.h>  // Linux栈回溯

struct AllocRecord {
    size_t size;
    char stackTrace[4096];  // 保存调用栈信息
};

std::mutex g_mutex;
std::unordered_map<void*, AllocRecord> g_allocs;

这里stackTrace我用固定大小的字符数组,而不是std::string。为什么?因为std::string内部也会分配内存,万一在new里面用string,就死循环了。我在项目中遇到过这种坑,排查了半天才发现是递归调用。

重载new和delete

先看new的重载:

void* operator new(size_t size) {
    void* ptr = std::malloc(size);
    if (!ptr) throw std::bad_alloc();

    // 获取栈回溯
    void* buffer[128];
    int frames = backtrace(buffer, 128);
    char** symbols = backtrace_symbols(buffer, frames);

    AllocRecord record;
    record.size = size;
    record.stackTrace[0] = '\0';

    // 拼接调用栈信息
    for (int i = 0; i < frames; ++i) {
        strncat(record.stackTrace, symbols[i], 
                sizeof(record.stackTrace) - strlen(record.stackTrace) - 1);
        strncat(record.stackTrace, "\n",
                sizeof(record.stackTrace) - strlen(record.stackTrace) - 1);
    }

    free(symbols);

    // 加锁保存记录
    std::lock_guard<std::mutex> lock(g_mutex);
    g_allocs[ptr] = record;

    return ptr;
}

delete的重载相对简单:

void operator delete(void* ptr) noexcept {
    if (!ptr) return;

    {
        std::lock_guard<std::mutex> lock(g_mutex);
        g_allocs.erase(ptr);
    }

    std::free(ptr);
}

注意这里operator delete不能抛异常,所以用noexcept。而且要先从map里删除记录,再释放内存——顺序不能反。

泄漏检测与报告

程序退出时,我们检查g_allocs是否为空。不为空就把泄漏信息打印出来:

void checkLeaks() {
    std::lock_guard<std::mutex> lock(g_mutex);

    if (g_allocs.empty()) {
        printf("[OK] 没有内存泄漏\n");
        return;
    }

    printf("[LEAK] 发现 %zu 处内存泄漏!\n", g_allocs.size());

    size_t totalLeaked = 0;
    for (const auto& [ptr, record] : g_allocs) {
        totalLeaked += record.size;
        printf("地址: %p, 大小: %zu 字节\n", ptr, record.size);
        printf("调用栈:\n%s\n", record.stackTrace);
    }

    printf("总计泄漏: %zu 字节\n", totalLeaked);
}

我建议在程序入口和出口分别调用checkLeaks()。比如在main()函数开头调用一次清空状态,结尾再调用一次检查泄漏。

完整示例

把上面的代码整合一下,写个测试程序:

// 在文件顶部包含上述代码
// 然后写测试代码

class TestClass {
    int* data;
public:
    TestClass() {
        data = new int[100];  // 故意泄漏
    }
    ~TestClass() {
        // 忘记 delete[] data;
    }
};

int main() {
    // 先清空可能存在的初始记录
    checkLeaks();

    TestClass* obj = new TestClass();
    delete obj;  // 只释放了对象,内部数组泄漏了

    // 检查泄漏
    checkLeaks();

    return 0;
}

运行后输出类似:

[OK] 没有内存泄漏
[LEAK] 发现 1 处内存泄漏!
地址: 0x55555556a2a0, 大小: 400 字节
调用栈:
./test(main+0x1e) [0x5555555552e5]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf3) [0x7ffff7a2c083]
./test(_start+0x2e) [0x5555555551ae]
总计泄漏: 400 字节

看到没?TestClass构造函数里new int[100]没释放,被我们抓到了。而且调用栈直接定位到了main函数,方便我们快速修复。

避坑指南

我曾经在重载new时犯过一个低级错误——在new里面用了std::cout打印日志。结果std::cout内部也会分配内存,又调用了我的new,直接栈溢出。所以记住:重载new/delete里不要用任何STL容器或IO操作,除非你能确保它们不会触发内存分配。

另外,多线程环境下一定要加锁。不加锁的话,两个线程同时new,map的插入操作会数据竞争,轻则丢记录,重则crash。

SVG流程图:内存泄漏检测核心逻辑

内存泄漏检测流程 程序启动 重载 operator new:记录地址+栈回溯 重载 operator delete:删除记录 程序退出 检查 g_allocs 是否为空 ✅ 无泄漏 ❌ 打印泄漏信息

进阶优化

上面的实现虽然能用,但有几个可以优化的地方:

  • 性能问题:每次new都获取栈回溯,开销不小。我建议只在Debug模式下启用,Release模式关掉。
  • 符号解析backtrace_symbols返回的是地址,不够直观。可以用addr2linedladdr解析出文件名和行号。
  • 内存对齐:标准new返回的内存地址至少是8字节对齐的。用std::malloc没问题,但如果要支持align_val_t重载,就得用std::aligned_alloc
小技巧:如果你用的是GCC/Clang,可以加-rdynamic编译选项,这样栈回溯里的符号名会更完整,不会出现一堆??
警告:这个工具不能用于检测C语言malloc/free的泄漏。如果要检测C的内存泄漏,得用LD_PRELOAD劫持malloc,那是另一个话题了。

总结

重载new/delete加栈回溯,是C++内存泄漏检测的经典方案。它轻量、可控,适合嵌入到自己的项目中。当然,生产环境我更推荐用Valgrind或AddressSanitizer,它们更全面。但理解底层原理,能让你在遇到诡异的内存问题时,多一条排查思路。

嗯,今天就聊到这儿。代码我已经整理好了,你可以直接拿去用。记住:内存泄漏就像房间里的灰尘,不打扫不会消失,只会越积越多


公众号:蓝海资料掘金营,微信deep3321