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 还有两个实用场景:
- 缓存系统:缓存中存 weak_ptr,外部使用者存 shared_ptr。当外部不再使用时,缓存自动失效。
- 资源池:对象池用 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 破局的核心逻辑:
8.8 总结
循环引用是 shared_ptr 的"阿喀琉斯之踵"。但有了 weak_ptr,这个问题迎刃而解。
记住几个要点:
- weak_ptr 不增加引用计数,不参与生命周期管理
- 使用前必须 lock(),不要直接用 expired() 判断
- 在父子关系中,子对象用 weak_ptr 指回父对象
- 回调中捕获 weak_ptr 而不是 shared_ptr
我曾经在一个大型项目中,因为没注意回调中的循环引用,导致内存泄漏了整整一周才发现。从那以后,我写代码时都会多问自己一句:"这里会不会形成环?"
嗯,养成这个习惯,你也能避免很多坑。
一句话总结:shared_ptr 管生命周期,weak_ptr 管观察访问。各司其职,天下太平。