内存泄漏检测与管理
内存泄漏检测与管理
时间:2026/04/16
关键词:RAII、
unique_ptr、shared_ptr循环引用、Sanitizer、Valgrind、资源封装
核心目标:把“谁释放、何时释放、怎么定位泄漏”变成工程上可检查的规则。
1. 什么才叫内存泄漏
最典型的内存泄漏是:
- 一块堆内存已经没有任何有效路径再访问它
- 但它也永远不会被释放
例如:
1 | void bad() { |
函数结束后,p 没了,这块内存也没人能再 delete。
这就是最标准的泄漏。
但工程里还要区分另一类问题:
- 对象严格来说还“可达”
- 但长期不释放,内存占用持续上涨
这未必是严格意义上的 leak,但同样会把服务拖垮。
2. 最常见的泄漏来源
2.1 裸 new / delete 配对失败
最常见的问题不是“不会 delete”,而是:
- 提前
return - 中途
throw - 多分支路径漏掉释放
2.2 容器里放 owning raw pointer
1 | std::vector<Foo*> items; |
这种写法会把“谁来删”变成记忆题。
2.3 shared_ptr 循环引用
两个对象互相持有 shared_ptr,引用计数永远不会归零。
2.4 C 风格资源没及时封装
比如:
FILE*malloc/free- socket / fd
- 第三方库句柄
如果它们在业务代码里裸奔,后面很容易漏掉释放。
3. 第一原则:先别写出会泄漏的代码
现代 C++ 管理泄漏,重点不是“人工记得回收”,而是默认采用不容易泄漏的结构。
优先顺序通常是:
- 能值语义就值语义
- 能栈对象就栈对象
- 必须动态分配时优先
std::unique_ptr - 确实需要共享拥有时才用
std::shared_ptr - 裸指针和引用默认只表达观察,不表达拥有
这背后的核心思想就是 RAII:
- 对象析构时自动释放资源
只要生命周期跟对象绑在一起,泄漏风险会大幅下降。
4. 一个典型泄漏例子
错误写法:
1 |
|
问题不在 new 本身,而在:
- 释放依赖调用路径是否完整
更稳妥的写法:
1 |
|
这样即使中途提前返回,局部对象也会自动清理。
5. 智能指针也不是绝对安全
unique_ptr 很少造成泄漏,真正容易出问题的是 shared_ptr。
1 |
|
如果两个节点互相持有,引用计数就会卡住。
更常见的修正方式是:
1 |
|
经验上:
- 形成树状、图状、双向关系时,先主动检查是否存在环
- “回指”通常更适合
weak_ptr
6. 泄漏和“内存一直涨”不是一回事
下面这些情况不一定是严格意义上的 leak:
- 全局缓存只增不减
std::vector/std::string容量长期不回收- 任务队列积压
- 对象池尺寸只扩不缩
它们的问题是:
- 生命周期策略不合理
- 上限控制缺失
所以排查内存问题时,要先分清两类:
- 对象已经不可达,但没释放
- 对象还可达,但系统把它留得太久
前者更像 bug,后者更像管理问题,但两者都要处理。
7. 怎么检测泄漏
7.1 先上 Sanitizer
本地开发最常用的办法通常是编译时打开 Sanitizer:
1 | clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address main.cpp -o app |
如果你的工具链把 leak 检测拆开,也可以按需使用:
-fsanitize=leak
它的优点是:
- 定位快
- 栈回溯清楚
- 很适合集成到测试里
7.2 再用 Valgrind 看存量问题
1 | valgrind --leak-check=full --show-leak-kinds=all ./app |
它更慢,但对一些历史代码排查仍然很有价值。
7.3 别只测“正常退出”
很多泄漏只在这些场景出现:
- 异常路径
- 超时取消
- 重试逻辑
- 长时间运行
- 高并发压力
所以测试不能只跑正常路径。
8. Valgrind 基础应用
Valgrind 是一套动态分析工具,最常用的是:
memcheck:检查内存泄漏、越界访问、使用未初始化内存、重复释放等helgrind:检查多线程数据竞争、锁使用问题drd:检查多线程数据竞争和线程 API 使用问题massif:分析堆内存占用峰值callgrind:分析函数调用和性能热点
平时排查 C++ 内存问题,先掌握 memcheck 就够;涉及多线程再看 helgrind 和 drd。
注意:
- Valgrind 主要适合 Linux 环境
- macOS 上支持不如 Linux 稳定,尤其新系统和 Apple Silicon 经常不方便
- 如果本机是 macOS,工程实践里更推荐用 Linux 虚拟机、Docker、远程 Linux 机器或 WSL
- Valgrind 会让程序慢很多,通常比原始运行慢几十倍,不适合直接跑线上服务
8.1 编译时建议加调试信息
Valgrind 不需要重新编译插桩,但为了看到清楚的源码行号,建议这样编译:
1 | g++ -std=c++20 -g -O0 -fno-omit-frame-pointer main.cpp -o app |
参数含义:
-g:保留调试信息,Valgrind 才能显示文件名和行号-O0:关闭优化,回溯更接近源码-fno-omit-frame-pointer:保留栈帧信息,回溯更完整
如果项目使用 CMake,可以临时打开 Debug 构建:
1 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug |
8.2 Memcheck 查内存泄漏
最常用命令:
1 | valgrind \ |
常用参数:
--tool=memcheck:使用内存检查工具,默认也是它--leak-check=full:显示每个泄漏点的详细调用栈--show-leak-kinds=all:显示所有泄漏类型--track-origins=yes:追踪未初始化值来自哪里,速度更慢但定位更准--num-callers=30:调用栈显示更多层
如果希望 CI 或脚本根据是否有泄漏返回失败,可以加:
1 | valgrind \ |
只要 Valgrind 检测到错误,进程退出码就是 1,方便自动化检查。
8.3 一个最小泄漏例子
示例代码:
1 |
|
编译并运行:
1 | g++ -std=c++20 -g -O0 leak.cpp -o leak |
典型输出里会看到类似信息:
1 | HEAP SUMMARY: |
重点看两处:
definitely lost:明确泄漏,优先修- 栈回溯:从
operator new[]往下看,找到自己代码里的分配位置
修正方式:
1 |
|
更推荐从所有权上修掉问题,而不是简单在所有路径上补 delete[]。
8.4 Valgrind 的泄漏类型怎么看
Valgrind 常见泄漏分类:
| 类型 | 含义 | 优先级 |
|---|---|---|
definitely lost |
已经没有任何指针能指向这块内存,明确泄漏 | 最高 |
indirectly lost |
因为上层对象泄漏,导致它指向的子对象也泄漏 | 高 |
possibly lost |
Valgrind 只能找到疑似指针,比如指向内存中间位置 | 中 |
still reachable |
程序退出时仍然有指针能访问,严格说不一定是泄漏 | 低到中 |
suppressed |
被 suppression 规则忽略的报告 | 视情况 |
排查顺序通常是:
- 先修
definitely lost - 再看
indirectly lost - 根据业务判断
possibly lost - 最后再处理
still reachable
still reachable 常见于:
- 全局单例
- 进程级缓存
- 第三方库退出时没有显式释放的全局资源
- 日志库、线程池、运行时库内部对象
它不一定要立刻修,但如果服务长期运行内存持续上涨,就不能只因为它是 still reachable 就忽略。
8.5 Memcheck 不只查泄漏
Memcheck 还能查很多典型内存错误。
越界写
1 | int* p = new int[3]; |
Valgrind 可能报告:
1 | Invalid write of size 4 |
越界读
1 | int* p = new int[3]; |
Valgrind 可能报告:
1 | Invalid read of size 4 |
Use After Free
1 | int* p = new int(42); |
Valgrind 可能报告:
1 | Invalid read of size 4 |
重复释放
1 | int* p = new int(42); |
Valgrind 可能报告:
1 | Invalid free() / delete / delete[] / realloc() |
new / delete[] 不匹配
1 | int* p = new int[10]; |
Valgrind 可能报告:
1 | Mismatched free() / delete / delete [] |
使用未初始化值
1 | int x; |
Valgrind 可能报告:
1 | Conditional jump or move depends on uninitialised value(s) |
这种问题建议加 --track-origins=yes,否则只知道哪里用了未初始化值,不一定知道它最早从哪里来。
8.6 多进程和子进程
如果程序会 fork 或启动子进程,可以加:
1 | valgrind \ |
--trace-children=yes 会让 Valgrind 继续跟踪子进程。
如果子进程很多,输出会很乱,建议先缩小测试范围,或者给不同进程写不同日志:
1 | valgrind \ |
其中 %p 会替换成进程 ID。
8.7 多线程程序能用 Memcheck 吗
能。
Memcheck 可以检查多线程程序里的:
- 泄漏
- 越界访问
- use-after-free
- double free
- 未初始化内存使用
但 Memcheck 不是专门的数据竞争检测器。
也就是说:
1 | 多线程程序的内存错误 -> Memcheck 能查一部分 |
8.8 Helgrind 查数据竞争
Helgrind 用来检查线程之间是否存在 data race。
典型命令:
1 | valgrind \ |
示例代码:
1 |
|
编译时要带 pthread:
1 | g++ -std=c++20 -g -O0 race.cpp -pthread -o race |
典型输出里会看到:
1 | Possible data race during read of size 4 |
核心意思是:
- 至少两个线程访问了同一块内存
- 至少一个访问是写
- Helgrind 没看到足够的同步关系
修正方式之一是加锁:
1 |
|
如果只是计数器,也可以用原子变量:
1 |
|
8.9 Helgrind 能发现哪些线程问题
Helgrind 常见能发现:
- 读写同一变量但没有锁
- 一个线程写、另一个线程读但没有同步
- 锁顺序不一致,存在潜在死锁风险
- pthread mutex 使用错误
- 条件变量使用不当
例如锁顺序不一致:
1 | std::mutex a; |
f1 是先锁 a 再锁 b,f2 是先锁 b 再锁 a。
如果两个线程同时执行,就可能互相等待。
修正方式:
1 | void safe() { |
std::scoped_lock 可以一次性锁多个 mutex,避免手写锁顺序。
8.10 DRD 查多线程问题
DRD 和 Helgrind 类似,也用于检查多线程程序。
命令:
1 | valgrind \ |
一般经验:
helgrind对锁顺序、happens-before 分析比较常用drd对 pthread 使用问题也比较敏感- 两者可能报告不同问题
- 多线程疑难问题可以两个都跑一遍
DRD 常见参数:
1 | valgrind \ |
含义粗略理解:
--check-stack-var=yes:检查栈变量上的数据竞争,可能产生更多报告--exclusive-threshold:锁被某线程独占持有太久时报告--shared-threshold:读写锁共享持有太久时报告
初学时先不用加太多参数,先跑:
1 | valgrind --tool=drd ./app |
8.11 Helgrind / DRD 的误报和限制
多线程检测工具不是绝对真理。
常见误报来源:
- 使用了工具不认识的自定义同步原语
- lock-free 数据结构
- 原子操作和内存序比较复杂
- 第三方库内部同步方式特殊
- 线程池、协程运行时、系统库内部实现
排查时不要只看最后一行错误,要看:
- 哪块内存被多个线程访问
- 哪些线程在读写
- 是否真的缺少同步
- 是否所有访问都使用同一把锁
- 是否有 happens-before 关系
如果确认是第三方库内部误报,可以使用 suppression 文件过滤。
8.12 Suppression 文件
有些报告来自标准库、第三方库或已确认的无害路径。
可以用 suppression 文件减少噪音。
先生成候选规则:
1 | valgrind \ |
把确认要忽略的规则保存到:
1 | valgrind.supp |
运行时使用:
1 | valgrind \ |
注意:
- suppression 只应该用于降低噪音
- 不要把自己业务代码里的真实泄漏压掉
- 每条 suppression 最好写注释说明为什么忽略
8.13 和 Sanitizer 怎么配合
Sanitizer 和 Valgrind 不是谁替代谁。
更实用的分工:
| 工具 | 优点 | 缺点 | 适合 |
|---|---|---|---|
| ASan / LSan | 快,适合开发和 CI | 需要重新编译,某些环境受限 | 新代码、单元测试 |
| TSan | 查数据竞争强 | 慢,内存开销大,需要编译插桩 | 多线程数据竞争 |
| Valgrind Memcheck | 不需要插桩,历史二进制也能查一部分 | 很慢,对平台支持有限 | 存量代码、泄漏排查 |
| Helgrind / DRD | 不需要 TSan 编译,能查线程问题 | 慢,可能误报 | 多线程疑难问题辅助排查 |
一个常见工程策略:
1 | 开发阶段:ASan / LSan / TSan |
8.14 实战排查流程
推荐流程:
- 先写一个能稳定复现问题的最小场景
- 用 Debug 构建编译,保留
-g - 先跑
memcheck,修掉明确内存错误 - 再看
definitely lost和indirectly lost - 如果是多线程问题,再跑
helgrind或drd - 对每条报告定位到自己的代码调用栈
- 修复后重新跑同一个场景确认报告消失
- 最后把关键场景固化成测试
命令模板:
1 | # 1. 内存泄漏和内存错误 |
8.15 Valgrind 报告怎么看
看报告时按这个顺序:
错误类型
例如Invalid write、Invalid read、definitely lost、Possible data race。访问大小
例如size 4通常对应int,size 8可能对应指针或long long。错误地址属于哪里
Valgrind 经常会提示这块地址是在已释放内存里、堆块边界外,还是栈上。分配栈
这块内存在哪里new/malloc出来的。释放栈
如果是 use-after-free,要看它在哪里已经被释放。当前访问栈
这次非法读写发生在哪里。
实际修 bug 时,最关键的是:
1 | 分配位置 + 释放位置 + 出错访问位置 |
把这三条路径串起来,通常就能看出生命周期哪里错了。
9. 工程里的泄漏管理清单
真正有效的治理,通常不是靠某一个工具,而是靠几条长期规则:
- 新代码默认不写“拥有语义的裸指针”
- 业务代码里尽量不直接出现
new/delete - 工厂接口优先返回
std::unique_ptr - 容器里优先放对象或智能指针,不放“需要人工回收”的裸指针
- 第三方资源在进入系统边界时立即封装成 RAII 类型
shared_ptr关系图需要专门检查循环引用- 测试或 CI 中固定开启 Sanitizer 版本
- 对缓存、对象池、队列设置上限,而不是默认无限增长
这才叫“管理”,不是出了问题再临时抓日志。
10. 一页总结
最值得记住的五条:
- 泄漏治理的核心不是“记得释放”,而是明确所有权
- 默认优先值语义、栈对象和 RAII
- 动态分配优先
unique_ptr,不是裸指针 shared_ptr最大的风险是循环引用- 用 Sanitizer 和 Valgrind 查问题,不要靠肉眼猜
参考:https://zhuanlan.zhihu.com/p/15101814919
如果只记一句:
预防内存泄漏最有效的方法,不是手写更多
delete,而是让代码结构默认不需要手写delete。