28. 内存泄漏常见模式:循环引用

说到内存泄漏,循环引用绝对是个「老朋友」了。

我刚开始用智能指针那会儿,觉得有了 shared_ptr 就万事大吉了。结果呢?程序跑着跑着内存蹭蹭往上涨,查了半天才发现——两个对象互相指着对方,谁也释放不了。说白了,就是它们俩「抱团等死」。

什么是循环引用?

先看一个最简单的例子。假设我们有两个类:ParentChild。父亲持有孩子的指针,孩子也持有父亲的指针。如果用 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 中的 pc 被销毁,引用计数各减 1。但两个对象内部还互相指着,计数变成 1,不是 0。所以内存永远释放不了。

循环引用的常见场景

我在项目中遇到过好几次循环引用,总结下来主要有这么几类:

场景 描述 典型例子
父子双向引用 父对象持有子对象,子对象回指父对象 GUI 控件树、文档-视图结构
观察者模式 主题持有观察者列表,观察者持有主题引用 事件系统、信号-槽机制
图结构 节点之间互相连接,形成环 社交网络、依赖关系图
缓存 + 回调 缓存对象持有回调,回调又捕获了缓存对象的 shared_ptr 异步任务、延迟计算

如何检测循环引用?

说实话,循环引用在代码里不太容易一眼看出来。我个人的习惯是:

  1. 看析构函数有没有被调用——在析构函数里加个日志输出,跑一遍功能,看看有没有漏掉的。
  2. 用工具检测——Valgrind、AddressSanitizer 都能帮你找出未释放的内存。
  3. 画引用关系图——把对象之间的 shared_ptr 关系画出来,看看有没有环。

下面这张图展示了循环引用的本质:

Parent 对象 引用计数: 2 (main + child->parent) Child 对象 引用计数: 2 (main + parent->child) shared_ptr<Child> shared_ptr<Parent> ⚠ 循环引用:双方引用计数永远降不到 0 main 中的 shared_ptr 销毁后,计数从 2 降到 1,但不会到 0

解决方案:用 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->parentweak_ptr,不增加引用计数。main 中的 pc 销毁后,两个对象的引用计数都变成 0,内存顺利释放。

小技巧:使用 weak_ptr::lock() 时,一定要检查返回值是否为空。因为在你调用 lock() 的那一刻,目标对象可能已经被销毁了。我曾经在回调函数里忘了检查,结果程序直接崩溃——嗯,从那以后我再也不敢省略这个检查了。

其他打破循环的方法

除了 weak_ptr,还有几种方式也能处理循环引用:

  • 手动重置——在适当时机调用 reset() 断开引用。但容易遗漏,不推荐。
  • 改用原始指针——如果确定被指对象的生命周期更长,可以用原始指针。但风险较高,需要非常小心。
  • 重新设计架构——有时候循环引用本身就是设计问题。比如用观察者模式时,可以考虑让观察者不持有主题的引用,而是通过回调参数传递。

注意:原始指针虽然能打破循环,但也带来了悬空指针的风险。除非你百分之百确定被指对象不会提前销毁,否则还是老老实实用 weak_ptr 吧。

总结一下

循环引用说白了就是两个对象互相用 shared_ptr 指着对方,谁也跑不掉。解决思路很简单:把其中一个方向的 shared_ptr 换成 weak_ptr

你想想看,这就像两个人互相扶着对方,谁都不敢先松手。但只要其中一个人只是「虚扶」着(weak_ptr),另一个人就能先走,然后虚扶的那个人自然也就跟着走了。

记住:shared_ptr 是「我拥有你」,weak_ptr 是「我认识你,但不拥有你」。分清这个区别,循环引用就不再是问题了。