当提及内存泄漏检测,ASan 和 Valgrind 往往是首选工具。然而,在实际开发场景中,你可能会遇到无法使用这些工具的情况:
- 在嵌入式设备上开发,没有 ASan 支持。
- 维护一个十年前的老项目,不能随意添加编译选项。
- 面试官明确要求:别用工具,手写方案。
这时候该怎么办?我的经验是,即使没有专用工具,通过代码埋点、系统观测和 GDB 调试,我们依然能够有效地定位并揪出内存泄漏点。下面这些方法,我用了多年,亲测有效。
一、预防:RAII 是底线
即使不借助外部工具,RAII(Resource Acquisition Is Initialization)依然是防止内存泄漏的第一道防线。

智能指针(unique_ptr/shared_ptr)必须成为你的默认选择。所有资源,包括文件描述符(fd)、Windows句柄(HANDLE)、内存映射(mmap)乃至 CUDA 显存,都应该被封装成具有明确作用域生命周期的对象。
auto file = std::unique_ptr<FILE, decltype(&fclose)>{fopen("log.txt", "r"), &fclose};
// 离开作用域时自动调用 fclose
如果你的代码中仍然直接使用 new/delete 而不进行任何封装,那么后续所有的检测手段都可能事倍功半。良好的内存管理习惯是基础。
二、检测:不用 ASan,怎么知道内存有没有泄漏?
专用工具能自动报告泄漏,但在无工具环境下,我们可以通过以下几种方法来判断内存是否泄漏。
方法 1:重载全局 new/delete,加计数器
这是最经典的手动方案之一,在程序退出前检查分配计数是否归零:
static std::atomic<size_t> g_alloc_count{0};
void* operator new(size_t size) {
++g_alloc_count;
return std::malloc(size);
}
void operator delete(void* p) noexcept {
if (p) { --g_alloc_count; std::free(p); }
}
程序退出前打印 g_alloc_count:
- 如果输出不是 0,说明存在内存泄漏。
- 在程序运行中,你也可以通过 GDB 附加进程来查看:
(gdb) p g_alloc_count
注:此方法只统计通过 operator new 进行的分配。如果代码中使用了 malloc,则需要额外重载。
方法 2:监控 /proc/PID/smaps 中的 Private_Dirty
内存占用(RSS)上涨不一定意味着泄漏,关键要看 Private_Dirty 指标,它表示进程私有且未被换出的脏内存。
watch -n1 'grep -s Private_Dirty /proc/$(pgrep your_app)/smaps | awk "{sum += \$2} END {print sum+0 \" KB\"}"'
执行一次特定的业务操作(例如处理 1000 个请求),观察 Private_Dirty 值。如果它呈现持续单向增长的趋势,基本可以判定存在内存泄漏。这个方法无需修改代码,非常适合线上应急排查。
方法 3:在 RAII 类里打日志,事后比对
为关键的资源类(如 Session、Connection)添加分配和释放的日志:
struct TrackedSession {
static std::atomic<size_t> alive;
TrackedSession() { ++alive; fprintf(stderr, "[+] Session created, total=%zu\n", alive.load()); }
~TrackedSession() { --alive; fprintf(stderr, "[-] Session destroyed, total=%zu\n", alive.load()); }
};
std::atomic<size_t> TrackedSession::alive{0};
程序运行结束后,分析日志:
grep "\[+\]" app.log | wc -l # 分配次数
grep "\[-\]" app.log | wc -l # 释放次数
如果分配次数大于释放次数,则说明有对象未被正确释放,存在泄漏。
三、定位:没有 ASan 调用栈,怎么找到泄漏点?
工具的优势在于能自动提供完整的调用栈。但我们完全可以结合手动埋点和 GDB 调试来实现近似的效果。
方法 1:分配时记录唯一 ID,GDB 反查
- 在分配内存时,记录一个唯一 ID 和地址:
[ALLOC] id=123, addr=0x55aabbccdd
- 在释放时记录:
[FREE] id=123
- 程序退出前,dump 出所有未释放的地址列表。
然后使用 GDB 附加到进程(或分析 core dump)进行反查:
gdb -p $(pgrep your_app)
(gdb) info symbol 0x55aabbccdd
如果编译时添加了 -g 调试选项,GDB 会告诉你这个地址属于哪个函数或变量,从而定位到源码位置。
方法 2:Linux 用 backtrace() 记录分配栈
在重载的 operator new 中捕获分配时刻的调用栈:
#include <execinfo.h>
thread_local void* g_last_alloc_bt[10];
thread_local int g_last_bt_size;
void* operator new(size_t size) {
void* p = malloc(size);
g_last_bt_size = backtrace(g_last_alloc_bt, 10); // 记录最近一次分配的调用栈
// 可以将 p 和 g_last_alloc_bt 存入一个全局映射表
return p;
}
当检测到泄漏时,对于未释放的地址,可以通过 backtrace_symbols() 函数将其对应的调用栈(g_last_alloc_bt)翻译成可读的函数名信息。这套方案虽不如 ASan 精准,但在无工具环境下已是强有力的定位手段。
四、不用工具,上线应急该怎么办?
线上环境往往不能重启,也无法临时添加 ASan。遇到疑似内存泄漏,我的应急排查流程如下:
1. 确认内存是否真的在上涨
watch -n2 'grep Private_Dirty /proc/$(pgrep your_app)/smaps | awk "{sum += \$2} END {print sum+0}"'
持续观察 Private_Dirty 的汇总值。
2. 复现问题
让测试人员或编写脚本,反复执行可疑的功能模块,尝试触发内存增长场景。
3. 初步判断
如果内存消耗随着操作执行而上涨,并且停不下来,那么十有八九是内存泄漏。
4. GDB 实时调试
直接附加 GDB 到线上进程(需谨慎):
gdb -p $(pgrep your_app)
5. 检查预埋的计数器
在 GDB 中打印之前埋下的资源计数器:
(gdb) p Session::alive_count
$1 = 1523
如果预期只有几十个活跃对象,但实际上有上千个,那么问题很可能就出在 Session 对象没有被正确释放。
6. 定位与修复
结合计数器、地址记录和 GDB 反查信息,最终定位到具体的泄漏代码行并进行修复。
五、最后说句实在话
ASan 等工具确实强大且方便,但真正的工程能力,恰恰体现在工具链不完善或缺失时,依然能利用系统特性和编程基本功来解决问题的能力。
上面介绍的这些方法,看似原始甚至有些“笨拙”,但它们却是无数嵌入式系统、遗留老项目赖以生存的真实之道。掌握这些手动检测与定位技能,能让你在面对各种复杂环境时更加从容。
你是否也有过在没有 ASan 的环境里,依靠 GDB 和日志“硬刚”内存泄漏的经历?欢迎在云栈社区与其他开发者交流你的实战经验。