24. 内存泄漏检测:Visual Studio诊断工具

内存泄漏这玩意儿,说大不大,说小不小。我见过线上服务跑了一周突然OOM,查了半天发现是某个定时器里new出来的对象没人管。也见过客户端软件越用越卡,最后用户骂娘——说白了,就是内存只借不还。

Visual Studio自带的诊断工具,其实挺强的。很多人只知道用它看CPU占用,却忽略了它抓内存泄漏的能力。今天我就带你把它用透。

24.1 诊断工具的基本用法

先说说怎么打开。调试的时候,点菜单栏的「调试」→「性能探测器」,或者直接按 Alt+F2。你会看到一个窗口,里面有几个选项:

  • CPU使用率:看哪个函数吃CPU
  • 内存使用率:看堆上分配了多少
  • 应用程序时间线:看UI响应情况

我们重点关注「内存使用率」。勾上它,点「开始」,程序就跑起来了。跑一会儿,点「获取快照」,就能看到当前堆上的内存分配情况。

我的习惯: 在可疑操作前后各拍一张快照。比如点了一个按钮之前拍一张,点完之后再拍一张。两张一对比,多出来的内存就是泄漏的嫌疑对象。

24.2 快照对比:找出泄漏的根

快照拍好了,怎么用?看下面这张图:

内存快照对比流程 快照1(操作前) 堆大小: 12.3MB 执行操作 点击按钮/加载数据 快照2(操作后) 堆大小: 18.7MB 差值 = 18.7 - 12.3 = 6.4MB(可疑泄漏) 快照2中新增的对象类型: • std::string 实例增加了 1200 个(约 2.1MB) • MyDataObject 实例增加了 350 个(约 4.3MB)

操作很简单:在「内存使用率」面板里,右键点击快照2,选「与快照1比较」。VS会自动算出差值,列出新增的对象类型和数量。

我遇到过最典型的情况:每次打开一个对话框,堆上就多出几个 std::string 和自定义对象。关闭对话框后,这些对象没被释放。一查代码,原来是对话框的析构函数里忘了 delete 某个成员指针。

24.3 查看堆分配详情

光知道多了哪些类型还不够,你得找到具体是哪行代码分配的内存。VS的诊断工具支持「查看分配调用堆栈」。

在快照对比结果里,双击某个类型(比如 MyDataObject),会弹出一个窗口,列出所有该类型的实例。选中一个实例,下面会显示它的分配调用堆栈:

// 假设你看到这样的调用堆栈
MyDataObject::MyDataObject() line 42
MainWindow::OnButtonClick() line 156
MainWindow::OnCommand() line 89
...

看到 MainWindow::OnButtonClick() line 156,你直接双击就能跳到源码。嗯,这里要注意:VS的调用堆栈有时候会缺帧,尤其是用了Release模式或者优化选项。我建议调试时用Debug模式,符号加载完整,堆栈才靠谱。

注意: 查看分配调用堆栈需要启用「堆分配跟踪」。在诊断工具启动前,点「设置」图标,勾上「启用堆分配跟踪」。不勾的话,你只能看到堆大小,看不到谁分配的。

24.4 使用CRT调试堆

除了诊断工具,Visual Studio还自带一套C运行时库的调试堆函数。说白了,就是 mallocnew 的调试版本,能帮你追踪内存泄漏。

用法很简单,在代码开头加上:

#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>

// 在程序入口处调用
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

程序退出时,如果输出窗口里出现类似这样的信息:

Detected memory leaks!
Dumping objects ->
{145} normal block at 0x0078C8A0, 40 bytes long.
Data: < ... > 
Object dump complete.

那恭喜你,抓到泄漏了。那个 {145} 是分配序号,你可以用 _CrtSetBreakAlloc(145) 让程序在分配第145块内存时断下来,直接看调用堆栈。

我的经验: 这个技巧在排查大型项目时特别好用。我曾经在一个百万行级的项目里,用 _CrtSetBreakAlloc 配合条件断点,半小时就定位到了泄漏点。比肉眼翻代码快多了。

24.5 实战:一个泄漏示例

光说不练假把式。我们写个简单的泄漏程序,然后用VS诊断工具抓它:

#include <vector>
#include <string>

class LeakyObject {
public:
    LeakyObject() {
        data = new char[1024];  // 分配了,但析构函数没释放
    }
    // 注意:没有定义析构函数!
private:
    char* data;
};

void SimulateLeak() {
    std::vector<LeakyObject*> objects;
    for (int i = 0; i < 100; ++i) {
        objects.push_back(new LeakyObject());
    }
    // 忘记 delete 每个对象
    // 也忘记 clear() 前先释放
}

int main() {
    SimulateLeak();
    // 程序退出时,100个LeakyObject泄漏
    return 0;
}

用VS诊断工具跑这个程序:

  1. SimulateLeak() 调用前拍快照1
  2. 调用后拍快照2
  3. 对比快照,看到新增了100个 LeakyObject 实例
  4. 双击查看调用堆栈,定位到 new LeakyObject() 那行

你看,整个过程不到两分钟。如果代码里写了析构函数 delete[] data,并且在 main 结束前遍历vector释放每个对象,泄漏就解决了。

24.6 常见陷阱与避坑指南

用VS诊断工具时,有几个坑我踩过,分享给你:

  • Release模式下的堆栈不准:编译器会内联函数、优化变量,导致调用堆栈残缺不全。调试时务必用Debug模式。
  • 第三方库的泄漏:有时候泄漏不在你的代码里,而在某个DLL里。VS诊断工具可以显示模块名,帮你区分是自家代码还是外部库的问题。
  • STL容器的误报:某些STL实现会在内部缓存已释放的内存,导致诊断工具报告「疑似泄漏」。这时候可以调低 _CRTDBG_ALLOC_MEM_DF 的检测级别,或者用 _CrtSetReportMode 过滤掉已知的误报。
我曾经踩过的坑: 有一次诊断工具报告了上千个泄漏,我慌了。仔细一看,全是 std::string 的内部缓冲区。原来是我在循环里频繁创建临时string,STL的small string optimization(SSO)导致一些缓冲区没被立即回收。这不是真正的泄漏,只是诊断工具的「过度敏感」。后来我改用 _CrtMemCheckpoint 做精确对比,才过滤掉这些噪音。

24.7 总结一下

Visual Studio的诊断工具,说白了就是三招:

工具 适用场景 优点 缺点
内存使用率快照 快速定位泄漏类型 操作简单,可视化强 需要手动对比
分配调用堆栈 定位泄漏代码行 精确到源码行 需要启用堆跟踪
CRT调试堆 自动化检测泄漏 程序退出时自动报告 有误报可能

我个人习惯是:先用快照对比看有没有泄漏,再用调用堆栈定位代码,最后用CRT调试堆做回归验证。三管齐下,基本没有抓不到的泄漏。

你想想看,如果每次写完代码都能顺手跑一遍诊断工具,很多线上问题根本不会发生。嗯,这就是专业和业余的区别。


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