实战:内存泄漏检测(重载new/delete+栈回溯)
内存泄漏,说白了就是程序申请了内存,但忘了还回去。你想想看,如果每次申请都不释放,日积月累,内存就被吃光了。我刚开始写C++那会儿,就吃过这个亏——一个后台服务跑了三天,内存从200MB飙到了2GB,最后直接OOM被系统杀掉。从那以后,我就养成了给代码加内存检测的习惯。
今天咱们就来手写一个轻量级的内存泄漏检测工具。核心思路就两条:重载全局的new和delete,再加上栈回溯定位泄漏点。
基本原理
C++允许我们重载全局的operator new和operator delete。我们可以在分配内存时,把地址、大小、调用栈等信息记录下来;释放时再把这些记录删掉。程序退出时,如果还有没释放的记录,那就是泄漏了。
嗯,这里要注意:重载全局new/delete会影响整个程序,包括STL容器。所以实现时要特别小心,避免递归调用。
核心数据结构
我们需要一个线程安全的哈希表,来管理所有分配记录。我个人习惯用std::unordered_map加互斥锁。
#include <mutex>
#include <unordered_map>
#include <string>
#include <cstdlib>
#include <cstring>
#include <execinfo.h> // Linux栈回溯
struct AllocRecord {
size_t size;
char stackTrace[4096]; // 保存调用栈信息
};
std::mutex g_mutex;
std::unordered_map<void*, AllocRecord> g_allocs;
这里stackTrace我用固定大小的字符数组,而不是std::string。为什么?因为std::string内部也会分配内存,万一在new里面用string,就死循环了。我在项目中遇到过这种坑,排查了半天才发现是递归调用。
重载new和delete
先看new的重载:
void* operator new(size_t size) {
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
// 获取栈回溯
void* buffer[128];
int frames = backtrace(buffer, 128);
char** symbols = backtrace_symbols(buffer, frames);
AllocRecord record;
record.size = size;
record.stackTrace[0] = '\0';
// 拼接调用栈信息
for (int i = 0; i < frames; ++i) {
strncat(record.stackTrace, symbols[i],
sizeof(record.stackTrace) - strlen(record.stackTrace) - 1);
strncat(record.stackTrace, "\n",
sizeof(record.stackTrace) - strlen(record.stackTrace) - 1);
}
free(symbols);
// 加锁保存记录
std::lock_guard<std::mutex> lock(g_mutex);
g_allocs[ptr] = record;
return ptr;
}
delete的重载相对简单:
void operator delete(void* ptr) noexcept {
if (!ptr) return;
{
std::lock_guard<std::mutex> lock(g_mutex);
g_allocs.erase(ptr);
}
std::free(ptr);
}
注意这里operator delete不能抛异常,所以用noexcept。而且要先从map里删除记录,再释放内存——顺序不能反。
泄漏检测与报告
程序退出时,我们检查g_allocs是否为空。不为空就把泄漏信息打印出来:
void checkLeaks() {
std::lock_guard<std::mutex> lock(g_mutex);
if (g_allocs.empty()) {
printf("[OK] 没有内存泄漏\n");
return;
}
printf("[LEAK] 发现 %zu 处内存泄漏!\n", g_allocs.size());
size_t totalLeaked = 0;
for (const auto& [ptr, record] : g_allocs) {
totalLeaked += record.size;
printf("地址: %p, 大小: %zu 字节\n", ptr, record.size);
printf("调用栈:\n%s\n", record.stackTrace);
}
printf("总计泄漏: %zu 字节\n", totalLeaked);
}
我建议在程序入口和出口分别调用checkLeaks()。比如在main()函数开头调用一次清空状态,结尾再调用一次检查泄漏。
完整示例
把上面的代码整合一下,写个测试程序:
// 在文件顶部包含上述代码
// 然后写测试代码
class TestClass {
int* data;
public:
TestClass() {
data = new int[100]; // 故意泄漏
}
~TestClass() {
// 忘记 delete[] data;
}
};
int main() {
// 先清空可能存在的初始记录
checkLeaks();
TestClass* obj = new TestClass();
delete obj; // 只释放了对象,内部数组泄漏了
// 检查泄漏
checkLeaks();
return 0;
}
运行后输出类似:
[OK] 没有内存泄漏
[LEAK] 发现 1 处内存泄漏!
地址: 0x55555556a2a0, 大小: 400 字节
调用栈:
./test(main+0x1e) [0x5555555552e5]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf3) [0x7ffff7a2c083]
./test(_start+0x2e) [0x5555555551ae]
总计泄漏: 400 字节
看到没?TestClass构造函数里new int[100]没释放,被我们抓到了。而且调用栈直接定位到了main函数,方便我们快速修复。
避坑指南
我曾经在重载new时犯过一个低级错误——在new里面用了std::cout打印日志。结果std::cout内部也会分配内存,又调用了我的new,直接栈溢出。所以记住:重载new/delete里不要用任何STL容器或IO操作,除非你能确保它们不会触发内存分配。
另外,多线程环境下一定要加锁。不加锁的话,两个线程同时new,map的插入操作会数据竞争,轻则丢记录,重则crash。
SVG流程图:内存泄漏检测核心逻辑
进阶优化
上面的实现虽然能用,但有几个可以优化的地方:
- 性能问题:每次new都获取栈回溯,开销不小。我建议只在Debug模式下启用,Release模式关掉。
- 符号解析:
backtrace_symbols返回的是地址,不够直观。可以用addr2line或dladdr解析出文件名和行号。 - 内存对齐:标准new返回的内存地址至少是8字节对齐的。用
std::malloc没问题,但如果要支持align_val_t重载,就得用std::aligned_alloc。
-rdynamic编译选项,这样栈回溯里的符号名会更完整,不会出现一堆??。
malloc/free的泄漏。如果要检测C的内存泄漏,得用LD_PRELOAD劫持malloc,那是另一个话题了。
总结
重载new/delete加栈回溯,是C++内存泄漏检测的经典方案。它轻量、可控,适合嵌入到自己的项目中。当然,生产环境我更推荐用Valgrind或AddressSanitizer,它们更全面。但理解底层原理,能让你在遇到诡异的内存问题时,多一条排查思路。
嗯,今天就聊到这儿。代码我已经整理好了,你可以直接拿去用。记住:内存泄漏就像房间里的灰尘,不打扫不会消失,只会越积越多。