23. 内存泄漏检测:Valgrind、AddressSanitizer

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

我见过太多项目,跑着跑着内存飙到几个G,最后直接OOM被系统杀掉。更可怕的是,有些泄漏藏得很深,平时测试看不出来,上线一周后才爆发。嗯,这时候你就需要趁手的工具了。

为什么需要内存泄漏检测工具?

你可能会想:「我代码写得挺小心,new和delete都成对出现,应该没问题吧?」

说实话,我以前也这么觉得。直到有一次,我在一个大型服务端项目里排查内存问题——代码几十万行,光new就有上千处。你让我肉眼检查?不现实。

而且很多泄漏不是简单的new/delete不匹配。比如:

  • 异常路径中忘记释放
  • 容器里存了指针,但容器销毁时没遍历释放
  • 第三方库内部泄漏
  • 循环引用导致shared_ptr无法释放

这些情况,靠代码审查很难发现。所以我们需要自动化工具。

Valgrind:老牌内存检测工具

Valgrind是我最早接触的内存检测工具。它的原理很简单——模拟一个虚拟CPU,在程序运行时拦截所有内存操作。

核心原理:Valgrind在程序执行每条指令前,都会检查这次内存访问是否合法。如果访问了未分配的内存、越界了、或者释放后又访问了,它就会报告。

基本用法

// 编译时加上 -g 保留调试信息
g++ -g -o myapp myapp.cpp

// 用valgrind运行
valgrind --leak-check=full ./myapp

输出大概长这样:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 72 bytes in 3 blocks
==12345==   total heap usage: 10 allocs, 7 frees, 1,024 bytes allocated
==12345== 
==12345== 72 bytes in 3 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2B0E0: malloc (vg_replace_malloc.c:299)
==12345==    by 0x4005E4: main (myapp.cpp:10)

看到「definitely lost」了吗?这就是明确的内存泄漏。Valgrind还会告诉你泄漏发生在哪个文件的哪一行。

我的经验:Valgrind跑起来确实慢,程序会慢20-30倍。所以我一般只在单元测试或小规模场景下用。生产环境?别想了,太慢了。

Valgrind的局限性

  • 速度慢:模拟执行,开销巨大
  • 内存占用高:程序本身内存占用会翻几倍
  • 不支持所有平台:主要支持Linux
  • 对多线程支持一般:有些竞态条件检测不太准

AddressSanitizer (ASan):现代C++的标配

说实话,我现在个人更推荐ASan。为什么?因为它快,而且集成在编译器里。

ASan是LLVM/GCC的一个编译选项。它通过在变量周围插入「红区」(redzone),来检测内存错误。你想想看,每次访问内存时,ASan都会检查是否踩到了红区——如果踩到了,说明越界了。

基本用法

// 编译时加上 -fsanitize=address
g++ -g -fsanitize=address -o myapp myapp.cpp

// 直接运行即可
./myapp

就这么简单。运行时如果发生内存错误,ASan会直接报错并退出:

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
    at pc 0x4008a1 bp 0x7ffd8f0b0 sp 0x7ffd8f0b0
    READ of size 4 at 0x602000000010 thread T0
    #0 0x4008a1 in main myapp.cpp:15
    #1 0x7f1234567890 in __libc_start_main libc-start.c:291
    #2 0x4007b9 in _start (myapp+0x4007b9)

看到「heap-use-after-free」了吗?这就是释放后继续使用的错误。ASan会告诉你:在myapp.cpp的第15行,你读了一个已经释放的内存块。

注意:ASan会显著增加内存占用(约2倍),但运行速度只慢2-3倍。相比Valgrind的20-30倍,这已经非常友好了。我建议在开发测试阶段始终开启ASan。

ASan能检测什么?

检测类型 说明 示例
堆内存越界 访问了分配区域之外的内存 数组下标越界
栈内存越界 局部变量越界 char buf[10]; buf[10] = 'a';
全局变量越界 全局/静态变量越界 全局数组越界
释放后使用 访问已释放的内存 delete后继续使用指针
双重释放 对同一块内存释放两次 两次delete同一指针
内存泄漏 分配后未释放 new后没有delete

Valgrind vs ASan:怎么选?

我个人的建议是这样的:

  • 日常开发:用ASan。编译时加个选项就行,方便快捷。
  • CI/CD流水线:也推荐ASan。速度快,能快速反馈问题。
  • 深度排查:如果ASan查不出来,或者需要更详细的信息(比如完整的调用栈),再用Valgrind。
  • 生产环境:两个都不适合。生产环境可以用轻量级的统计工具,比如jemalloc的profiling功能。

避坑指南:我曾经在一个项目里同时开了ASan和Valgrind,结果程序直接崩溃。这两个工具不能同时用,因为它们会互相干扰。先关掉一个再试。

实战:一个泄漏示例

来看一个典型的泄漏场景:

#include <vector>
#include <memory>

class Data {
    int* buffer;
public:
    Data() { buffer = new int[1024]; }
    ~Data() { delete[] buffer; }  // 嗯,这里没问题
};

void leak_example() {
    std::vector<Data*> vec;
    for (int i = 0; i < 10; i++) {
        vec.push_back(new Data());  // 分配了,但谁释放?
    }
    // vec销毁时,只会销毁指针本身,不会delete指向的对象
}

用ASan跑一下:

g++ -g -fsanitize=address -o test leak.cpp
./test

// 输出:
// ==12345==ERROR: LeakSanitizer: detected memory leaks
// Direct leak of 40 bytes in 10 objects allocated from:
//     #0 0x4c2b0e0 in operator new(unsigned long)
//     #1 0x4008a1 in leak_example() leak.cpp:12

看到了吧?ASan直接报出了泄漏。它告诉你:在leak.cpp的第12行,你new了10个对象,但没释放。

怎么修?用智能指针:

void fixed_example() {
    std::vector<std::unique_ptr<Data>> vec;
    for (int i = 0; i < 10; i++) {
        vec.push_back(std::make_unique<Data>());
    }
    // vec销毁时,unique_ptr会自动delete Data对象
}

知识体系总览

下面这张图总结了内存泄漏检测的核心脉络:

内存泄漏检测工具对比 Valgrind AddressSanitizer 原理:模拟CPU,拦截内存操作 原理:编译时插入红区检测 速度:慢(20-30倍) 速度:较快(2-3倍) 内存占用:高(数倍) 内存占用:中等(约2倍) 适用:深度排查、复杂场景 适用:日常开发、CI/CD 推荐:日常用ASan,深度排查用Valgrind

总结

内存泄漏检测,说白了就是让工具帮你盯着内存的「借」和「还」。

我个人习惯是:开发阶段全程开ASan,CI流水线也加上ASan。如果遇到ASan查不出来的诡异问题,再上Valgrind做深度分析。这两个工具配合使用,基本能覆盖99%的内存问题。

嗯,最后提醒一句:工具再好,也不如养成良好的编码习惯。智能指针、RAII、避免裸指针——这些才是治本的方法。

一句话总结:Valgrind是「事后诸葛亮」,ASan是「事前预防针」。两者结合,内存泄漏无处遁形。

专注资料整理