写在前面
本文定位为“从入门到精通”的深度教程,全文超过 12,000 字,结合作者多年在 Qt 框架、游戏引擎、服务器端及高并发协程框架中的一线经验,系统梳理 C++ 内存泄漏的原理、检测、定位与修复方案。示例代码均可在 GCC/Clang/MSVC(C++20 标准)下编译通过,并特别对 Windows、Linux、macOS 三大平台的差异化工具与坑点进行说明。欢迎评论区互动交流~
“If you think your program has no bugs, you just haven’t found them yet.” —— Anonymous
在 C++ 的世界里,手动内存管理 与 高性能 常常如影随形。随着业务复杂度、并发量级或实时渲染要求的提升,即使是资深开发者,也可能在一次“代码重构”或“紧急上线”后留下隐蔽的内存泄漏隐患——
案例引入:作者曾维护过一款医用影像软件(Qt + OpenGL)。客户反馈“挂一晚机器直接假死”。最终定位:一个 QTimer 定时拼接大尺寸 QPixmap 时使用了
new
分配缓冲区,忘记 delete。每 33 ms 泄漏 24 MB,一夜便吃掉数十 GB!
痛点即动力。下文我们将从“原理—工具—实战—最佳实践”四条主线展开,帮助你建立一套体系化的排查思维。
图 2‑1 展示了 ELF / PE 可执行文件在虚拟地址空间中的典型布局:
|-----------------------| 0xFFFF FFFF 内核映射
| Kernel Space |
|-----------------------| 0xC000 0000
| Stack | <- 遇到递归时暴涨
|-----------------------|
| Heap | <- new/malloc 典型泄漏区
|-----------------------|
| BSS & Data Segment | <- 静态全局变量
|-----------------------|
| Text | <- 代码段
|-----------------------| 0x0000 0000
内存碎片(fragmentation)≠ 泄漏。碎片源于频繁的 new/delete 大小不一,导致可用块被切割。虽可回收却难以重用大块。泄漏则是彻底丢失指针引用,本质是 不可达但仍占用。
场景 | 描述 | 典型代码片段 |
---|---|---|
① new 未 delete | 最传统 | auto* buf=new char[1024]; /* 忘记 delete[] */ |
② 异常提前退出 | RAII 未覆盖 | try { p=new Obj(); risky(); } catch() { /* 泄漏 */ } |
③ 智能指针环 | std::shared_ptr 循环引用 |
struct Node{sp |
④ STL 容器中裸指针 | 容器析构不负责释放 | std::vector |
⑤ 自定义内存池未回收 | arenas / object pool | 游戏服务器常见 |
⑥ Qt 父子层级断裂 | 误用 QObject::setParent(nullptr) | |
⑦ DLL 边界跨堆释放 | new in DLL, delete in EXE | Windows 易踩坑 |
⑧ 模板 static 单例 | 线程反复加载卸载 .so | 插件热重载泄漏 |
⑨ 忽略第三方句柄 | libpng_malloc, OpenSSL BIO | C API 带外资源 |
⑩ 系统资源句柄 | HWND / FILE* / fd | 句柄数耗尽 |
Tip:善用 clang-tidy 之
bugprone-unused-return-value
、cppcoreguidelines-ownerless-resource
等检查,及早发现 ①~④ 类问题。
-fsanitize=address -g -O1
/fsanitize=address
int* leak() {
int* p=new int[10];
return p; // 未 delete
}
int main(){ leak(); return 0; }
输出:
==12345==ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).
valgrind --leak-check=full --track-origins=yes ./app
建议:在 CI 中使用 ASan + UBSan,Beta 环境用 Valgrind,生产线上布署自研监控(见 §14)。
公司图像诊断软件(50 万行 C++,Qt 5.15)。用户反馈长时间录像后进程内存从 200 MB 飙到 4 GB。初步猜测:视频帧缓存未释放或 QPixmapCache
占用。
for(int i=0;i<100000;++i){
QPixmap pix(1920,1080);
pix.fill(Qt::black);
// imshow
}
QImageData
数量持续增加。QSharedPointer
搭配 Qt 信号槽 跨线程传递帧,未勾选 Qt::QueuedConnection
,导致循环引用!connect(this,&Producer::newFrame,viewer,&Viewer::onFrame);
// lambda 内捕获 shared_ptr,Viewer 持有 Producer,环状依赖
std::weak_ptr
或 QPointer
,并在 Viewer 析构时断开连接。修复后 8 小时稳定在 600 MB,无持续增长。
(保持原有 8.1 与 8.2 小节内容不变,追加 8.3 和 8.4 丰富示例)
场景 | 资源 | 典型写法 |
---|---|---|
C API 关闭句柄 | FILE* , DIR* , BIO* |
std::unique_ptr |
Windows 句柄 | HANDLE |
using Handle = std::unique_ptr |
GPU 资源 | VkBuffer , GLuint |
将 OpenGL/Vulkan 对象封装进 RAII 类,析构中调用销毁函数 |
std::pmr
搭配的定制分配器将 polymorphic_allocator
与自定义内存池结合,可缩减碎片同时便于统计分配信息:
class TrackingResource : public std::pmr::memory_resource {
... // 统计 bytes_in_use
};
std::pmr::vector<int> v{&tracker};
std::shared_ptr
通过内部控制块维护引用计数,若对象 A 和对象 B 互持 shared_ptr
,计数永远 ≥1,析构函数不会被调用。Qt 的信号槽在 QueuedConnection 模式下会将实参复制到事件队列,也常导致隐形持有。
Lambda 捕获强引用
connect(sender, &Sender::sig, this, [w = shared_from_this()](const Frame& f){
w->process(f);
});
捕获 w
会延长 this
的生命周期。
跨线程 shared_ptr
传递
线程 A 发射信号,线程 B 槽函数又存入全局缓存,若未及时清理将长期占用内存。
std::weak_ptr
:在槽函数内部 lock()
检查。QPointer
:仅在主线程 GUI 对象间传递。disconnect
:在对象析构前解除连接,确保事件队列不再引用目标。Qt::UniqueConnection
或自定义节流,减少排队对象数量。class FrameDistributor : public QObject {
Q_OBJECT
public:
void pushFrame(std::shared_ptr<Frame> f) {
emit newFrame(std::move(f));
}
signals:
void newFrame(std::shared_ptr<Frame> f);
};
class FrameView : public QObject {
Q_OBJECT
public:
explicit FrameView(FrameDistributor* d) {
// 捕获 weak_ptr 防环
connect(d, &FrameDistributor::newFrame, this,
[weak = QPointer<FrameView>(this)](std::shared_ptr<Frame> f){
if(!weak) return;
weak->render(*f);
}, Qt::QueuedConnection);
}
void render(const Frame& f);
};
clang++ -fsanitize=thread -g -O1 race.cpp -o race
./race
可定位 data race、死锁与非原子释放。若 TSAN 报告 heap-use-after-free
,极可能是线程 A 已 delete,线程 B 仍在访问。
类型 | 描述 | 排查方法 |
---|---|---|
无锁队列节点遗留 | MPSC 队列出队慢,dummy node 未释放 | 统计队列长度差值 |
条件变量丢失唤醒 | wait 早于 notify,导致永眠线程持有资源 | GDB info threads 检查栈 |
Thread‑local 缓存 | 每线程 std::vector 缓存,线程不退出则不析构 |
定时合并线程或使用池化 |
若使用不带 memory fence 的 DCLP 初始化单例,可能出现同一指针被构造两次甚至泄漏旧对象。推荐使用 std::call_once
或 static
局部变量。
CreateWindow
失败。任务管理器 ➜ 选择列 ➜ 打勾 GDI 对象 / USER 对象,观察实时增量。
CLI 快照:
tasklist /FI "PID eq 1234" /FO LIST | findstr "GDI USER"
WinDbg:!gdi -p
列出句柄类型与计数,!handle
定位泄漏对象。
使用 GDIView
或 Process Explorer
强图形化查看泄漏源。
QFont
/ QPen
却不复用。CreateCompatibleDC
后忘记 DeleteDC
。字段 | 含义 | 注意 |
---|---|---|
Size | 区段大小 | 包括文件映射 + 匿名 |
Rss | 常驻集大小 | 真正占物理内存 |
Pss | 共享分摊后 | 多进程共享库时更准确 |
AnonHugePages | THP 使用量 | 突然飙升多与 glibc malloc 有关 |
#!/usr/bin/env bash
pid=$1
while true; do
grep -E "^(Anon|Rss)" /proc/$pid/smaps_rollup | awk '{s+=$2} END{print strftime("%H:%M:%S"), s/1024 " MB"}'
sleep 5
done
perf record -e kmem:mm_page_alloc,kmem:mm_page_free -p $PID -- sleep 10
,配合 perf script
查看分配堆栈,快速定位疯狂分配点。
CFTypeRef
泄漏。dispatch_source_create
后未 cancel
。CGColor
激增。leaks PID
可在无 GUI 环境临时确认泄漏摘要。
TCMALLOC_SAMPLE_PARAMETER=524288
,每 512 KB 采样一次,pprof --inuse_space
导出火焰图。MALLOC_CONF=prof:true,lg_prof_sample:17
。周期性发送 mallctl"prof.dump"
信号到程序。指标 | 解释 | 典型阈值 |
---|---|---|
heap_inuse_bytes | 堆当前占用 | 高于启动基线 50% 报警 |
virt_bytes | 虚拟地址空间 | 容器限制下需重点关注 |
gdi_objects | Windows 专属 | 接近 8,000 警报 |
当检测到泄漏趋势但未定位时,可部署 定时进程重启 + 连接迁移 方案,兼顾高可用与排查窗口。
class LeakListener : public ::testing::EmptyTestEventListener {
void OnTestStart(const ::testing::TestInfo&) override { start = mallinfo2().uordblks; }
void OnTestEnd(const ::testing::TestInfo& info) override {
size_t end = mallinfo2().uordblks;
ASSERT_LT(end - start, 1024) << "Memory leak detected in " << info.name();
}
size_t start{};
};
int main(int argc,char** argv){
::testing::InitGoogleTest(&argc,argv);
::testing::UnitTest::GetInstance()->listeners().Append(new LeakListener);
return RUN_ALL_TESTS();
}
适合检测回归测试中新引入的泄漏。
CXXFLAGS="-fsanitize=address" cmake .. && ctest -T memcheck
-DNDEBUG
关闭断言?-O2/-O3
与 -fno-omit-frame-pointer
便于 Profiler?MALLOC_CONF
采样?ulimit -n
设置 ≥ 10240?FreeLibrary
后资源回收?LD_PRELOAD
调试库?stats.print
输出检查碎片率?malloc_trim
调用在测试环境验证?Q1: AddressSanitizer 是否会误报?
ASan 基于 Shadow Memory 标记边界,理论误报极低。若报错位置与源码不符,多为编译优化导致内联,可在报错后使用 llvm-symbolizer
解析调用链。
Q2: Valgrind 很慢,如何提速?
使用 --vgdb=yes
仅在感兴趣的函数下断点运行,或启用 --track-origins=no --read-var-info=no
减少符号解析。
Q3: Windows 没有 Valgrind,怎么办?
可选 Dr.Memory
、Visual Studio Diagnostic Tools。对于 64‑bit 进程,ASan 也已支持。
Q4: shared_ptr 必须配合 make_shared 吗?
make_shared
少一次分配,但调试泄漏时需注意其控制块与对象共址,无法通过指针差异判断是否释放。
Q5: 如何在生产紧急定位某版本引入的泄漏?
行级 Git bisect + Canary 部署 + 指标对比,同步使用分配采样工具排查增量。