9、内存泄漏检测:Valgrind使用、AddressSanitizer、自定义检测工具、泄漏模式分析

内存泄漏,说白了就是「申请了内存,忘了还」。

我见过最离谱的一次,一个后台服务跑了三天,内存从 200MB 涨到 8GB,最后 OOM 被内核干掉。查了半天,就是一个循环里每次 new 了对象,但只在特定分支下 delete。这种 bug 藏得深,靠肉眼 review 很难发现。

所以,工具是必须的。今天我们就聊聊三样东西:ValgrindAddressSanitizer,以及自定义检测。最后再分析几种常见的泄漏模式。

9.1 Valgrind 使用

Valgrind 是个老牌工具了。我最早接触它是在 2010 年,那时候项目里有个诡异的崩溃,GDB 根本抓不住。后来同事说「试试 Valgrind」,一跑,直接定位到一处野指针写入。

Valgrind 的核心是 Memcheck 工具。它模拟 CPU 执行你的程序,同时跟踪每一块内存的状态。说白了,它就是个「内存警察」。

基本用法

g++ -g -O0 main.cpp -o main   # 必须加 -g,否则定位不准
valgrind --tool=memcheck ./main

跑完之后,它会输出类似这样的信息:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 40 bytes in 1 blocks
==12345==   total heap usage: 2 allocs, 1 frees, 72,744 bytes allocated
==12345== 
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2B0E0: operator new(unsigned long) (vg_replace_malloc.c:344)
==12345==    by 0x4005E4: main (main.cpp:5)

看到 definitely lost 了吗?这就是泄漏。Valgrind 把泄漏分成了几类:

类别含义
definitely lost确定泄漏,指针丢了,无法释放
indirectly lost间接泄漏,比如父节点丢了,子节点也丢了
possibly lost可能泄漏,指针还在,但指向了堆内部某处
still reachable没释放,但指针还在,程序退出前可以释放
我的习惯: 每次提交代码前,跑一遍 Valgrind。哪怕只是小改动,也值得花 30 秒验证一下。很多泄漏就是「改了一行代码,忘了释放」造成的。

常用选项

  • --leak-check=full:显示泄漏详情
  • --show-leak-kinds=all:显示所有泄漏类型
  • --track-origins=yes:追踪未初始化值的来源
  • --suppressions=file.supp:忽略已知的第三方库泄漏

嗯,这里要注意:Valgrind 会让程序跑得慢很多,大概慢 10-20 倍。所以不适合做性能测试,只适合做内存检查。

9.2 AddressSanitizer

Valgrind 虽好,但太慢了。如果你在开发阶段想快速迭代,我推荐用 AddressSanitizer(简称 ASan)。

ASan 是编译器内置的工具,GCC 和 Clang 都支持。它比 Valgrind 快得多,大概只慢 2-3 倍。

基本用法

g++ -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o main
./main

如果程序有泄漏,ASan 会在退出时打印泄漏报告:

=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 40 byte(s) in 1 object(s) allocated from:
    #0 0x4c2b0e0 in operator new(unsigned long)
    #1 0x4005e4 in main main.cpp:5
ASan 的优势:
  • 速度快,适合日常开发
  • 能检测越界、use-after-free、泄漏等多种问题
  • 集成在编译器里,不需要额外安装

我曾经在一个项目里,用 ASan 发现了一个隐藏了两年的 bug。那个 bug 只在特定输入下触发,QA 测了无数次都没抓到。ASan 一跑,直接报 use-after-free,定位到某处 vector 扩容后迭代器失效的问题。

注意: ASan 会增加内存占用,大概 2-3 倍。生产环境不要开。另外,ASan 和某些第三方库可能不兼容,比如一些老旧的 GPU 驱动。

9.3 自定义检测工具

有时候,Valgrind 和 ASan 都不够用。比如你想在线上环境做轻量级检测,或者你想统计某个模块的内存分配情况。这时候就需要自己动手了。

重载 new/delete

最简单的做法:重载全局的 operator newoperator delete,记录分配和释放的地址。

#include <iostream>
#include <map>
#include <mutex>

std::map<void*, size_t> allocMap;
std::mutex allocMutex;

void* operator new(size_t size) {
    void* ptr = std::malloc(size);
    if (ptr) {
        std::lock_guard<std::mutex> lock(allocMutex);
        allocMap[ptr] = size;
    }
    return ptr;
}

void operator delete(void* ptr) noexcept {
    if (ptr) {
        std::lock_guard<std::mutex> lock(allocMutex);
        allocMap.erase(ptr);
        std::free(ptr);
    }
}

void dumpLeaks() {
    std::lock_guard<std::mutex> lock(allocMutex);
    for (auto& [ptr, size] : allocMap) {
        std::cout << "Leak: " << ptr << " size=" << size << "\n";
    }
}

然后在程序退出时调用 dumpLeaks()。这样你就能看到哪些内存没释放。

我的经验: 自定义检测适合「定向排查」。比如你怀疑某个模块有泄漏,就只在这个模块里重载 new/delete,或者用 hook 技术拦截 malloc。不要全局重载,否则会影响第三方库。

使用宏包装

另一种方式:用宏来包装 new 和 delete,记录文件名和行号。

#define NEW new(__FILE__, __LINE__)

void* operator new(size_t size, const char* file, int line) {
    void* ptr = std::malloc(size);
    // 记录 file 和 line
    return ptr;
}

这样泄漏报告里就能直接看到是哪行代码分配的内存。嗯,这个技巧我在做嵌入式开发时经常用,因为 Valgrind 在嵌入式平台上跑不动。

9.4 泄漏模式分析

工具只是辅助,关键还是得懂「泄漏是怎么发生的」。我总结了几种常见的泄漏模式:

模式一:循环中忘记释放

for (int i = 0; i < 1000; i++) {
    char* buf = new char[1024];
    // 处理 buf,但忘了 delete[]
}

这种最蠢,但也最常见。每次循环分配一块内存,指针被覆盖,之前的就丢了。

模式二:异常路径未释放

void func() {
    int* p = new int(42);
    if (someCondition) {
        throw std::runtime_error("error");
        // p 没释放!
    }
    delete p;
}

这种在异常处理中特别容易出问题。我的建议是:能用 RAII 就用 RAII,比如 std::unique_ptrstd::vector

模式三:容器中存了裸指针

std::vector<int*> vec;
vec.push_back(new int(1));
vec.push_back(new int(2));
// 忘了遍历 delete

这种模式在旧代码里很常见。解决方案:用 std::vector<std::unique_ptr<int>> 代替。

模式四:循环引用(shared_ptr)

struct Node {
    std::shared_ptr<Node> next;
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;  // 循环引用,泄漏!

这种泄漏用 Valgrind 很难查,因为 shared_ptr 内部用了引用计数,Valgrind 不会报「definitely lost」。但内存确实没释放。解决方案:用 weak_ptr 打破循环。

总结一下:
  • Valgrind 适合深度检查,但慢
  • ASan 适合日常开发,快且准
  • 自定义工具适合线上或嵌入式场景
  • 理解泄漏模式,比依赖工具更重要

我个人习惯是:开发阶段用 ASan,提交前跑一遍 Valgrind,线上用自定义 hook 做轻量监控。三管齐下,基本能覆盖 99% 的泄漏场景。

内存泄漏检测工具对比 Valgrind 工具:Memcheck 速度:慢(10-20x) 检测:泄漏、越界、未初始化 适用:深度检查、CI AddressSanitizer 工具:编译器内置 速度:中等(2-3x) 检测:泄漏、use-after-free、越界 适用:日常开发 自定义检测 工具:重载 new/delete 速度:几乎无影响 检测:定向泄漏 适用:线上、嵌入式 常见泄漏模式 循环中忘记释放 异常路径未释放 容器中存裸指针 shared_ptr循环引用 用 RAII 解决 用 unique_ptr 解决 用容器存智能指针 用 weak_ptr 打破

好了,这一章的内容就到这里。工具是死的,人是活的。多写、多测、多总结,内存泄漏其实没那么可怕。

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