内存泄漏检测与管理

内存泄漏检测与管理

时间:2026/04/16

关键词:RAII、unique_ptrshared_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++ 管理泄漏,重点不是“人工记得回收”,而是默认采用不容易泄漏的结构。

优先顺序通常是:

  1. 能值语义就值语义
  2. 能栈对象就栈对象
  3. 必须动态分配时优先 std::unique_ptr
  4. 确实需要共享拥有时才用 std::shared_ptr
  5. 裸指针和引用默认只表达观察,不表达拥有

这背后的核心思想就是 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 容量长期不回收
  • 任务队列积压
  • 对象池尺寸只扩不缩

它们的问题是:

  • 生命周期策略不合理
  • 上限控制缺失

所以排查内存问题时,要先分清两类:

  1. 对象已经不可达,但没释放
  2. 对象还可达,但系统把它留得太久

前者更像 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 检测拆开,也可以按需使用:

  • -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 就够;涉及多线程再看 helgrinddrd

注意:

  • 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 规则忽略的报告 视情况

排查顺序通常是:

  1. 先修 definitely lost
  2. 再看 indirectly lost
  3. 根据业务判断 possibly lost
  4. 最后再处理 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
Invalid write of size 4

越界读

1
2
3
int* p = new int[3];
int x = p[3]; // 越界
delete[] p;

Valgrind 可能报告:

1
Invalid read of size 4

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; // double free

Valgrind 可能报告:

1
Invalid free() / delete / delete[] / realloc()

new / delete[] 不匹配

1
2
int* p = new int[10];
delete p; // 错:应该 delete[]

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 再锁 bf2 是先锁 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
valgrind.supp

运行时使用:

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 实战排查流程

推荐流程:

  1. 先写一个能稳定复现问题的最小场景
  2. 用 Debug 构建编译,保留 -g
  3. 先跑 memcheck,修掉明确内存错误
  4. 再看 definitely lostindirectly lost
  5. 如果是多线程问题,再跑 helgrinddrd
  6. 对每条报告定位到自己的代码调用栈
  7. 修复后重新跑同一个场景确认报告消失
  8. 最后把关键场景固化成测试

命令模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 内存泄漏和内存错误
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--error-exitcode=1 \
./app

# 2. 多线程 data race
valgrind --tool=helgrind ./app

# 3. 另一种线程检查
valgrind --tool=drd ./app

8.15 Valgrind 报告怎么看

看报告时按这个顺序:

  1. 错误类型
    例如 Invalid writeInvalid readdefinitely lostPossible data race

  2. 访问大小
    例如 size 4 通常对应 intsize 8 可能对应指针或 long long

  3. 错误地址属于哪里
    Valgrind 经常会提示这块地址是在已释放内存里、堆块边界外,还是栈上。

  4. 分配栈
    这块内存在哪里 new / malloc 出来的。

  5. 释放栈
    如果是 use-after-free,要看它在哪里已经被释放。

  6. 当前访问栈
    这次非法读写发生在哪里。

实际修 bug 时,最关键的是:

1
分配位置 + 释放位置 + 出错访问位置

把这三条路径串起来,通常就能看出生命周期哪里错了。


9. 工程里的泄漏管理清单

真正有效的治理,通常不是靠某一个工具,而是靠几条长期规则:

  • 新代码默认不写“拥有语义的裸指针”
  • 业务代码里尽量不直接出现 new / delete
  • 工厂接口优先返回 std::unique_ptr
  • 容器里优先放对象或智能指针,不放“需要人工回收”的裸指针
  • 第三方资源在进入系统边界时立即封装成 RAII 类型
  • shared_ptr 关系图需要专门检查循环引用
  • 测试或 CI 中固定开启 Sanitizer 版本
  • 对缓存、对象池、队列设置上限,而不是默认无限增长

这才叫“管理”,不是出了问题再临时抓日志。


10. 一页总结

最值得记住的五条:

  1. 泄漏治理的核心不是“记得释放”,而是明确所有权
  2. 默认优先值语义、栈对象和 RAII
  3. 动态分配优先 unique_ptr,不是裸指针
  4. shared_ptr 最大的风险是循环引用
  5. 用 Sanitizer 和 Valgrind 查问题,不要靠肉眼猜

参考:https://zhuanlan.zhihu.com/p/15101814919
如果只记一句:

预防内存泄漏最有效的方法,不是手写更多 delete,而是让代码结构默认不需要手写 delete