22、内存泄漏检测:在单元测试中集成Valgrind或AddressSanitizer检测内存问题

内存泄漏,说白了就是你的程序申请了内存,但忘了还回去。

我刚开始写C++那几年,总觉得内存泄漏离自己很远。直到有一次,一个后台服务跑了三天,内存从200MB涨到了2GB,最后被运维直接kill掉。嗯,从那以后,我再也不敢轻视内存问题了。

今天我们就聊聊,怎么在单元测试里自动揪出这些内存问题。

为什么单元测试要管内存泄漏?

你可能会想:「单元测试不是测逻辑对不对吗?内存泄漏不是有专门的工具吗?」

没错,但你想过没有——

  • 一个函数每次调用泄漏100字节,看起来不多
  • 但它在循环里被调用100万次呢?
  • 或者它在高并发场景下被反复调用呢?

内存泄漏就像温水煮青蛙。单次看不出来,累积起来就要命。

我个人习惯是:每个单元测试跑完后,自动检查有没有内存泄漏。这样问题在代码提交前就被发现了,而不是等到线上崩溃才追悔莫及。

核心观点:内存泄漏检测应该成为单元测试流水线的一环,而不是事后才想起来的手动操作。

两大主流工具:Valgrind vs AddressSanitizer

目前最常用的内存检测工具有两个:Valgrind 和 AddressSanitizer(简称ASan)。

我两个都用过,各有千秋。先看个对比:

特性 Valgrind AddressSanitizer
运行速度 慢(20-30倍) 快(2-3倍)
内存占用 中等
检测能力 泄漏、越界、未初始化 泄漏、越界、use-after-free
是否需要重新编译 不需要 需要(加编译选项)
适用场景 CI环境、深度检查 开发阶段、快速反馈

简单说:开发时用ASan,CI里用Valgrind。这是我踩过坑后总结出来的最佳实践。

实战:在单元测试中集成AddressSanitizer

先讲ASan,因为它用起来最简单。

你只需要在编译时加上几个flag:

// CMakeLists.txt 中配置
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")

然后正常跑你的单元测试就行。如果代码里有内存问题,ASan会在测试结束时打印详细的报告。

举个例子,假设我们有这样一个类:

class Buffer {
public:
    Buffer(size_t size) : data_(new char[size]) {}
    // 注意:没有定义析构函数!
    // 这会导致 data_ 永远不会被 delete[]
private:
    char* data_;
};

// 单元测试
TEST(BufferTest, CreateAndDestroy) {
    Buffer buf(1024);
    // 测试结束时,buf 被销毁,但 data_ 泄漏了
}

跑这个测试时,ASan会输出类似这样的信息:

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

Direct leak of 1024 byte(s) in 1 object(s) allocated from:
    #0 0x7f1234567890 in operator new[](unsigned long)
    #1 0x4a1b2c in Buffer::Buffer(unsigned long) buffer.cpp:5
    #2 0x4a2d3e in BufferTest_CreateAndDestroy_Test::TestBody() test.cpp:10
=================================================================

看到没?它直接告诉你在哪一行分配的内存,哪一行泄漏了。这就是我为什么喜欢ASan——定位精准,反馈迅速

小技巧:在Google Test中,可以用环境变量控制ASan的行为。比如 ASAN_OPTIONS=detect_leaks=1:exitcode=1,这样一旦检测到泄漏,测试进程会返回非零退出码,CI就能自动捕获。

实战:在单元测试中集成Valgrind

Valgrind不需要重新编译,但跑起来慢很多。我一般在CI的定时任务里用它做深度检查。

用法也很简单:

# 直接运行测试程序
valgrind --leak-check=full \
         --show-leak-kinds=all \
         --track-origins=yes \
         --error-exitcode=1 \
         ./my_unit_tests

参数说明:

  • --leak-check=full:开启完整泄漏检测
  • --show-leak-kinds=all:显示所有类型的泄漏
  • --track-origins=yes:追踪未初始化值的来源
  • --error-exitcode=1:发现错误时返回1(方便CI判断)

我曾经在一个项目里,用Valgrind跑了一整夜的测试,抓出了7个隐藏的内存泄漏。其中一个是在异常处理路径上忘记释放资源——正常路径没问题,但一旦抛出异常,内存就泄漏了。这种问题,靠人工review很难发现。

注意:Valgrind在macOS上支持不太好,建议在Linux环境下使用。另外,如果你的测试程序用了大量多线程,Valgrind会跑得非常慢,甚至可能超时。这时候可以考虑用Helgrind或DRD做线程相关的检测。

在CMake中集成内存检测

我习惯在CMake里加一个选项,让开发者可以一键开启内存检测:

option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
option(ENABLE_VALGRIND "Enable Valgrind testing" OFF)

if(ENABLE_ASAN)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
endif()

if(ENABLE_VALGRIND)
    # 添加一个自定义测试目标
    add_test(NAME memcheck
             COMMAND valgrind --leak-check=full --error-exitcode=1
             $<TARGET_FILE:my_unit_tests>)
endif()

这样,开发者可以这样用:

# 开发时快速检查
cmake -DENABLE_ASAN=ON ..
make && ./my_unit_tests

# CI中深度检查
cmake -DENABLE_VALGRIND=ON ..
make && ctest -R memcheck

常见的内存问题类型

根据我的经验,单元测试中最常见的内存问题有这几类:

  1. 直接泄漏:new了没delete,malloc了没free
  2. 间接泄漏:容器里的指针没释放
  3. use-after-free:释放后继续使用指针
  4. 缓冲区溢出:写入超过分配的大小
  5. 未初始化读取:读取了没赋值的变量

其中,间接泄漏是最容易被忽视的。比如你有一个 std::vector<int*>,清空vector时,里面的指针指向的内存并不会自动释放。你得手动遍历一遍,逐个delete。

避坑指南:我曾经在一个项目里,用智能指针替换了所有裸指针,结果内存泄漏率下降了90%。如果你还在用裸指针管理资源,强烈建议换成 std::unique_ptrstd::shared_ptr。这比任何检测工具都管用。

内存检测的知识体系

下面这张图,是我对内存泄漏检测在单元测试中应用的整体理解:

内存泄漏检测在单元测试中的集成 目标:自动化检测内存问题 AddressSanitizer (ASan) Valgrind 速度快(2-3倍) 需重新编译 适合开发阶段 速度慢(20-30倍) 无需重新编译 适合CI深度检查 检测泄漏/越界 use-after-free 未初始化读取 集成方式:CMake选项 + CI流水线 + 环境变量控制

写在最后

内存泄漏检测不是锦上添花,而是单元测试的必修课

我个人建议的落地步骤:

  1. 先把ASan集成到本地开发环境,每次跑测试都带上
  2. 然后在CI里加上Valgrind的定时任务,做深度检查
  3. 最后,逐步用智能指针替换裸指针,从源头减少泄漏

这三步走完,你的代码质量会上一个台阶。相信我,线上服务的内存曲线会感谢你的。

最后提醒一句:工具再强大,也替代不了良好的编码习惯。RAII(资源获取即初始化)和智能指针,才是根治内存问题的王道。

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