内存泄漏检测与管理 时间:2026/04/16
关键词:RAII、unique_ptr、shared_ptr 循环引用、Sanitizer、Valgrind、资源封装 核心目标:把“谁释放、何时释放、怎么定位泄漏”变成工程上可检查的规则。
1. 什么才叫内存泄漏 最典型的内存泄漏是:
一块堆内存已经没有任何有效路径再访问它
但它也永远不会被释放
例如:
1 2 3 void bad () { int * p = new int (42 ); }
函数结束后,p 没了,这块内存也没人能再 delete。 这就是最标准的泄漏。
但工程里还要区分另一类问题:
对象严格来说还“可达”
但长期不释放,内存占用持续上涨
这未必是严格意义上的 leak,但同样会把服务拖垮。
2. 最常见的泄漏来源 2.1 裸 new / delete 配对失败 最常见的问题不是“不会 delete”,而是:
提前 return
中途 throw
多分支路径漏掉释放
2.2 容器里放 owning raw pointer 1 2 std::vector<Foo*> items; items.push_back (new Foo ());
这种写法会把“谁来删”变成记忆题。
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 2 3 4 5 6 7 8 9 #include <memory> struct Widget {};Widget* create_widget (bool failed) { Widget* p = new Widget (); if (failed) return nullptr ; return p; }
问题不在 new 本身,而在:
更稳妥的写法:
1 2 3 4 5 6 7 8 9 #include <memory> struct Widget {};std::unique_ptr<Widget> create_widget (bool failed) { auto p = std::make_unique <Widget>(); if (failed) return nullptr ; return p; }
这样即使中途提前返回,局部对象也会自动清理。
5. 智能指针也不是绝对安全 unique_ptr 很少造成泄漏,真正容易出问题的是 shared_ptr。
1 2 3 4 5 6 #include <memory> struct Node { std::shared_ptr<Node> next; std::shared_ptr<Node> prev; };
如果两个节点互相持有,引用计数就会卡住。
更常见的修正方式是:
1 2 3 4 5 6 #include <memory> struct Node { std::shared_ptr<Node> next; std::weak_ptr<Node> prev; };
经验上:
形成树状、图状、双向关系时,先主动检查是否存在环
“回指”通常更适合 weak_ptr
6. 泄漏和“内存一直涨”不是一回事 下面这些情况不一定是严格意义上的 leak:
全局缓存只增不减
std::vector / std::string 容量长期不回收
任务队列积压
对象池尺寸只扩不缩
它们的问题是:
所以排查内存问题时,要先分清两类:
对象已经不可达,但没释放
对象还可达,但系统把它留得太久
前者更像 bug,后者更像管理问题,但两者都要处理。
7. 怎么检测泄漏 7.1 先上 Sanitizer 本地开发最常用的办法通常是编译时打开 Sanitizer:
1 2 clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address main.cpp -o app ASAN_OPTIONS=detect_leaks=1 ./app
如果你的工具链把 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 2 cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build
8.2 Memcheck 查内存泄漏 最常用命令:
1 2 3 4 5 6 7 valgrind \ --tool=memcheck \ --leak-check=full \ --show-leak-kinds=all \ --track-origins=yes \ --num-callers=30 \ ./app
常用参数:
--tool=memcheck:使用内存检查工具,默认也是它
--leak-check=full:显示每个泄漏点的详细调用栈
--show-leak-kinds=all:显示所有泄漏类型
--track-origins=yes:追踪未初始化值来自哪里,速度更慢但定位更准
--num-callers=30:调用栈显示更多层
如果希望 CI 或脚本根据是否有泄漏返回失败,可以加:
1 2 3 4 valgrind \ --leak-check=full \ --error-exitcode=1 \ ./app
只要 Valgrind 检测到错误,进程退出码就是 1,方便自动化检查。
8.3 一个最小泄漏例子 示例代码:
1 2 3 4 5 6 7 8 9 10 11 #include <iostream> void leak () { int * p = new int [10 ]; p[0 ] = 42 ; } int main () { leak (); std::cout << "done\n" ; }
编译并运行:
1 2 g++ -std=c++20 -g -O0 leak.cpp -o leak valgrind --leak-check=full --show-leak-kinds=all ./leak
典型输出里会看到类似信息:
1 2 3 4 5 6 7 8 9 10 HEAP SUMMARY: in use at exit: 40 bytes in 1 blocks 40 bytes in 1 blocks are definitely lost at 0x...: operator new[](unsigned long) by 0x...: leak() (leak.cpp:4) by 0x...: main (leak.cpp:9) LEAK SUMMARY: definitely lost: 40 bytes in 1 blocks
重点看两处:
definitely lost:明确泄漏,优先修
栈回溯:从 operator new[] 往下看,找到自己代码里的分配位置
修正方式:
1 2 3 4 5 6 7 8 9 10 11 12 #include <iostream> #include <memory> void no_leak () { auto p = std::make_unique <int []>(10 ); p[0 ] = 42 ; } int main () { no_leak (); std::cout << "done\n" ; }
更推荐从所有权上修掉问题,而不是简单在所有路径上补 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 2 3 int * p = new int [3 ];p[3 ] = 10 ; delete [] p;
Valgrind 可能报告:
越界读 1 2 3 int * p = new int [3 ];int x = p[3 ]; delete [] p;
Valgrind 可能报告:
Use After Free 1 2 3 int * p = new int (42 );delete p;std::cout << *p << "\n" ;
Valgrind 可能报告:
1 2 Invalid read of size 4 Address ... is 0 bytes inside a block of size 4 free'd
重复释放 1 2 3 int * p = new int (42 );delete p;delete p;
Valgrind 可能报告:
1 Invalid free() / delete / delete[] / realloc()
new / delete[] 不匹配1 2 int * p = new int [10 ];delete p;
Valgrind 可能报告:
1 Mismatched free() / delete / delete []
使用未初始化值 1 2 3 4 int x;if (x > 0 ) { std::cout << x << "\n" ; }
Valgrind 可能报告:
1 Conditional jump or move depends on uninitialised value(s)
这种问题建议加 --track-origins=yes,否则只知道哪里用了未初始化值,不一定知道它最早从哪里来。
8.6 多进程和子进程 如果程序会 fork 或启动子进程,可以加:
1 2 3 4 valgrind \ --trace-children=yes \ --leak-check=full \ ./app
--trace-children=yes 会让 Valgrind 继续跟踪子进程。
如果子进程很多,输出会很乱,建议先缩小测试范围,或者给不同进程写不同日志:
1 2 3 4 valgrind \ --trace-children=yes \ --log-file=valgrind.%p.log \ ./app
其中 %p 会替换成进程 ID。
8.7 多线程程序能用 Memcheck 吗 能。
Memcheck 可以检查多线程程序里的:
泄漏
越界访问
use-after-free
double free
未初始化内存使用
但 Memcheck 不是专门的数据竞争检测器。
也就是说:
1 2 多线程程序的内存错误 -> Memcheck 能查一部分 多线程数据竞争 / 锁问题 -> 用 Helgrind 或 DRD
8.8 Helgrind 查数据竞争 Helgrind 用来检查线程之间是否存在 data race。
典型命令:
1 2 3 4 valgrind \ --tool=helgrind \ --history-level=full \ ./app
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <thread> int counter = 0 ;void worker () { for (int i = 0 ; i < 100000 ; ++i) { ++counter; } } int main () { std::thread t1 (worker) ; std::thread t2 (worker) ; t1. join (); t2. join (); }
编译时要带 pthread:
1 2 g++ -std=c++20 -g -O0 race.cpp -pthread -o race valgrind --tool=helgrind ./race
典型输出里会看到:
1 2 Possible data race during read of size 4 Possible data race during write of size 4
核心意思是:
至少两个线程访问了同一块内存
至少一个访问是写
Helgrind 没看到足够的同步关系
修正方式之一是加锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include <mutex> #include <thread> int counter = 0 ;std::mutex m; void worker () { for (int i = 0 ; i < 100000 ; ++i) { std::lock_guard<std::mutex> lock (m) ; ++counter; } } int main () { std::thread t1 (worker) ; std::thread t2 (worker) ; t1. join (); t2. join (); }
如果只是计数器,也可以用原子变量:
1 2 3 4 5 6 7 8 9 10 #include <atomic> #include <thread> std::atomic<int > counter{0 }; void worker () { for (int i = 0 ; i < 100000 ; ++i) { counter.fetch_add (1 , std::memory_order_relaxed); } }
8.9 Helgrind 能发现哪些线程问题 Helgrind 常见能发现:
读写同一变量但没有锁
一个线程写、另一个线程读但没有同步
锁顺序不一致,存在潜在死锁风险
pthread mutex 使用错误
条件变量使用不当
例如锁顺序不一致:
1 2 3 4 5 6 7 8 9 10 11 12 std::mutex a; std::mutex b; void f1 () { std::lock_guard<std::mutex> l1 (a) ; std::lock_guard<std::mutex> l2 (b) ; } void f2 () { std::lock_guard<std::mutex> l1 (b) ; std::lock_guard<std::mutex> l2 (a) ; }
f1 是先锁 a 再锁 b,f2 是先锁 b 再锁 a。 如果两个线程同时执行,就可能互相等待。
修正方式:
1 2 3 void safe () { std::scoped_lock lock (a, b) ; }
std::scoped_lock 可以一次性锁多个 mutex,避免手写锁顺序。
8.10 DRD 查多线程问题 DRD 和 Helgrind 类似,也用于检查多线程程序。
命令:
1 2 3 valgrind \ --tool=drd \ ./app
一般经验:
helgrind 对锁顺序、happens-before 分析比较常用
drd 对 pthread 使用问题也比较敏感
两者可能报告不同问题
多线程疑难问题可以两个都跑一遍
DRD 常见参数:
1 2 3 4 5 6 valgrind \ --tool=drd \ --check-stack-var=yes \ --exclusive-threshold=100 \ --shared-threshold=100 \ ./app
含义粗略理解:
--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 2 3 4 valgrind \ --leak-check=full \ --gen-suppressions=all \ ./app
把确认要忽略的规则保存到:
运行时使用:
1 2 3 4 valgrind \ --leak-check=full \ --suppressions=valgrind.supp \ ./app
注意:
suppression 只应该用于降低噪音
不要把自己业务代码里的真实泄漏压掉
每条 suppression 最好写注释说明为什么忽略
8.13 和 Sanitizer 怎么配合 Sanitizer 和 Valgrind 不是谁替代谁。
更实用的分工:
工具
优点
缺点
适合
ASan / LSan
快,适合开发和 CI
需要重新编译,某些环境受限
新代码、单元测试
TSan
查数据竞争强
慢,内存开销大,需要编译插桩
多线程数据竞争
Valgrind Memcheck
不需要插桩,历史二进制也能查一部分
很慢,对平台支持有限
存量代码、泄漏排查
Helgrind / DRD
不需要 TSan 编译,能查线程问题
慢,可能误报
多线程疑难问题辅助排查
一个常见工程策略:
1 2 3 开发阶段:ASan / LSan / TSan 存量排查:Valgrind Memcheck 多线程疑难问题:TSan + Helgrind / DRD 交叉验证
8.14 实战排查流程 推荐流程:
先写一个能稳定复现问题的最小场景
用 Debug 构建编译,保留 -g
先跑 memcheck,修掉明确内存错误
再看 definitely lost 和 indirectly lost
如果是多线程问题,再跑 helgrind 或 drd
对每条报告定位到自己的代码调用栈
修复后重新跑同一个场景确认报告消失
最后把关键场景固化成测试
命令模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 valgrind --tool=memcheck \ --leak-check=full \ --show-leak-kinds=all \ --track-origins=yes \ --error-exitcode=1 \ ./app valgrind --tool=helgrind ./app valgrind --tool=drd ./app
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 时,最关键的是:
把这三条路径串起来,通常就能看出生命周期哪里错了。
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。