8. shared_ptr(下):循环引用与weak_ptr破局

上一讲我们把 shared_ptr 的引用计数原理讲透了。今天要聊的,是 shared_ptr 最让人头疼的一个问题——循环引用。说白了就是两个对象互相指着对方,结果谁也释放不了。

我在项目里见过太多次这种问题了。有一次线上服务内存持续上涨,查了两天才发现是循环引用导致的内存泄漏。嗯,从那以后我对这个问题就特别敏感。

8.1 循环引用是怎么产生的?

先看一个最简单的例子。假设我们有两个类,每个类都持有对方的 shared_ptr:

#include <memory>
#include <iostream>

class B; // 前置声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A 被销毁\n"; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() { std::cout << "B 被销毁\n"; }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_ptr = a;
    
    // 离开作用域,a 和 b 应该被销毁?
    // 但你会发现——两个析构函数都没调用!
    return 0;
}

运行这段代码,控制台什么都不会打印。为什么?

你想想看:

  • a 的引用计数是 2(main 中的 a 和 b 中的 a_ptr)
  • b 的引用计数也是 2(main 中的 b 和 a 中的 b_ptr)

当 main 结束时,a 和 b 各自减掉 1,但引用计数还剩 1。谁都不为 0,谁都不释放。这就是经典的循环引用死锁

核心问题:两个 shared_ptr 互相持有,形成环状依赖。引用计数永远无法归零。

8.2 为什么普通指针没这问题?

有人可能会问:用原始指针不就没这事了吗?确实,原始指针没有引用计数,不会"卡住"。但原始指针的问题在于——你不知道对方什么时候被释放了,容易产生悬空指针。

shared_ptr 的设计初衷是自动管理生命周期,但循环引用恰恰让这个机制失效了。说白了,shared_ptr 只能处理无环的引用关系。

8.3 weak_ptr 登场

那怎么破局?C++11 给出了答案:weak_ptr

weak_ptr 是一种"弱引用"智能指针。它指向一个对象,但不增加引用计数。你可以把它理解成一个"观察者"——它能看到对象,但不参与生命周期管理。

用 weak_ptr 改写上面的例子:

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A 被销毁\n"; }
};

class B {
public:
    std::weak_ptr<A> a_ptr;  // 改成 weak_ptr!
    ~B() { std::cout << "B 被销毁\n"; }
};

int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_ptr = a;  // weak_ptr 不增加引用计数
    
    // 现在 a 的引用计数是 1(只有 main 中的 a)
    // b 的引用计数是 2(main 中的 b + a 中的 b_ptr)
    
    // main 结束,a 先释放,然后 b 的引用计数变为 1
    // 再释放 b,一切正常!
    return 0;
}

运行结果:

A 被销毁
B 被销毁

完美释放!

我的习惯:在父子关系中,父对象用 shared_ptr 指向子对象,子对象用 weak_ptr 指回父对象。这样既安全又不会循环引用。

8.4 weak_ptr 怎么用?

weak_ptr 不能直接访问对象。你得先把它"升级"成 shared_ptr:

std::weak_ptr<A> wp = a;

// 方式一:lock() 返回 shared_ptr
if (auto sp = wp.lock()) {
    sp->doSomething();  // 对象还在,安全使用
} else {
    // 对象已经被释放了
}

// 方式二:expired() 检查是否失效
if (!wp.expired()) {
    // 对象还在,但注意:这里检查完可能就被释放了
    // 所以还是推荐用 lock()
}

// 方式三:用 weak_ptr 构造 shared_ptr(会抛异常)
// std::shared_ptr<A> sp(wp);  // 如果对象已释放,抛 std::bad_weak_ptr

我个人最推荐用 lock()。它原子性地检查并提升,不会出现"检查通过但使用时已释放"的竞态条件。

注意:不要直接用 expired() 判断后再 lock()。这两步之间可能有其他线程释放了对象。lock() 本身是线程安全的,一步到位。

8.5 循环引用的典型场景

我在项目中遇到最多的循环引用场景:

  • 父子节点树:每个节点持有子节点的 shared_ptr,子节点持有父节点的 shared_ptr
  • 观察者模式:主题持有观察者的 shared_ptr,观察者持有主题的 shared_ptr
  • 回调闭包:lambda 捕获了 this 的 shared_ptr,而 this 又持有这个 lambda

举个例子,回调中的循环引用:

class Worker {
public:
    std::function<void()> callback;
    
    void start() {
        // 危险!lambda 捕获了 shared_from_this()
        callback = [self = shared_from_this()]() {
            self->doWork();
        };
    }
    
    void doWork() { /* ... */ }
};

这里 shared_from_this() 让 lambda 持有了 Worker 的 shared_ptr,而 Worker 又持有了 callback(内含 shared_ptr)。循环引用形成!

解决办法:用 weak_ptr 捕获:

void start() {
    std::weak_ptr<Worker> weak_self = shared_from_this();
    callback = [weak_self]() {
        if (auto self = weak_self.lock()) {
            self->doWork();
        }
    };
}

8.6 weak_ptr 的其他用途

除了破循环引用,weak_ptr 还有两个实用场景:

  1. 缓存系统:缓存中存 weak_ptr,外部使用者存 shared_ptr。当外部不再使用时,缓存自动失效。
  2. 资源池:对象池用 weak_ptr 跟踪对象是否还在使用,避免长期占用。

举个例子,一个简单的对象缓存:

class ObjectCache {
    std::map<std::string, std::weak_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        auto it = cache.find(key);
        if (it != cache.end()) {
            if (auto sp = it->second.lock()) {
                return sp;  // 缓存命中
            }
        }
        // 创建新对象
        auto sp = std::make_shared<Resource>(key);
        cache[key] = sp;
        return sp;
    }
};

这样缓存不会阻止对象释放。当所有外部 shared_ptr 都销毁后,缓存中的 weak_ptr 自动失效。

8.7 知识体系图

下面这张图总结了 shared_ptr 循环引用和 weak_ptr 破局的核心逻辑:

shared_ptr 循环引用与 weak_ptr 破局 循环引用(问题) 对象 A shared_ptr<B> 对象 B shared_ptr<A> 引用计数永不归零 → 内存泄漏 weak_ptr 破局(解决方案) 对象 A shared_ptr<B> 对象 B weak_ptr<A> weak_ptr 不增加引用计数 → 正常释放 weak_ptr 使用要点 1. 不能直接访问对象,必须通过 lock() 提升为 shared_ptr 2. lock() 是线程安全的,推荐使用 3. expired() 检查后要立即 lock(),避免竞态 4. 典型场景:父子节点、观察者模式、回调闭包、缓存 5. 原则:谁持有生命周期,谁用 shared_ptr;谁只是观察,谁用 weak_ptr

8.8 总结

循环引用是 shared_ptr 的"阿喀琉斯之踵"。但有了 weak_ptr,这个问题迎刃而解。

记住几个要点:

  • weak_ptr 不增加引用计数,不参与生命周期管理
  • 使用前必须 lock(),不要直接用 expired() 判断
  • 在父子关系中,子对象用 weak_ptr 指回父对象
  • 回调中捕获 weak_ptr 而不是 shared_ptr

我曾经在一个大型项目中,因为没注意回调中的循环引用,导致内存泄漏了整整一周才发现。从那以后,我写代码时都会多问自己一句:"这里会不会形成环?"

嗯,养成这个习惯,你也能避免很多坑。

一句话总结:shared_ptr 管生命周期,weak_ptr 管观察访问。各司其职,天下太平。

公众号:蓝海资料掘金营,微信 deep3321