26. 内存泄漏检测:Valgrind、AddressSanitizer、CRT 调试堆、智能指针的泄漏场景

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

我见过不少项目,跑着跑着内存占用就飙上去了,最后进程被 OOM killer 干掉。更可怕的是,有些泄漏藏得很深,只在特定路径下触发,测试环境根本发现不了。

这一章,我们来聊聊怎么逮住这些泄漏。我会介绍四种主流手段:Valgrind、AddressSanitizer、Windows 上的 CRT 调试堆,以及智能指针本身可能带来的泄漏场景。

Valgrind:Linux 下的老牌利器

Valgrind 是个工具集,其中 memcheck 工具专门检测内存问题。它的原理是模拟 CPU 执行,拦截所有内存操作指令。

说白了,它会在你的程序外面包一层「监控壳」。每次 malloc/free,它都记录在案。程序退出时,它检查有没有没释放的块。

核心用法:
g++ -g -O0 main.cpp -o main
valgrind --tool=memcheck --leak-check=full ./main

我个人习惯在 CI 流程里加一个 Valgrind 步骤。虽然跑起来慢(大概慢 10-20 倍),但能抓到很多隐蔽问题。

小技巧: 加上 --show-leak-kinds=all 可以看到「definitely lost」和「possibly lost」两种泄漏。前者是铁证,后者可能是指针被覆盖导致的。

AddressSanitizer (ASan):编译期插桩

Valgrind 慢,对吧?那 ASan 就是它的「快版本」。

ASan 是 LLVM/GCC 内置的检测工具。它在编译时往代码里插桩,运行时用影子内存记录每一字节的状态。一旦访问越界或泄漏,立刻报错。

编译命令:
g++ -fsanitize=address -fno-omit-frame-pointer -g main.cpp -o main

跑起来之后,如果程序退出时还有未释放的内存,ASan 会打印泄漏报告,包含调用栈。

注意: ASan 会显著增加内存占用(约 2-3 倍),不适合生产环境长期运行。我一般只在单元测试和集成测试阶段开启。

我遇到过最坑的一次:某个模块在 release 模式下一切正常,但一开 ASan 就崩。查了半天,发现是 memcpy 长度算错了,越界写了几个字节。release 下没触发段错误,但 ASan 直接逮住了。

CRT 调试堆:Windows 上的专属武器

如果你在 Windows 上用 MSVC 开发,CRT 调试堆是官方给的「体检工具」。

启用方式:
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>

int main() {
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
    // 你的代码
    return 0;
}

程序退出时,输出窗口会打印泄漏信息。你还可以用 _CrtSetBreakAlloc(分配序号) 在指定分配处下断点,直接定位到代码行。

个人经验: 我习惯在项目预编译头里全局开启 _CRTDBG_MAP_ALLOC,这样 new/delete 也会被映射到调试版本。配合 _CrtDumpMemoryLeaks() 可以在任意时刻手动检查。

智能指针的泄漏场景

很多人觉得用了智能指针就不会泄漏了。嗯,理想很丰满,现实很骨感。

我总结了几种常见的「智能指针泄漏」场景:

  • 循环引用(shared_ptr 互指):A 持有 B 的 shared_ptr,B 持有 A 的 shared_ptr。引用计数永远降不到 0。
  • 裸指针混用:把 shared_ptr 的裸指针取出来,又用这个裸指针去创建另一个 shared_ptr。两个控制块互不知情,内存释放两次或泄漏。
  • 回调中的生命周期问题:lambda 捕获了 shared_ptr,但 lambda 被长期持有,导致对象无法释放。
  • 容器中的 shared_ptr 未清理:往 vector/map 里塞了 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;  // 循环引用,泄漏!

解决办法?用 weak_ptr 打破循环。谁「被引用」谁就用 weak_ptr,谁「引用别人」谁就用 shared_ptr。

避坑指南: 我曾经接手过一个服务端项目,里面大量使用 shared_ptr 做事件回调。结果每个连接对象都互相引用,连接断开后对象不释放。最后用 weak_ptr 替换了回调中的 shared_ptr,内存占用直接降了 40%。

知识体系总览

下面这张图帮你理清四种检测手段的定位和适用场景:

内存泄漏检测工具对比 Valgrind Linux 平台 模拟执行 慢但全面 无需重新编译 AddressSanitizer Linux/macOS/Windows 编译期插桩 速度快 内存占用高 CRT 调试堆 Windows 专属 MSVC 内置 可设断点 仅 Debug 模式 智能指针泄漏 循环引用 裸指针混用 回调捕获 容器未清理 选择建议 • 开发阶段:ASan 优先,速度快,定位准 • CI 阶段:Valgrind 补充,覆盖 ASan 可能漏掉的场景 • Windows 开发:CRT 调试堆 + ASan 双保险 • 代码审查:重点检查 shared_ptr 循环引用和回调捕获

实战建议

说了这么多,到底怎么用?我个人的工作流是这样的:

  1. 本地开发:编译时开 ASan,跑单元测试。泄漏了直接看调用栈。
  2. 提交前:用 Valgrind 跑一遍集成测试,确保没有「possibly lost」的泄漏。
  3. Windows 环境:Debug 模式下开启 CRT 调试堆,配合 _CrtSetBreakAlloc 定位。
  4. 代码审查:重点看 shared_ptr 的循环引用,以及 lambda 捕获的智能指针。
最后说一句: 工具再好,也替代不了良好的编码习惯。我见过太多人依赖 Valgrind 来「擦屁股」,结果代码里全是裸指针和手动 new/delete。我的建议是:能用智能指针就别用裸指针,能用 unique_ptr 就别用 shared_ptr。减少手动管理,泄漏自然就少了。

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