
一、内存泄漏检测工具本质上要解决什么问题先看最简单的内存泄漏int *p new int(10); // 忘记delete这里的问题是申请了一块内存 ↓ 程序已经不再使用 ↓ 但是没有释放如果这种情况不断发生申请 申请 申请 申请 ...进程占用的内存就会不断增加。那么如果让我们设计一个工具最直接的思路就是每次申请内存 ↓ 记录下来 每次释放内存 ↓ 把对应记录删除 程序结束 ↓ 还没有被删除的记录 ↓ 就是疑似泄漏例如void *p1 malloc(100); void *p2 malloc(200); free(p1);内部记录变化malloc(100) 记录 p1 → 100字节然后malloc(200) 记录 p1 → 100字节 p2 → 200字节执行free(p1);变成记录 p2 → 200字节程序结束以后p2仍然存在那么就可以报告发现200字节疑似内存泄漏所以整个检测工具最核心的问题其实是如何记录当前所有“已经申请但还没有释放”的内存块这就是数据结构选择的关键。二、最适合的数据结构为什么是哈希表我们需要记录的信息大概包括内存地址 分配大小 文件名 代码行号 分配时间 线程ID 调用栈可以设计一个结构struct AllocationInfo { size_t size; const char *file; int line; unsigned long threadId; };然后需要建立内存地址 ↓ AllocationInfo之间的映射。例如0x1000 ↓ { size 128 file main.cpp line 25 }最合适的基础数据结构通常就是std::unordered_mapvoid *, AllocationInfo也就是哈希表例如std::unordered_mapvoid *, AllocationInfo allocations;当申请void *p malloc(100);记录allocations[p] info;释放free(p);删除allocations.erase(p);为什么不用std::vector呢假设已经记录了100000个内存块释放某一块free(0x123456);如果使用 vector需要从头开始寻找地址查找复杂度通常O(N)而哈希表根据内存地址计算hash ↓ 直接找到对应记录平均查找复杂度O(1)插入也是O(1)删除也是O(1)所以非常适合这种频繁插入 频繁查找 频繁删除的场景。因此面试中如果问你会选择什么数据结构可以直接回答我会使用哈希表以内存地址作为 key分配信息作为 value。因为内存申请和释放都非常频繁需要快速插入、查询和删除unordered_map 平均 O(1) 的复杂度比较合适。结构可以理解成unordered_map ┌──────────┬────────────────────────┐ │ 地址 │ 分配信息 │ ├──────────┼────────────────────────┤ │ 0x1000 │ size64, main.cpp:20 │ ├──────────┼────────────────────────┤ │ 0x2000 │ size128, test.cpp:50 │ ├──────────┼────────────────────────┤ │ 0x3000 │ size256, net.cpp:100 │ └──────────┴────────────────────────┘三、怎么拦截malloc/free或者new/delete知道要记录以后下一个问题就是怎么知道程序什么时候malloc了 怎么知道什么时候free了最简单的教学版本可以自己封装void *debugMalloc(size_t size, const char *file, int line) { void *ptr malloc(size); if (ptr) { // 记录 } return ptr; }释放void debugFree(void *ptr) { // 删除记录 free(ptr); }然后定义宏#define DEBUG_MALLOC(size) \ debugMalloc(size, __FILE__, __LINE__)使用int *p static_castint *(DEBUG_MALLOC(sizeof(int)));这样就可以自动获得__FILE__和__LINE__例如main.cpp 42于是泄漏报告可以做到Leak detected Address: 0x123456 Size: 100 bytes File: main.cpp Line: 42如果是 C 的new delete也可以考虑重载operator new 重载operator delete例如void *operator new(std::size_t size) { void *ptr std::malloc(size); // 记录ptr和size if (!ptr) { throw std::bad_alloc(); } return ptr; }释放void operator delete(void *ptr) noexcept { // 删除记录 std::free(ptr); }但是这里马上会出现一个非常经典的问题记录内存的时候 unordered_map本身也可能需要malloc例如operator new() ↓ 记录到unordered_map ↓ unordered_map扩容 ↓ 内部调用new ↓ 再次进入operator new() ↓ 再次记录 ↓ 无限递归也就是内存检测器 为了记录内存 自己又申请内存这就是实际实现中必须考虑的问题。一种简化处理方式是设置线程局部递归保护标志例如thread_local bool g_inHook false;逻辑if (g_inHook) { return std::malloc(size); } g_inHook true; // 记录 g_inHook false;可以理解成第一次进入hook ↓ g_inHook true ↓ 记录过程中再次触发malloc ↓ 发现g_inHook已经是true ↓ 直接调用真正malloc ↓ 不再重复记录真实工具通常会采用更加完善的 Hook 和内部内存管理机制但面试中能够意识到检测器自己不能无限递归已经是一个比较重要的点。四、完整算法流程怎么设计可以先设计一个全局管理器#include unordered_map #include mutex #include cstdio struct AllocationInfo { size_t size; const char *file; int line; }; class MemoryTracker { private: std::unordered_mapvoid *, AllocationInfo allocations_; std::mutex mutex_; public: void add(void *ptr, size_t size, const char *file, int line) { if (!ptr) return; std::lock_guardstd::mutex lock(mutex_); allocations_[ptr] { size, file, line }; } void remove(void *ptr) { if (!ptr) return; std::lock_guardstd::mutex lock(mutex_); allocations_.erase(ptr); } void reportLeaks() { std::lock_guardstd::mutex lock(mutex_); size_t total 0; for (const auto item : allocations_) { void *ptr item.first; const AllocationInfo info item.second; printf( Leak: address%p size%zu file%s line%d\n, ptr, info.size, info.file, info.line ); total info.size; } printf( Leak blocks: %zu, total bytes: %zu\n, allocations_.size(), total ); } };整个核心算法非常简单。1. malloc/new申请内存 ↓ 得到地址ptr ↓ 构造AllocationInfo ↓ hash[ptr] info复杂度平均O(1)2. free/delete准备释放ptr ↓ hash.find(ptr) ↓ 找到对应记录 ↓ erase(ptr) ↓ 真正释放内存平均复杂度O(1)如果find(ptr) end说明这个地址并不存在于当前记录中。这时候就可以额外检测一些问题。例如Double Free或者释放了一个没有被工具记录的地址比如free(ptr); free(ptr);第一次erase成功第二次找不到ptr就可以输出Warning: invalid free or double free所以这个工具不仅可以检测Memory Leak还可以顺便发现Double Free Invalid Free3. 程序退出最终遍历unordered_map所有还存在的数据都代表没有对应free/delete于是输出泄漏信息。算法for each allocation: 输出地址 输出大小 输出文件 输出行号复杂度O(N)这里的 N 是程序退出时仍然没有释放的内存块数量整个检测流程程序启动 ↓ unordered_map为空 ↓ malloc/new ↓ 插入记录 ↓ free/delete ↓ 删除记录 ↓ 程序退出 ↓ 遍历剩余记录 ↓ 生成Leak Report五、真正做成工具还需要考虑哪些问题如果面试官继续追问这个方案还有什么问题这里就可以开始体现工程思维。1. 多线程安全多个线程可能同时malloc free例如Thread A ↓ allocations_[ptr] info同时Thread B ↓ allocations_.erase(ptr)unordered_map本身不是线程安全的。所以最简单的方法就是std::mutex保护。例如std::lock_guardstd::mutex lock(mutex_);但是这样又会产生新的问题所有malloc/free 都竞争同一个mutex如果程序每秒进行几十万次内存操作性能开销可能很大。更进一步可以考虑分片哈希表 Sharded Hash Table例如拆成64个bucket组每一组有自己的mutex unordered_map根据地址index hash(ptr) % 64;找到对应分片。这样Thread A操作bucket 1 Thread B操作bucket 30可以同时执行不需要竞争同一个锁。结构MemoryTracker Bucket0 ↓ mutex map Bucket1 ↓ mutex map Bucket2 ↓ mutex map ... Bucket63 ↓ mutex map这种设计能明显减少锁竞争2. 只知道地址和大小还不够如果最后报告Leak address 0x123456 size 1024其实帮助有限。更重要的是这块内存到底在哪里申请的所以最好记录调用栈 Stack Trace例如main() ↓ createUser() ↓ loadAvatar() ↓ malloc(1024)最终报告Leak 1024 bytes loadAvatar() createUser() main()这样就非常容易定位。Linux 下可以考虑backtrace或者使用libunwind获取调用栈。实际工具中通常还会进行地址符号化把0x7f123456转换成UserManager::createUser() user.cpp:125这样报告才真正有价值。3. 内存开销假设程序进行了100万个有效分配检测器如果每个记录存地址 大小 文件名 行号 线程ID 调用栈本身也会占用大量内存。所以真实工具需要考虑采样 压缩调用栈 共享字符串 地址去重例如很多内存都是从同一个调用栈申请的A → B → C → malloc没必要给每个 AllocationInfo 都保存一整份调用栈。可以调用栈 ↓ 计算hash ↓ 得到stack_idAllocationInfo 只保存struct AllocationInfo { size_t size; uint32_t stackId; };而另外维护stackId ↓ 完整调用栈这样可以降低空间占用。4. 如何区分真正泄漏和仍然存活的全局对象程序结束时仍然存在的内存不一定100%都是Bug有些可能是全局缓存 单例对象 第三方库内部缓存这些内存在进程结束时操作系统最终也会统一回收。因此严格来说没有free的内存可以称为疑似泄漏还需要结合对象生命周期 业务预期 调用栈进行分析。更高级的工具甚至会做可达性分析 Reachability Analysis例如虽然没有free 但仍然存在有效指针可以访问可能标记成still reachable而已经没有任何有效引用能够找到才更像真正意义上的definitely lostValgrind 这类工具就会做更复杂的分类。如果只是面试设计一个简化工具malloc记录 free删除 退出检查已经是非常合理的第一版。如果面试官问如果让你实现一个内存泄漏检测工具你会怎么做可以这样回答我会拦截程序中的内存申请和释放接口例如 malloc/free 或 new/delete。每次申请成功以后以内存地址作为 key把大小、文件名、行号、线程 ID 和调用栈等信息记录到哈希表中释放时根据地址在哈希表中查找并删除记录。程序退出时遍历哈希表剩余记录就是疑似没有释放的内存。因为申请和释放操作非常频繁我会优先使用 unordered_map使插入、查找和删除平均达到 O(1)。如果继续问为什么选unordered_map不选vector或者map可以回答核心操作是根据内存地址频繁插入、查找和删除。vector 查找地址需要 O(N)map 是红黑树操作复杂度是 O(logN)unordered_map 平均是 O(1)所以更适合做地址到分配信息的映射。如果问多线程怎么办可以回答最简单可以用 mutex 保护哈希表。如果担心所有 malloc/free 竞争同一把锁可以进一步使用分片哈希表根据指针地址的 hash 把记录分散到多个 bucket每个 bucket 使用独立锁从而减少锁竞争。如果继续问怎么知道具体是哪一行泄漏可以回答可以通过宏包装 malloc/new把__FILE__和__LINE__一起记录如果要做得更通用可以在分配时抓取调用栈保存 stack trace最终通过符号解析得到函数名和源码位置。最后把整个设计思路串起来拦截malloc / new ↓ 拿到ptr ↓ 记录 ptr size file line thread stack ↓ unordered_map ↓ free / delete ↓ 根据ptr查找 ↓ 删除记录 ↓ 程序结束 ↓ 遍历剩余记录 ↓ 输出内存泄漏报告如果进一步优化单个unordered_map mutex ↓ 锁竞争严重 ↓ 分片哈希表 ↓ 多个bucket 多把锁再继续增强只记录地址大小 ↓ 不好定位 ↓ 记录调用栈 ↓ 符号化 ↓ 输出函数名 文件 行号所以这道题真正考察的知识点其实很多malloc / free new / delete 哈希表 时间复杂度 线程安全 锁竞争 调用栈 Hook 程序生命周期一个面试级的内存泄漏检测工具不需要做成 Valgrind 那么复杂只要能够把分配时登记 释放时注销 结束时检查这三个核心动作讲清楚再说明为什么选择哈希表以及如何处理多线程整体设计就已经比较完整了。0voice · GitHub