【C++避坑实战系列文章03】为什么delete后还会内存泄漏?C++智能指针循环引用的3种检测与修复方案
不少C++新手会有这样的困惑:明明用了delete释放动态内存,或者换成了“自动管理内存”的shared_ptr,程序运行一段时间后还是出现内存泄漏——内存占用越来越高,甚至触发崩溃。这其中,智能指针的循环引用是最隐蔽也最容易被忽视的原因之一。本文会从“现象本质→检测方法→修复方案”三步拆解问题,用可复现的代码和工具操作,帮你彻底搞懂循环引用导致的内存泄漏。
一、先搞懂:为什么delete/智能指针还会漏?
要理解这个问题,得先明确两种“看似安全却漏内存”的场景:普通delete的“漏删”,以及shared_ptr的“循环引用陷阱”。其中后者是新手最容易踩的坑。
1.1 场景1:普通delete的“显性漏删”(基础误区)
最基础的内存泄漏是“忘了delete”或“delete执行不到”,比如分支逻辑里漏写、异常抛出跳过delete:
#include <iostream>
#include <stdexcept>
using namespace std;
void func(bool flag) {
int* p = new int[100]; // 分配内存
if (flag) {
// 错误:flag为true时,跳过delete,直接返回
return;
}
if (flag == false) {
throw runtime_error("异常"); // 抛出异常,同样跳过delete
}
delete[] p; // 永远执行不到的delete
}
int main() {
func(true); // 内存泄漏
return 0;
}
这种情况只要仔细检查代码逻辑就能发现,但shared_ptr的循环引用则是“隐性泄漏”——代码看起来没问题,甚至用了智能指针,却依然漏内存。
1.2 场景2:shared_ptr的“隐性泄漏”(核心陷阱)
shared_ptr的核心是“引用计数”:每多一个shared_ptr指向同一对象,计数+1;每一个shared_ptr销毁(出作用域/赋值),计数-1。当计数降到0时,自动调用delete释放内存。
但如果两个对象用shared_ptr互相指向对方,就会形成“循环引用”——两者的引用计数永远无法降到0,即使出了作用域,delete也不会执行,最终导致内存泄漏。
可复现的循环引用代码
#include <iostream>
#include <memory> // shared_ptr头文件
using namespace std;
// 前向声明:A类需要用到B类,B类需要用到A类
class B;
class A {
public:
// A持有B的shared_ptr
shared_ptr<B> b_ptr;
~A() {
cout << "A析构,内存释放" << endl; // 析构时打印,验证是否释放
}
};
class B {
public:
// B持有A的shared_ptr(循环引用的关键)
shared_ptr<A> a_ptr;
~B() {
cout << "B析构,内存释放" << endl;
}
};
int main() {
{ // 局部作用域:让a和b出作用域后销毁
shared_ptr<A> a = make_shared<A>(); // A的引用计数:1
shared_ptr<B> b = make_shared<B>(); // B的引用计数:1
a->b_ptr = b; // A的b_ptr指向b,B的计数变为2
b->a_ptr = a; // B的a_ptr指向a,A的计数变为2
} // 作用域结束,a和b销毁:
// A的计数从2→1(还被b->a_ptr持有)
// B的计数从2→1(还被a->b_ptr持有)
// 计数永远不为0,析构不执行,内存泄漏
cout << "程序未结束,内存已泄漏" << endl;
// 此处若用任务管理器/top查看,会发现内存未释放
return 0;
}
运行结果暴露问题
编译运行后,控制台只会输出程序未结束,内存已泄漏——A和B的析构函数完全没执行!这意味着shared_ptr内部的delete根本没调用,内存被永久占用。
这就是“delete后还漏”的关键:不是delete没写,而是shared_ptr因为循环引用,根本没触发delete。
二、怎么检测:3种定位循环引用泄漏的方法
发现内存泄漏不难,但要定位到“循环引用”这个根源,需要结合工具和代码埋点。以下3种方法是企业开发中最常用的,新手也能快速上手。
2.1 工具1:Valgrind(Linux/macOS首选)
Valgrind的memcheck工具能检测所有未释放的动态内存,还能显示内存分配的堆栈,帮你找到哪里创建了泄漏的对象。
操作步骤
- 编译代码时加调试信息(
-g选项):g++ -std=c++11 -g leak_demo.cpp -o leak_demo - 用Valgrind运行程序:
valgrind --leak-check=full ./leak_demo
关键输出解读
Valgrind会在最后给出泄漏报告,重点看definitely lost(确认泄漏)部分:
==12345== Definitely lost: 40 bytes in 2 blocks
==12345== at 0x4C2DB8F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x400E1F: std::make_shared<A>() (in ./leak_demo)
==12345== by 0x400F3A: main (leak_demo.cpp:28)
==12345==
==12345== Definitely lost: 40 bytes in 2 blocks
==12345== at 0x4C2DB8F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x400E3F: std::make_shared<B>() (in ./leak_demo)
==12345== by 0x400F5A: main (leak_demo.cpp:29)
从报告能看到:泄漏的内存是在main函数的28、29行(创建a和b)分配的,结合代码逻辑就能怀疑是循环引用。
2.2 工具2:Visual Studio内存诊断(Windows首选)
VS自带的“内存诊断”工具能实时监控内存变化,还能对比内存快照,找出未释放的对象。
操作步骤
- 打开VS,创建C++项目,粘贴循环引用代码;
- 点击菜单栏「调试」→「Windows」→「内存诊断」;
- 启动调试(F5),在程序运行到关键位置(如作用域结束后),点击「拍摄快照」;
- 查看快照中的“未释放分配”,VS会显示内存分配的代码行和调用栈。
优势
不需要额外安装工具,集成在VS中,新手操作门槛低,还能直观看到内存占用的变化曲线。
2.3 代码埋点:自定义内存跟踪器(跨平台通用)
如果没有工具,也可以通过“自定义内存跟踪+堆栈捕获”定位泄漏。核心思路是:记录每个shared_ptr创建的对象,在程序结束时检查未释放的对象,并输出创建时的堆栈。
实现代码(可直接复用)
#include <iostream>
#include <memory>
#include <unordered_map>
#include <stacktrace> // C++23,需编译器支持
using namespace std;
// 全局内存跟踪器:记录shared_ptr管理的对象
class MemoryTracker {
private:
// 存储:对象地址 → 创建时的堆栈
unordered_map<const void*, stacktrace> obj_stack;
static MemoryTracker instance;
public:
// 记录对象创建
template <typename T>
void track(const T* obj) {
obj_stack[obj] = stacktrace::current(); // 捕获创建时的堆栈
}
// 移除已释放的对象
template <typename T>
void untrack(const T* obj) {
obj_stack.erase(obj);
}
// 程序结束时检查未释放对象
~MemoryTracker() {
if (!obj_stack.empty()) {
cout << "\n=== 检测到" << obj_stack.size() << "个未释放对象 ===" << endl;
for (const auto& [obj, st] : obj_stack) {
cout << "对象地址:" << obj << ",创建堆栈:" << endl;
cout << st << endl; // 输出创建时的函数调用链
}
}
}
static MemoryTracker& get() { return instance; }
};
MemoryTracker MemoryTracker::instance;
// 自定义删除器:shared_ptr释放对象时,通知跟踪器
template <typename T>
void tracked_delete(T* obj) {
MemoryTracker::get().untrack(obj); // 从跟踪器移除
delete obj; // 真正释放内存
}
// 包装make_shared,添加跟踪
template <typename T, typename... Args>
shared_ptr<T> make_tracked_shared(Args&&... args) {
T* obj = new T(forward<Args>(args)...);
MemoryTracker::get().track(obj); // 记录对象创建
// 使用自定义删除器
return shared_ptr<T>(obj, tracked_delete<T>);
}
// ------------------- 原循环引用代码修改 -------------------
class B;
class A {
public:
shared_ptr<B> b_ptr;
~A() { cout << "A析构,内存释放" << endl; }
};
class B {
public:
shared_ptr<A> a_ptr;
~B() { cout << "B析构,内存释放" << endl; }
};
int main() {
{
// 改用make_tracked_shared创建对象,自动跟踪
shared_ptr<A> a = make_tracked_shared<A>();
shared_ptr<B> b = make_tracked_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
}
cout << "程序未结束,等待跟踪器检查..." << endl;
return 0;
}
运行结果
程序结束时,跟踪器会输出未释放对象的创建堆栈:
程序未结束,等待跟踪器检查...
=== 检测到2个未释放对象 ===
对象地址:0x55f8d7a6aeb0,创建堆栈:
0# MemoryTracker::track<A>(A const*) at tracker.cpp:18
1# make_tracked_shared<A>() at tracker.cpp:38
2# main() at tracker.cpp:58
对象地址:0x55f8d7a6af00,创建堆栈:
0# MemoryTracker::track<B>(B const*) at tracker.cpp:18
1# make_tracked_shared<B>() at tracker.cpp:38
2# main() at tracker.cpp:59
从堆栈能明确看到:未释放的对象是在main函数58、59行创建的,结合代码逻辑就能定位到循环引用。
三、怎么修复:3种打破循环引用的方案
定位到循环引用后,核心解决思路是“打破引用循环”——让其中一方不再用shared_ptr持有对方,从而让引用计数能正常降到0。以下3种方案各有适用场景,新手优先掌握第一种。
3.1 方案1:用weak_ptr替代一方的shared_ptr(推荐)
weak_ptr是shared_ptr的“弱引用”伙伴:它指向shared_ptr管理的对象,但不增加引用计数。这样既能访问对象,又不会导致循环引用。
修复逻辑
把之前循环引用中的一个shared_ptr改成weak_ptr(比如B类中的a_ptr):
- A的
b_ptr(shared_ptr)指向B → B的计数+1; - B的
a_ptr(weak_ptr)指向A → A的计数不变; - 作用域结束时,a和b销毁:B的计数从2→1→0(释放B),A的计数从1→0(释放A),循环被打破。
修复代码
#include <iostream>
#include <memory>
using namespace std;
class B;
class A {
public:
// A仍用shared_ptr持有B(假设A是“所有者”)
shared_ptr<B> b_ptr;
~A() { cout << "A析构,内存释放" << endl; }
};
class B {
public:
// 关键:B用weak_ptr持有A,不增加引用计数
weak_ptr<A> a_ptr;
~B() { cout << "B析构,内存释放" << endl; }
// 访问A时,用weak_ptr::lock()获取shared_ptr(确保对象未释放)
void accessA() {
if (shared_ptr<A> a = a_ptr.lock()) { // lock()成功:对象还在
cout << "成功访问A对象" << endl;
} else { // lock()失败:对象已释放
cout << "A对象已释放,无法访问" << endl;
}
}
};
int main() {
{
shared_ptr<A> a = make_shared<A>(); // A计数:1
shared_ptr<B> b = make_shared<B>(); // B计数:1
a->b_ptr = b; // B计数:2
b->a_ptr = a; // A计数:1(weak_ptr不增加)
} // 作用域结束:
// a销毁 → A计数1→0 → A析构,A的b_ptr销毁 → B计数2→1
// b销毁 → B计数1→0 → B析构
// 两者都正常释放,无泄漏
cout << "程序结束,内存无泄漏" << endl;
return 0;
}
运行结果(关键验证)
A析构,内存释放
B析构,内存释放
程序结束,内存无泄漏
A和B的析构函数都执行了,证明内存已正常释放。
注意事项
weak_ptr不能直接访问对象,必须通过lock()获取shared_ptr(lock()会检查对象是否还存在,避免悬垂引用);- 通常让“非所有者”一方用
weak_ptr,比如“树节点的父节点”用weak_ptr,“子节点”用shared_ptr。
3.2 方案2:打破引用关系(手动解除循环)
如果不希望用weak_ptr,也可以在合适的时机“手动断开引用”——比如在对象不再需要对方时,把shared_ptr赋值为nullptr,让引用计数下降。
修复代码
#include <iostream>
#include <memory>
using namespace std;
class B;
class A {
public:
shared_ptr<B> b_ptr;
~A() { cout << "A析构,内存释放" << endl; }
};
class B {
public:
shared_ptr<A> a_ptr;
~B() { cout << "B析构,内存释放" << endl; }
};
int main() {
{
shared_ptr<A> a = make_shared<A>();
shared_ptr<B> b = make_shared<B>();
a->b_ptr = b;
b->a_ptr = a; // 形成循环引用
// 关键:手动断开一方的引用
b->a_ptr = nullptr; // B的a_ptr不再指向A,A计数从2→1
} // 作用域结束:
// a销毁 → A计数1→0 → A析构,b_ptr销毁 → B计数2→1
// b销毁 → B计数1→0 → B析构
cout << "程序结束,内存无泄漏" << endl;
return 0;
}
适用场景
适合“引用关系有明确生命周期”的场景,比如“临时关联的两个对象”,在任务完成后手动断开引用。但缺点是需要手动管理,容易忘写,不如weak_ptr自动。
3.3 方案3:用unique_ptr替代shared_ptr(明确所有权)
如果循环引用中的一方对另一方没有“共享所有权”,只是“临时访问”,可以用unique_ptr(独占所有权)+ 裸指针(临时访问)的组合,从根源避免循环。
修复逻辑
- A用
unique_ptr持有B(独占所有权,A销毁时B也销毁); - B用裸指针访问A(仅临时访问,不持有所有权,不影响计数);
- 确保裸指针的生命周期不超过A的生命周期(避免悬垂指针)。
修复代码
#include <iostream>
#include <memory>
using namespace std;
class B;
class A {
public:
// A独占B的所有权
unique_ptr<B> b_ptr;
~A() { cout << "A析构,内存释放" << endl; }
};
class B {
public:
// B用裸指针访问A(不持有所有权)
A* a_ptr;
~B() { cout << "B析构,内存释放" << endl; }
void accessA() {
if (a_ptr != nullptr) { // 检查指针有效性
cout << "成功访问A对象" << endl;
}
}
};
int main() {
{
unique_ptr<A> a = make_unique<A>(); // A独占
a->b_ptr = make_unique<B>(); // A持有B的unique_ptr
// B的裸指针指向A(确保A的生命周期比B长)
a->b_ptr->a_ptr = a.get(); // get()获取裸指针,不转移所有权
} // 作用域结束:
// a销毁 → A析构,b_ptr销毁 → B析构
// 无循环引用,内存正常释放
cout << "程序结束,内存无泄漏" << endl;
return 0;
}
适用场景
适合“明确单一所有者”的场景,比如“容器持有元素”“父对象持有子对象”,用unique_ptr更高效(无引用计数开销),但要注意裸指针的生命周期,避免A已销毁但B还在访问a_ptr。
四、避坑总结:3个关键原则
- 理解引用计数的本质:
shared_ptr不是“万能解药”,只要有循环引用,计数就会卡住,必须用weak_ptr打破; - 优先用weak_ptr解决循环:相比手动断引用和裸指针,
weak_ptr更安全(自动检查对象是否存在)、更省心(无需手动管理); - 善用工具定位泄漏:新手不要凭感觉找泄漏,Valgrind/VS内存诊断能快速定位到泄漏的对象和创建位置,效率提升10倍。
记住:C++内存泄漏的核心是“对象生命周期失控”,无论是delete还是智能指针,只要理清“谁持有谁”“谁该释放谁”,就能从根源避免泄漏。
------------伴代码深耕技术、连万物探索物联,我聚焦计算机、物联网与上位机领域,盼同频的你关注,一起交流成长~
更多推荐

所有评论(0)