排查了大半天的内存泄漏,最后发现根源是两个shared_ptr互相引用——这类循环引用问题,可以说是 C++ 智能指针领域最经典的坑之一。今天这篇把weak_ptr解决shared_ptr循环引用的来龙去脉完整梳理一遍:底层原理、引用计数到底在数什么、实际改造手法、内存上涨时怎么一步步定位,以及我踩过的几个细节坑。新手可以把它当入门教程,老手也可以对照一下排查思路有没有可借鉴的地方。
先还原一个现场。假设你在维护一个长期运行的网络服务,突然发现内存随时间只涨不跌,压测跑一天就吃掉几个 GB,翻遍业务代码又找不到哪里 new 了没 delete。这时候十有八九,问题出在对象之间的相互持有关系上——它们谁都不肯先放手。
1. 先复现一次"销毁不掉"的对象:循环引用的现场
1.1 一段看起来人畜无害的代码
#include <iostream> #include <memory> struct Node { std::shared_ptr<Node> next; ~Node() { std::cout << "Node destroyed" << std::endl; } }; int main() { auto a = std::make_shared<Node>(); auto b = std::make_shared<Node>(); a->next = b; b->next = a; // 问题就出在这一行 // main 结束前,a 和 b 两个局部 shared_ptr 会依次析构 return 0; }你猜程序跑完会打印几遍 "Node destroyed"?答案是零遍。局部变量a和b确实在 main 末尾析构了,但这两个 Node 对象本身仍然存活,因为各自还被对方的一个shared_ptr成员牵着。两个对象就这样互相"保全"对方,形成一个谁也出不去的环。
1.2 引用计数变化的全过程
我把数字推演一遍。初始时:
a这个 shared_ptr 指向 A 对象,A 的强引用计数 = 1;b这个 shared_ptr 指向 B 对象,B 的强引用计数 = 1。
执行a->next = b后,B 的强引用计数变成 2(一个是局部变量 b,一个是 A::next 成员)。再执行b->next = a后,A 的强引用计数也变成 2。
到 main 结束时,a、b两个局部变量依次析构,各自释放一个强引用:A 从 2 变 1,B 从 2 变 1。注意,无论谁先析构,结果都是两个计数各自停在 1——因为析构的是"外部的指向",而"对象内部的指向"(A::next 和 B::next)还在。计数没有归零,析构函数自然不会被调用,这块内存就永远留在堆上。
循环引用造成的泄漏通常不会在退出小程序的瞬间暴露成崩溃,因为进程一结束,操作系统会把所有内存收回去。真正难受的场景是服务端程序:每次请求都可能构造类似的对象环,环里的对象析构不掉,内存像漏水的桶一样慢慢涨,直到被 OOM 杀掉。这种问题的隐蔽之处在于,它不报错、不崩溃,只会在运行几小时甚至几天后给你一记"surprise"。
1.3 这不是某个指针的"错",是所有权设计出了问题
很多人遇到这个问题第一反应是"shared_ptr 有 bug"。不是。shared_ptr的行为完全符合语义:它承诺"最后一个持有者负责销毁对象"。但当你让 A 持有 B 的同时让 B 持有 A,就相当于告诉编译器"你们两个永远都是对方的最后一个持有者",于是谁都不会成为最后一个。
共享所有权的前提是所有权有明确的语义边界,不能构成环。
打个比方:两个人各拿一把对方房子的钥匙,谁也不愿意先放手,房子就永远没人退租。weak_ptr的作用,就是让其中一个人改成"只有参观权、没有掌控权"——钥匙不攥在自己手里,需要用到的时候再临时申请,这样就打破了僵局。
2. 引用计数和强弱引用:搞清楚 shared_ptr 到底在数什么
2.1 控制块里其实有两个计数器
要理解 weak_ptr,得先看清楚 shared_ptr 的底层结构。一个 shared_ptr 内部通常有两个指针:一个指向对象本身,另一个指向控制块(control block)。控制块里存着两样关键数据:
- 强引用计数(strong ref count):有多少个 shared_ptr 正在"拥有"这个对象;当它归零时,对象被销毁。
- 弱引用计数(weak ref count):有多少个 weak_ptr 正在"观察"这个对象;它不参与对象生命周期管理,但会阻止控制块过早释放。
为什么需要弱引用计数?因为 weak_ptr 在调用lock()时必须能安全地判断"对象是否还活着"。如果强引用计数归零时整个控制块直接被释放,那所有指向它的 weak_ptr 就变成悬空指针,再访问就是未定义行为。所以标准库的做法是:强引用计数归零 → 销毁对象,但保留控制块;弱引用计数也归零 → 才释放整个控制块。这也是为什么循环引用泄漏时,泄漏的不只是对象本身,还有控制块。
2.2 weak_ptr:一个"不持有、只观察"的引用
weak_ptr 的 API 很小,但每个都是重点:
lock():如果对象还活着,返回一个临时的 shared_ptr,并原子性地把强引用计数加一;如果对象已经销毁,返回一个空的 shared_ptr。expired():等价于use_count() == 0,判断强引用计数是否归零。reset():释放当前 weak_ptr 对控制块的引用。owner_before():提供严格弱序,用于把 weak_ptr 放进 map/set 等关联容器。
注意,weak_ptr 没有operator->也没有operator*。这是故意设计的:它不拥有对象,就不该让你绕过生命周期检查直接触碰对象。想用对象,必须先lock()得到 shared_ptr,在持有这个临时强引用的期间,对象绝对不会被销毁。
弱引用、强引用和裸指针三者的差别,可以看这张表:
| 指针类型 | 是否参与所有权 | 生命周期安全 | 访问语法 | 典型用途 |
|---|---|---|---|---|
| shared_ptr | 是,强引用计数 | 自动释放 | ->/* | 多人共享同一个对象 |
| weak_ptr | 否,只观察 | 需 lock 判空 | lock() | 打破循环、观察者、缓存 |
| 裸指针 | 否 | 需人工保证存活 | ->/* | 非拥有的临时访问 |
2.3 为什么 weak_ptr 能解开循环
回到第一小节的计数推演。如果把其中一个成员的shared_ptr换成weak_ptr,例如 B::next 指向 A 时用的是弱引用,那么在 main 结束时:
b局部变量析构,B 的强引用计数从 2 降到 1(剩下的 1 来自 A::next);- B 的 next 是弱引用,指向 A 时不增加 A 的计数;
- 接着
a局部变量析构,A 的强引用计数从 1 降到 0,A 被销毁; - A 析构会释放它内部的 shared_ptr 成员
next,这个成员指向 B,于是 B 的强引用计数从 1 降到 0,B 随后也被销毁。
链条从"外部的最后一个强引用"开始,像多米诺骨牌一样逐环倒下。关键点在于:整个环上至少有一条边是弱引用,环就不存在"永远互相保全"的条件。
这里顺带解释了为什么很多人搜"c++智能指针底层原理":智能指针本质上是裸指针 + RAII 包装 + 引用计数管理。shared_ptr 解决的是"多个持有者共同管理生命周期"的问题,weak_ptr 解决的是"既想安全观察,又不想参与管理"的问题。理解这两层,比死记 API 有用得多。
3. 三个典型场景的弱引用改造实战
3.1 双向链表:next 强引用,prev 弱引用
链表是智能指针最容易出循环的场景,因为"双向"天生就暗示了互相指向。改造原则很简单:顺着所有权方向用强引用,逆着方向用弱引用。
#include <iostream> #include <memory> struct DListNode { int value; std::shared_ptr<DListNode> next; // 后驱:强引用 std::weak_ptr<DListNode> prev; // 前驱:弱引用 explicit DListNode(int v) : value(v) {} ~DListNode() { std::cout << "destroy node " << value << std::endl; } }; int main() { auto head = std::make_shared<DListNode>(1); auto tail = std::make_shared<DListNode>(2); head->next = tail; tail->prev = head; // 弱引用,不参与计数 // head 和 tail 都能正常销毁 return 0; }跑一下,会看到两句 "destroy node" 都打印出来。prev 指向 head 时不再增加 head 的强引用计数,等到 head 的最后一个强引用消失,它就能正常销毁;tail 的析构函数也不用担心"谁还指着我",因为 prev 是弱引用。
实际业务里链表节点往往还存数据、可能被多段代码同时使用,这时候还要问一句:这个节点到底归谁所有?如果整条链表由某个容器独占管理,unique_ptr其实更合适,更不存在循环问题;只有确实需要多个地方共享节点时,才上 shared_ptr + weak_ptr 的组合。
3.2 观察者模式:通知者不该"拥有"观察者
观察者模式是弱引用的典型应用场景:Subject 需要通知一堆 Observer,但 Subject 不应该因为"通知过你"就把 Observer 的命续上。如果 Subject 用std::vector<std::shared_ptr<Observer>>存观察者,而 Observer 又持有指向 Subject 的回引,两者就会互相保命,谁也别想销毁。
正确做法是 Subject 内部存弱引用列表,通知时临时 lock:
class Observer; class Subject { public: void registerObserver(const std::shared_ptr<Observer>& obs) { observers_.push_back(obs); // 存入的是 weak_ptr } void notify(int eventID) { for (auto& wobs : observers_) { if (auto sp = wobs.lock()) { sp->onEvent(eventID); } } removeExpired(); // 顺手清掉失效观察者 } private: void removeExpired() { observers_.erase( std::remove_if(observers_.begin(), observers_.end(), [](const std::weak_ptr<Observer>& w) { return w.expired(); }), observers_.end()); } std::vector<std::weak_ptr<Observer>> observers_; };Observer 那边如果有回指 Subject 的需求,同样应该用弱引用,或者干脆在通知回调的参数里直接传 Subject 的引用,而不是在 Observer 里存一个 shared_ptr 回引。这里有一个很容易被忽略的细节:弱引用列表里的条目不会自动消失。某个 Observer 销毁后,vector 里对应 weak_ptr 会变成 expired,但元素还在。如果不定期清理,时间长了列表里堆满"尸体",每次 notify 都要空转遍历一遍,这是使用层面的坑,我后面还会再提。
3.3 父子对象:回引用用 weak_ptr,正向持有用 shared_ptr
UI 控件、游戏实体、配置树这类"父容器管理子对象"的模型也很常见。父对象持有子对象的 shared_ptr,子对象需要访问父对象时,持有一个父对象的 weak_ptr:
class Parent : public std::enable_shared_from_this<Parent> { public: void addChild(std::shared_ptr<Child> child) { child->setParent(shared_from_this()); children_.push_back(std::move(child)); } private: std::vector<std::shared_ptr<Child>> children_; }; class Child { public: void setParent(const std::shared_ptr<Parent>& p) { parent_ = p; } void doSomething() { if (auto p = parent_.lock()) { // 在持锁期间,p 保证存活,可以安全调用 } else { // 父对象已经没了,走降级逻辑 } } private: std::weak_ptr<Parent> parent_; };注意一个细节:子对象在构造函数里不能拿到指向自己的 weak_ptr,因为此刻它还没有被任何 shared_ptr 管理。所以上面的写法是"先创建子对象,再通过 setParent 注入父引用"。关于shared_from_this()和weak_from_this()的限制,我会在后面的避坑章节展开。
4. 排查循环引用泄漏:从"内存上涨"到定位循环边
4.1 第一步:析构日志和对象计数器先确认"没被销毁"
排查循环引用,最快的探测手段就是在析构函数里打印日志,或者用一个静态计数器统计存活实例数。如果对象本应在流程结束后销毁,但日志没打印、计数器不降,就基本可以断定存在生命周期泄漏。
光有日志还不够,你还需要知道"哪些对象互相持有"以及"销毁链路在哪里断开"。更实用的技巧是写一个自定义 deleter:
template <typename T> std::shared_ptr<T> makeTracked(T* p, const char* name) { return std::shared_ptr<T>(p, [name](T* ptr) { std::cout << "[delete] " << name << std::endl; delete ptr; }); }自定义 deleter 的好处是:只要强引用计数归零,deleter 立刻触发,不管对象是从哪个分支走出来的。它能帮你确认"析构链路"是否被切断,也能配合日志看销毁顺序,判断哪条边是"最后放手"的那条。
提示:deleter 里不要只打印不释放,否则会二次泄漏。打印的目的是观察,释放仍然要
delete ptr。
4.2 第二步:交给工具去定位
确认有泄漏后,必须用工具把泄漏对象抓出来。Linux 下我习惯先上 Valgrind:
valgrind --leak-check=full --show-leak-kinds=all ./your_program如果看到一串 "definitely lost" 和 "indirectly lost" 的堆栈,且多个泄漏块之间有互指关系(输出里通常会显示next、prev这类成员名),基本就能锁定循环引用。不过 Valgrind 跑起来很慢,适合小规模复现。
日常开发我更推荐 AddressSanitizer 自带的 LeakSanitizer:
g++ -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o main ASAN_OPTIONS=detect_leaks=1 ./mainLSAN 的输出会直接告诉你泄漏点在哪个文件哪一行,定位效率比 Valgrind 高不少。再配合 heaptrack(heaptrack ./main生成分析文件,用heaptrack_print查看),还能同时看到对象的分配栈和释放栈,对分析"谁创建了它、谁应该销毁它"非常有帮助。
4.3 第三步:代码审查时直接看"所有权图"
工具能告诉你"哪里有泄漏",但"为什么泄漏"还得靠人。我自己的习惯是在代码里画一张所有权关系图(用文本注释就够):把所有类的成员变量里的std::shared_ptr都列出来,画成 A → B 的边。如果发现从某个对象出发,沿着 shared_ptr 的边能走回它自己,这里就是一个环,必须打断。
检查时重点关注三类成员:
- 数据成员里的
std::shared_ptr字段,尤其是next、prev、parent、callback、observer这类名字; - 构造函数里互相传入
shared_from_this()的地方; - 容器里存放
shared_ptr元素的类型,例如vector<shared_ptr<...>>,如果元素的回引用也是 shared_ptr,同样会形成环。
打断的原则是:所有权方向不变的那条边保留强引用,回指边改成 weak_ptr。如果环里实在分不清谁拥有谁,说明这里的共享所有权设计本身就暧昧,这种情况我放到最后一部分再说。
5. 日常使用 weak_ptr 的细节和容易踩的坑
5.1 lock() 的返回值必须检查
最常见的新手错误是拿到lock()的结果不做判空直接解引用。对象是否存活是运行时状态,编译器帮不了你:
std::shared_ptr<Parent> sp = child->getParent().lock(); // 可能是空 if (sp) { sp->doSomething(); }还有一种写起来"看起来更严谨、实际更危险"的写法:
if (!weak.expired()) { auto sp = weak.lock(); // 不好:expired 之后对象可能已被销毁 sp->doSomething(); // 这里的 sp 可能是空的 }expired()返回后到lock()执行前,另一个线程完全可能把对象销毁掉。正确的姿势是只调一次lock(),拿到结果后检查非空,一个原子操作同时完成"判断是否存活"和"增加强引用计数"两件事。
提示:不要写
expired()+lock()的"先判断再用"模式,直接lock()再判空既简洁又线程安全。
5.2 从 this 拿到 weak_ptr:enable_shared_from_this 与 weak_from_this
很多类需要在成员函数内部拿到"指向自己的弱引用",用于对外暴露。这时候要继承std::enable_shared_from_this<T>,C++17 之后可以直接用weak_from_this():
class Item : public std::enable_shared_from_this<Item> { public: std::weak_ptr<Item> self() { return weak_from_this(); } };注意几点:
- 不要在构造函数里调用
shared_from_this()或weak_from_this()。原因前面提过:对象还没有进入任何一个 shared_ptr 的控制块,调用是未定义行为。想拿自己的引用,必须等对象构造完成并且确实由 shared_ptr 管理之后; - 不要以为继承了 enable_shared_from_this 之后,栈上创建的对象也能调用——不行,它要求对象本身是被 shared_ptr 管理的;
- 在 C++17 之前没有
weak_from_this(),只能先shared_from_this()得到一个 shared_ptr,再隐式转成 weak_ptr,兜一圈。
5.3 多线程下的生命周期保证
lock()内部是原子操作,这在 C++11 之后是标准行为。你完全可以在一个线程持有 weak_ptr、另一个线程负责销毁对象,因为:
- 只要
lock()成功返回 shared_ptr,在临时 shared_ptr 存活期间,对象绝对不会被销毁; - 即使持有 weak_ptr 的线程刚刚检查完 expired,销毁线程立刻销毁对象,下一次
lock()依然会安全地返回空。
但有一点要特别提醒:use_count()在多线程场景下不可靠。它返回的是某个时刻的快照,读取的瞬间可能已经被别的线程修改。所以use_count() == 1这类判断只能用于单线程调试,不能作为并发逻辑的依据。
5.4 容器里的 weak_ptr 会积累"过期条目"
前面观察者模式里已经提到,vector 里的 weak_ptr 不会自动失效删除。实际项目中如果用 weak_ptr 做缓存或订阅列表,一定要考虑清理策略:
- 要么在每次遍历时顺手 erase 掉 expired 条目(懒清理,代码量最小);
- 要么用
owner_before把 weak_ptr 放进 set/map 里,通过键的失效做索引维护; - 要么在对象析构时主动通知容器把它移除(这要求容器是全局的,注意别把耦合搞复杂)。
很多人在小项目里忽视了这一点,等到运行几周后才发现容器越撑越大。weak_ptr 本身不会泄漏,但"放任过期条目堆积"会导致容器膨胀,这属于使用层面的坑,我踩过不止一次。
6. 更本质的问题:循环引用是所有权设计失误的信号
6.1 先问"谁拥有谁",再决定用哪个智能指针
接触过一段智能指针后你会发现,循环引用与其说是技术问题,不如说是所有权边界不清的设计问题。建议在动手前先回答三个问题:
- 这个对象被创建后,谁负责销毁它?
- 另一个对象只是"用一下"它,还是要"管着"它?
- 如果持有者全部消失,这个对象是否应该随之消失?
回答越清晰,代码越简单。能明确"只有一个所有者"的场景,直接unique_ptr,根本不会出现循环;确属"多人共享、共同拥有"的场景才用shared_ptr;只是"临时访问、不敢保证对方活着"的场景用weak_ptr或裸指针(裸指针要求你能严格保证访问时机,做不到就用 weak_ptr)。
如果你的系统里大量对象互相用 shared_ptr 引用,那多半不是缺一个 weak_ptr 的问题,而是对象的职责边界画得太乱。
6.2 一个更彻底的思路:把共享状态抽出来
有时候两个对象确实"必须互相活着才能工作",比如图形界面里一个控件的模型和视图。这时候硬把一条边改成 weak_ptr,虽然打破了循环,但业务语义上可能变弱——视图访问模型时发现模型已经被销毁,降级逻辑写起来很别扭。
更干净的做法是:把双方都依赖的那部分共享状态抽成第三个对象,由这个共享对象被外部统一持有,模型和视图各自持有它的引用(可以是裸指针或 weak_ptr,看你确信谁会先消失)。这样所有权变成清晰的"外部拥有、内部借用",循环自然消失。
6.3 我的一点经验:每个 shared_ptr 成员都值得被追问
最后分享一个我在 code review 时常用的习惯:看到一个类的成员变量是std::shared_ptr,我会下意识追问"这个成员真的需要参与所有权吗?"十次里有七八次,答案是不需要——它只是需要安全地访问另一个对象。把 shared_ptr 改成 weak_ptr + lock 之后,不仅破了循环,还顺带让代码更明确:这段关系是"借用",不是"占有"。
排查工具虽然能帮你找到泄漏位置,但真正避免循环引用,靠的是在写每一行代码时就把所有权关系想清楚。把"这个指针是拥有的吗"当成一道常规检查题,比事后跑 Valgrind 划算得多。
还有一个细节顺带提一下:C++17 之后shared_ptr可以直接管理数组,比如std::shared_ptr<int[]>,它会正确地用delete[]释放,不要再用shared_ptr<int>(new int[10])这种历史写法了。数组指针、链表指针这些经典话题换到智能指针语境下,核心其实都是同一件事:裸指针强调的是"指向哪里",智能指针强调的是"这份内存由谁负责管理"——想清楚后者,循环引用就不再是噩梦。