28. 内存泄漏常见模式:循环引用
说到内存泄漏,循环引用绝对是个「老朋友」了。
我刚开始用智能指针那会儿,觉得有了 shared_ptr 就万事大吉了。结果呢?程序跑着跑着内存蹭蹭往上涨,查了半天才发现——两个对象互相指着对方,谁也释放不了。说白了,就是它们俩「抱团等死」。
什么是循环引用?
先看一个最简单的例子。假设我们有两个类:Parent 和 Child。父亲持有孩子的指针,孩子也持有父亲的指针。如果用 shared_ptr 来管理,就会出问题。
#include <memory>
#include <iostream>
class Child;
class Parent {
public:
std::shared_ptr<Child> child;
~Parent() { std::cout << "Parent destroyed\n"; }
};
class Child {
public:
std::shared_ptr<Parent> parent;
~Child() { std::cout << "Child destroyed\n"; }
};
int main() {
auto p = std::make_shared<Parent>();
auto c = std::make_shared<Child>();
p->child = c;
c->parent = p;
// 离开作用域,p 和 c 都不会被销毁!
return 0;
}
运行一下,你会发现控制台什么也没输出。两个析构函数都没被调用。为什么会这样?
核心原因:引用计数永远降不到零。
p的引用计数:main 中的p占 1,c->parent占 1,总共 2。c的引用计数:main 中的c占 1,p->child占 1,总共 2。
离开作用域时,main 中的 p 和 c 被销毁,引用计数各减 1。但两个对象内部还互相指着,计数变成 1,不是 0。所以内存永远释放不了。
循环引用的常见场景
我在项目中遇到过好几次循环引用,总结下来主要有这么几类:
| 场景 | 描述 | 典型例子 |
|---|---|---|
| 父子双向引用 | 父对象持有子对象,子对象回指父对象 | GUI 控件树、文档-视图结构 |
| 观察者模式 | 主题持有观察者列表,观察者持有主题引用 | 事件系统、信号-槽机制 |
| 图结构 | 节点之间互相连接,形成环 | 社交网络、依赖关系图 |
| 缓存 + 回调 | 缓存对象持有回调,回调又捕获了缓存对象的 shared_ptr | 异步任务、延迟计算 |
如何检测循环引用?
说实话,循环引用在代码里不太容易一眼看出来。我个人的习惯是:
- 看析构函数有没有被调用——在析构函数里加个日志输出,跑一遍功能,看看有没有漏掉的。
- 用工具检测——Valgrind、AddressSanitizer 都能帮你找出未释放的内存。
- 画引用关系图——把对象之间的
shared_ptr关系画出来,看看有没有环。
下面这张图展示了循环引用的本质:
解决方案:用 weak_ptr 打破循环
解决循环引用的标准做法是:把其中一个方向的 shared_ptr 换成 weak_ptr。
weak_ptr 是个「弱引用」,它不会增加引用计数。它只是「看着」那个对象,但不「持有」它。需要访问的时候,通过 lock() 临时提升为 shared_ptr。
那到底哪边该用 weak_ptr?我个人的经验是:谁的生命周期更长,谁就用 shared_ptr;生命周期短的那边用 weak_ptr 回指。
拿父子关系来说,父对象通常比子对象活得久。所以父对象用 shared_ptr<Child>,子对象用 weak_ptr<Parent> 回指父对象。
#include <memory>
#include <iostream>
class Child;
class Parent {
public:
std::shared_ptr<Child> child;
~Parent() { std::cout << "Parent destroyed\n"; }
};
class Child {
public:
std::weak_ptr<Parent> parent; // 改成 weak_ptr!
~Child() { std::cout << "Child destroyed\n"; }
void doSomething() {
if (auto p = parent.lock()) { // 临时提升为 shared_ptr
std::cout << "Parent is still alive\n";
} else {
std::cout << "Parent is gone\n";
}
}
};
int main() {
auto p = std::make_shared<Parent>();
auto c = std::make_shared<Child>();
p->child = c;
c->parent = p;
// 离开作用域,两个对象都能正常销毁!
return 0;
}
这次运行,你会看到两个析构函数都被调用了。因为 c->parent 是 weak_ptr,不增加引用计数。main 中的 p 和 c 销毁后,两个对象的引用计数都变成 0,内存顺利释放。
小技巧:使用 weak_ptr::lock() 时,一定要检查返回值是否为空。因为在你调用 lock() 的那一刻,目标对象可能已经被销毁了。我曾经在回调函数里忘了检查,结果程序直接崩溃——嗯,从那以后我再也不敢省略这个检查了。
其他打破循环的方法
除了 weak_ptr,还有几种方式也能处理循环引用:
- 手动重置——在适当时机调用
reset()断开引用。但容易遗漏,不推荐。 - 改用原始指针——如果确定被指对象的生命周期更长,可以用原始指针。但风险较高,需要非常小心。
- 重新设计架构——有时候循环引用本身就是设计问题。比如用观察者模式时,可以考虑让观察者不持有主题的引用,而是通过回调参数传递。
注意:原始指针虽然能打破循环,但也带来了悬空指针的风险。除非你百分之百确定被指对象不会提前销毁,否则还是老老实实用 weak_ptr 吧。
总结一下
循环引用说白了就是两个对象互相用 shared_ptr 指着对方,谁也跑不掉。解决思路很简单:把其中一个方向的 shared_ptr 换成 weak_ptr。
你想想看,这就像两个人互相扶着对方,谁都不敢先松手。但只要其中一个人只是「虚扶」着(weak_ptr),另一个人就能先走,然后虚扶的那个人自然也就跟着走了。
记住:shared_ptr 是「我拥有你」,weak_ptr 是「我认识你,但不拥有你」。分清这个区别,循环引用就不再是问题了。