1. 引言:为什么 C++ 需要 shared_ptr
C++ 以强大的性能和极致的资源控制能力著称,但长期以来,手动内存管理带来的「悬挂指针」「重复释放」「内存泄漏」三大灾难,一直是大型 C++ 项目中最令人头疼的问题。一个对象可能被多个模块、多个线程、多个容器同时持有,到底谁负责在最后一个使用完成后释放它,往往没有明确的答案。
于是,「引用计数」这一经典思想被引入 C++ 标准库:每当有一个新的管理者接管对象时,计数加一;每当一个管理者不再需要对象时,计数减一;当计数归零时,说明世界上再也没有任何人需要这个对象了,此时安全地销毁它。shared_ptr 正是这一思想的工业化实现。
假设有下面这段朴素的代码:
// 手动管理:隐患重重 class Connection { public: ~Connection() { close(); } void close() { /* 关闭底层连接 */ } }; void process(Connection* conn) { // conn 到底该不该由我释放?调用者还在用吗? } void caller() { Connection* conn = new Connection(); process(conn); // 如果 process 已经 delete 了 conn,这里继续使用就是悬挂指针; // 如果 process 没有 delete,这里忘了 delete 就是内存泄漏。 delete conn; }上面的问题在于:所有权不明确。而 shared_ptr 的核心价值就是用一个可见的、可追踪的计数器,把「谁持有、谁释放」的模糊约定变成机器可验证的客观事实。caller和process各自持有一份 shared_ptr,对象会在最后一个 shared_ptr 离开作用域时自动释放,不再需要任何一方承担「我是最后一个吗」的猜测。
本文将从零开始,由浅入深地拆解 shared_ptr 引用计数的原理:控制块到底存在哪里、强引用与弱引用为什么是两套计数、每次拷贝和赋值背后发生了什么、原子操作为什么必不可少、make_shared 与直接构造有何差别、enable_shared_from_this 为什么能安全地返回 this,以及隐藏在实现细节中的性能开销和典型陷阱。
2. shared_ptr 概述:它到底持有了什么
很多初学者会以为 shared_ptr 只是「一个带计数的指针」,实际上它比这复杂得多,也正因如此,理解它需要先明确两个基础概念:被管理对象和控制块。
2.1 被管理对象
被管理对象就是用户真正关心其生命周期的那个对象。它可以是堆上分配的对象,也可以是任意资源,甚至可以通过自定义删除器来管理非堆资源(如文件句柄、socket、自定义内存池)。shared_ptr 并不关心被管理对象是谁分配的,它只关心两点:如何访问它,以及最终如何销毁它。
2.2 控制块
控制块是 shared_ptr 引用的第二个堆对象,它是引用计数的真正载体。一个典型的控制块至少包含以下内容:
- 强引用计数(shared count):记录当前有多少个 shared_ptr 指向被管理对象。
- 弱引用计数(weak count):记录当前有多少个 weak_ptr 指向被管理对象(以及一些实现中的特殊计数)。
- 删除器:知道应该如何销毁被管理对象。
- 分配器:知道控制块本身应该如何释放(与删除器对应的分配器)。
- 被管理对象指针:指向真正要销毁的对象。
因此,一个 shared_ptr 实例内部通常只包含两个裸指针:指向被管理对象的指针和指向控制块的指针。逻辑结构如下图所示:
shared_ptr 实例 +-------------------+ +---------------------+ | 对象指针 (ptr) | ------> | 被管理对象 | +-------------------+ | (user object) | | 控制块指针 (cb) | ---+ +---------------------+ +-------------------+ | | +---------------------+ +---> | 控制块 (control block)| | - shared count | | - weak count | | - deleter | | - allocator | | - object pointer | +---------------------+正是因为有了独立控制块,多个 shared_ptr 才能共享同一份计数状态:它们各自保存对象指针和控制块指针,而对计数的任何修改都落到同一个控制块上,从而保证全局唯一的引用计数视图。
2.3 为什么需要 weak count
如果只有 shared count,那么当 shared count 归零时,被管理对象被销毁。但此时如果有 weak_ptr 还活着,它仍然需要访问控制块,去查询「对象是否还活着」。于是控制块本身不能被立即释放。标准规定:当 shared count 归零时销毁被管理对象;当 weak count 也归零时,才释放控制块本身。这正是两套计数存在的根本原因,后续章节会详细展开。
3. 引用计数的核心模型
3.1 经典引用计数的三条规则
任何引用计数机制都遵循三条最基本的规则:
- 构造时计数加一:每当一个新的 shared_ptr 指向某个对象时,该对象的强引用计数增加 1。
- 析构时计数减一:每当一个 shared_ptr 不再指向该对象(离开作用域、被 reset、被重新赋值等),强引用计数减少 1。
- 计数归零时销毁对象:当强引用计数变成 0,说明没有任何 shared_ptr 还指向对象,此时销毁被管理对象;当弱引用计数也变成 0,销毁控制块。
用一个最简单的示例演示这个变化过程:
#include <memory> #include <iostream> struct Widget { int value; explicit Widget(int v) : value(v) { std::cout << "Widget constructed\n"; } ~Widget() { std::cout << "Widget destroyed\n"; } }; int main() { std::shared_ptr<Widget> p1(new Widget(42)); // shared count = 1,weak count = 0 { std::shared_ptr<Widget> p2 = p1; // shared count = 2,weak count = 0 } // p2 析构:shared count = 1 p1.reset(); // p1 析构:shared count = 0,Widget 被销毁 return 0; }输出结果会是:先打印构造信息,最后打印析构信息,中间显式展示引用计数的增减。可以使用use_count()在调试阶段观察计数的实时变化。
3.2 引用计数的原子性
朴素引用计数最大的问题是:如果两个线程同时操作同一个 shared_ptr 的控制块,普通的++count和--count会发生数据竞争。C++ 标准明确要求:shared_ptr 对控制块中计数的所有修改都必须是线程安全的,通常通过std::atomic或平台原语实现。
这意味着,多个线程可以安全地同时拷贝同一个 shared_ptr、析构各自持有的副本,而不会造成计数错乱。但需要注意的是,线程安全性只针对控制块的计数操作,并不保证被管理对象本身的线程安全,也不保证同一个 shared_ptr 实例被多个线程同时写时的安全。这一点在第 9 章会专门讨论。
3.3 强引用与弱引用的协同
控制块中的两个计数不是孤立的。weak_ptr 的存在会延长控制块的生命周期,但不会延长被管理对象的生命周期。这种设计允许我们安全地做「弱持有」:观察一个对象是否还存在,但不妨碍它被正常销毁。
下面的状态转换表总结了不同操作对两个计数的影响(假定初始状态为 shared count = S,weak count = W):
| 操作 | shared count 变化 | weak count 变化 | 对象销毁? | 控制块释放? |
|---|---|---|---|---|
| 创建 shared_ptr 指向新对象 | S + 1 | W 不变或 +1(视实现) | 否 | 否 |
| 拷贝构造 shared_ptr | S + 1 | 不变 | 否 | 否 |
| 移动构造 shared_ptr | 不变 | 不变 | 否 | 否 |
| 销毁 shared_ptr | S - 1 | 不变 | S 归零时是 | W 也归零时是 |
| 从 weak_ptr 创建 shared_ptr(lock) | S + 1 | 不变 | 否 | 否 |
| 销毁 weak_ptr | 不变 | W - 1 | 否 | W 归零时是 |
这张表是理解后续所有行为的基础。值得特别注意的是:某些实现中从裸指针创建第一个 shared_ptr 时,weak count 也会被初始化为 1,用来表示「至少有一个 shared_ptr 存在」这一事实,直到 shared count 归零后再由析构逻辑递减 weak count。不同的标准库实现细节略有差异,但语义等价。
4. 控制块(Control Block)的完整解剖
4.1 控制块的标准与实现差异
C++ 标准只规定了 shared_ptr 的语义,并没有规定控制块的具体内存布局。因此 libstdc++(GCC)、libc++(Clang)和 MSVC STL 三家实现的控制块结构各不相同,但它们都必须能支持同样的功能集合。
一个典型控制块需要解决以下问题:
- 强引用计数和弱引用计数的原子操作。
- 被管理对象指针的保存。
- 删除器的类型擦除:shared_ptr 在编译期知道删除器的类型,但控制块要用统一的接口在运行期调用它。
- 分配器的类型擦除:同理,控制块自身销毁时需要按用户提供的分配器释放。
- 虚函数机制:通过虚函数或函数指针实现多态销毁,而无需把 shared_ptr 本身做成模板的多态基类。
4.2 一个简化的控制块实现
下面给出一个教学用途的简化实现,帮助读者建立直观认识。实际标准库实现比这复杂得多,尤其是为了优化大小、避免虚表、优化原子操作和实现 make_shared 的单块分配。
#include <atomic> struct ControlBlockBase { std::atomic<long> shared_count; std::atomic<long> weak_count; explicit ControlBlockBase(long sc, long wc) : shared_count(sc), weak_count(wc) {} virtual ~ControlBlockBase() = default; // 销毁被管理对象 virtual void destroy_object() noexcept = 0; // 销毁控制块自身 virtual void destroy_control_block() noexcept = 0; void add_shared() noexcept { shared_count.fetch_add(1, std::memory_order_relaxed); } void release_shared() noexcept { if (shared_count.fetch_sub(1, std::memory_order_acq_rel) == 1) { destroy_object(); release_weak(); } } void add_weak() noexcept { weak_count.fetch_add(1, std::memory_order_relaxed); } void release_weak() noexcept { if (weak_count.fetch_sub(1, std::memory_order_acq_rel) == 1) { destroy_control_block(); } } }; template <typename T, typename Deleter, typename Alloc> struct ControlBlockImpl : ControlBlockBase { T* object; Deleter deleter; Alloc allocator; ControlBlockImpl(T* obj, Deleter d, Alloc a) : ControlBlockBase(1, 1), object(obj), deleter(std::move(d)), allocator(a) {} void destroy_object() noexcept override { deleter(object); } void destroy_control_block() noexcept override { using cb_alloc = typename std::allocator_traits<Alloc> ::template rebind_alloc<ControlBlockImpl>; cb_alloc cb_allo(allocator); this->~ControlBlockImpl(); cb_allo.deallocate(this, 1); } };这段代码展示了几个核心设计:
- 原子计数:
std::atomic<long>保证多线程增减安全。 - 虚函数类型擦除:基类提供统一的销毁接口,子类持有具体的删除器和分配器。
- 延迟销毁控制块:
release_shared在 shared count 归零时先销毁对象,再调用release_weak递减 weak count;只有 weak count 也归零时才销毁控制块本身。
4.3 为什么必须用原子操作
引用计数的增减操作必须满足「读-改-写」的原子性。假设没有原子操作,两个线程同时执行p1和p2的析构:
- 线程 A 读取 count = 2,准备减一写回 1。
- 线程 B 也读取 count = 2,准备减一写回 1。
- 两个线程都把 1 写回,最终 count = 1,但实际上两个 shared_ptr 都已经析构,对象的强引用应该是 0。
这种经典的「丢失更新」会直接导致内存泄漏或对象永远不销毁。原子操作通过底层硬件指令(如 x86 的lock前缀或 ARM 的ldxr/stxr)保证读-改-写过程不会被其他核心穿插。
内存序的选择也有讲究:计数增减通常使用memory_order_relaxed或memory_order_acq_rel。单纯计数不需要建立与其他数据的顺序关系时用 relaxed 即可;而在 shared count 从 1 变为 0 的那个操作中,需要确保其他线程之前对被管理对象的所有写操作都已完成同步,因此最后一次递减往往使用 release 语义,并在销毁对象前使用 acquire 语义。
5. 引用计数的生命周期:构造、拷贝、移动与赋值
5.1 从裸指针构造
从裸指针构造 shared_ptr 是最常见也最危险的入口。标准会创建一个新的控制块,并将 shared count 初始化为 1:
std::shared_ptr<Widget> sp(new Widget(42)); // 控制块:shared = 1, weak = 1 // 对象指针:指向 Widget危险在于:如果同一个裸指针被用来构造两个独立的 shared_ptr,就会产生两个控制块,每个控制块都认为自己是唯一的管理者,最终造成对象被重复释放:
Widget* raw = new Widget(42); std::shared_ptr<Widget> sp1(raw); // 控制块 A:shared = 1 std::shared_ptr<Widget> sp2(raw); // 控制块 B:shared = 1 // sp1 析构时销毁 Widget;sp2 析构时再次销毁同一 Widget —— 未定义行为!这条规则必须牢记:同一裸指针只能用于构造一次 shared_ptr。如果需要多个管理者,应该从第一个 shared_ptr 拷贝构造,而不是重复使用裸指针。
5.2 拷贝构造与拷贝赋值
拷贝一个 shared_ptr 不会创建新的控制块,而是共享原控制块,并将 shared count 加一:
std::shared_ptr<Widget> a(new Widget(1)); // shared = 1 std::shared_ptr<Widget> b = a; // shared = 2 std::shared_ptr<Widget> c(a); // shared = 3拷贝赋值的语义稍微复杂一点,它相当于「先让右侧对象加一,再让左侧对象减一」:
std::shared_ptr<Widget> x(new Widget(10)); // control block X: shared = 1 std::shared_ptr<Widget> y(new Widget(20)); // control block Y: shared = 1 x = y; // 1. y 的 shared count 加一,变为 2 // 2. x 原来指向的 control block X 的 shared count 减一,变为 0 // 3. Widget(10) 被销毁 // 4. x 现在与 y 共享 control block Y,shared count = 2特别要注意顺序:标准实现会先递增右侧计数,再递减左侧计数。这样可以处理「自我赋值」的情况(虽然自我赋值在 shared_ptr 中无害),更重要的是保证在异常安全的情况下,右侧对象的引用不会被提前释放。
5.3 移动构造与移动赋值
移动操作是 shared_ptr 性能优化的关键。移动构造不改变引用计数,只是把源 shared_ptr 的「对象指针」和「控制块指针」直接转移给目标,然后把源 shared_ptr 置空:
std::shared_ptr<Widget> src(new Widget(99)); // shared = 1 std::shared_ptr<Widget> dst = std::move(src); // shared = 1,不增不减 // src 现在为空,dst 持有原对象由于没有原子操作,移动构造比拷贝构造快得多。移动赋值则等价于「先释放当前持有的对象,再接管源对象的指针」。
移动操作的引用计数变化表:
| 操作 | 源 shared count | 目标 shared count | 原子操作次数 |
|---|---|---|---|
| 拷贝构造 | 不变 | +1 | 至少 1 次 |
| 移动构造 | 不变 | 不变 | 0 次 |
| 拷贝赋值 | +1 | -1(若有旧对象) | 最多 2 次 |
| 移动赋值 | 不变 | 可能 -1(若有旧对象) | 最多 1 次 |
5.4 reset 与 use_count
reset()相当于让当前 shared_ptr 放弃所有权。不带参数时,它递减当前控制块的 shared count;带参数时,它相当于「先释放旧对象,再接管新对象」:
std::shared_ptr<Widget> sp(new Widget(1)); // shared = 1 sp.reset(); // shared = 0,Widget(1) 销毁 sp.reset(new Widget(2)); // 创建新控制块,shared = 1use_count()返回当前的强引用计数,主要用于调试和断言。需要注意的是,在use_count()返回之后到实际使用这个数值之间的时间窗口内,其他线程可能已经改变了计数,因此它不能用于任何需要精确定义的运行期逻辑。
6. weak_ptr 与弱引用计数的深入剖析
6.1 weak_ptr 的定位:不拥有,只观察
weak_ptr 是 shared_ptr 的「旁观者」。它指向同一个控制块,但不会增加 shared count,因此不会阻止被管理对象被销毁。它解决的核心问题是循环引用:
#include <memory> #include <iostream> struct Node; struct Node { std::shared_ptr<Node> next; // 强引用 std::weak_ptr<Node> prev; // 弱引用,打破循环 ~Node() { std::cout << "Node destroyed\n"; } }; int main() { auto a = std::make_shared<Node>(); auto b = std::make_shared<Node>(); a->next = b; // b 的 shared count = 2 b->prev = a; // a 的 shared count = 1(weak_ptr 不增加) // a 离开作用域时 shared count 归零,a 被销毁; // a 被销毁后 b 的 shared count 从 2 变成 1; // b 离开作用域时 shared count 归零,b 被销毁。 return 0; }如果把prev也改成 shared_ptr,那么a和b互相持有强引用,两者离开作用域后计数都不会归零,形成经典的内存泄漏。weak_ptr 正是通过「不增加 shared count,只增加 weak count」来打破这种循环。
6.2 从 shared_ptr 构造 weak_ptr
当我们从 shared_ptr 构造 weak_ptr 时,控制块的 weak count 加一,shared count 不变。这保证了控制块会一直存活到所有 weak_ptr 也销毁为止。
std::shared_ptr<Widget> sp = std::make_shared<Widget>(); // control block: shared = 1, weak = 1 std::weak_ptr<Widget> wp = sp; // control block: shared = 1, weak = 26.3 lock 与 expired
weak_ptr 不能直接访问对象,必须通过lock()尝试提升为 shared_ptr。这个操作是原子的,解决了著名的「检查后使用」竞态问题:
if (!wp.expired()) { // 危险!在这两步之间,对象可能已经被其他线程销毁 auto sp = wp.lock(); } // 正确写法:lock 本身就是原子的检查 + 提升 if (auto sp = wp.lock()) { // 现在 sp 持有强引用,对象保证存活 sp->do_something(); }lock()的内部逻辑是:以原子方式读取 shared count,如果 shared count 是 0(对象已经销毁或正在销毁),返回空 shared_ptr;否则尝试将 shared count 加一,并返回一个新的 shared_ptr。这个检查和加一必须是原子的,否则在检查和加一之间对象可能被销毁,使得返回的 shared_ptr 指向已死对象。
// 简化的 lock 语义 std::shared_ptr<T> weak_ptr<T>::lock() const { auto* cb = control_block; if (!cb) return {}; long cur = cb->shared_count.load(std::memory_order_relaxed); while (cur > 0) { if (cb->shared_count.compare_exchange_weak( cur, cur + 1, std::memory_order_acquire, std::memory_order_relaxed)) { return std::shared_ptr<T>(ptr, cb); // 构造成功 } } return {}; }这个循环是必须的:compare_exchange_weak可能在「其他线程同时修改了 shared count」的情况下偶发失败,因此需要在循环中重试。每次失败后cur会被更新为最新的计数值,循环重新判断cur > 0是否成立。
6.4 weak count 的销毁时机
经典规则是:shared count 归零即销毁对象,weak count 归零即销毁控制块。但在实际实现中还有一种特殊计数:某些库会用「shared count + 1」作为弱计数,其中额外的一个 1 代表「至少有一个 shared_ptr 存在」。当 shared count 从 1 减到 0 时,析构逻辑同时执行一次 weak count 递减,以抵消最初虚增的那一个。这种实现让「有 shared_ptr 活着时控制块一定不能释放」这一约束自然地得到满足,读者在阅读不同标准库源码时会看到这种细节差异。
7. enable_shared_from_this:安全地返回 this
7.1 问题的起源
在编写对象自身需要「给自己创建 shared_ptr」的场景时,最常见的错误是直接在成员函数里写:
class Widget : public std::enable_shared_from_this<Widget> { public: std::shared_ptr<Widget> get_shared() { return std::shared_ptr<Widget>(this); // 灾难! } }; int main() { auto sp1 = std::make_shared<Widget>(); // control block A auto sp2 = sp1->get_shared(); // control block B! // sp1 和 sp2 各自拥有独立的控制块,最终 Widget 被析构两次 —— 未定义行为 }问题的本质还是「一个裸指针被两次交给 shared_ptr 构造」,只不过这次this扮演了裸指针的角色。直接用this构造 shared_ptr 会创建一个全新的控制块,与外部已有的控制块脱钩。
7.2 enable_shared_from_this 的原理
enable_shared_from_this 解决了「如何从对象内部拿到那个已存在的控制块」的问题。它内部通常持有一个weak_ptr,在对象第一次被 shared_ptr 接管时,标准库会把对象的 weak_ptr 指向同一个控制块。之后在成员函数里就可以通过这个 weak_ptr 安全地提升出新的 shared_ptr:
class Widget : public std::enable_shared_from_this<Widget> { public: std::shared_ptr<Widget> get_shared() { return shared_from_this(); // 内部调用 weak_ptr::lock() } }; int main() { auto sp1 = std::make_shared<Widget>(); // 控制块 A auto sp2 = sp1->get_shared(); // 仍然使用控制块 A,shared = 2 // 一切正常,对象只析构一次 }关键在于make_shared或 shared_ptr 构造函数在创建时需要检测T是否派生自enable_shared_from_this,如果是,就把内部 weak_ptr 初始化为指向当前控制块。标准库通过std::enable_shared_from_this的基类与模板元编程(如is_convertible)来完成这一检测。
7.3 使用前提与注意事项
- 对象必须已经被 shared_ptr 管理之后才能调用
shared_from_this();否则内部 weak_ptr 为空或已经过期,调用会抛出std::bad_weak_ptr或产生未定义行为。 - 优先使用
make_shared创建对象;直接new后再构造 shared_ptr 虽然标准库也会初始化 weak_ptr,但会有额外的控制块分配。 - 不要在构造函数或析构函数中调用
shared_from_this():构造时 weak_ptr 尚未初始化,析构时对象可能已经处于销毁流程中。 - 派生类应当公开继承
enable_shared_from_this,并注意基类中调用时的 static type 与 dynamic type 的正确性。
8. 线程安全模型:shared_ptr 到底安全在哪里
8.1 标准保证的范围
C++ 标准对 shared_ptr 的线程安全给出了非常精确的限定,可以总结为三层:
- 不同 shared_ptr 实例操作同一个控制块是线程安全的:多个线程可以同时拷贝同一个 shared_ptr、析构自己的副本,控制块的计数增减由原子操作保证。
- 同一个 shared_ptr 实例被多个线程同时读(拷贝构造)是安全的:因为拷贝只是读取对象指针和控制块指针,不修改实例本身。
- 同一个 shared_ptr 实例被多个线程同时写(赋值、reset)是不安全的:这会导致实例内部两个指针的读写竞态,需要外部同步。
8.2 直观的理解
可以把 shared_ptr 理解为一个「只读句柄的共享计数器」:控制块是全局安全的数据结构,而每个 shared_ptr 实例本身只是两个裸指针。多个线程读写不同的 shared_ptr 实例时,它们实际争抢的是同一个控制块,因此原子计数保护了它们。但如果多个线程读写同一个 shared_ptr 实例,那么争抢的对象变成了这个实例本身的两个裸指针成员,原子计数就无能为力了。
std::shared_ptr<Widget> global_sp; // 全局实例 // 线程 1 和线程 2 同时执行: void reader() { // 安全:只读 global_sp,拷贝构造是 const 操作 std::shared_ptr<Widget> local = global_sp; } void writer() { // 与 reader 并发执行时不安全:写 global_sp 的两个指针成员 global_sp = std::make_shared<Widget>(); }如果需要多个线程同时读写同一个 shared_ptr 实例,应使用std::atomic<std::shared_ptr<T>>(C++20)或外部互斥锁保护。
8.3 被管理对象本身的线程安全性
即便所有 shared_ptr 操作都安全,被管理对象的成员函数仍然需要自己保证线程安全。shared_ptr 只负责「对象什么时候被销毁」,不负责「对象内部数据的并发访问」。
另一个容易被忽略的问题是:当最后一个 shared_ptr 在某线程析构、对象开始销毁的同时,其他线程可能仍然持有着指向对象内部数据的裸指针。由于 shared_ptr 的线程安全保证不包括对「已开始销毁的对象」的访问,这种场景必须通过业务层的生命周期协议来避免。
9. make_shared 与内存布局优化
9.1 两次分配的问题
直接使用new构造 shared_ptr 会发生两次堆分配:一次分配被管理对象,一次分配控制块。两次分配增加了内存碎片、缓存未命中和分配器压力:
std::shared_ptr<Widget> sp(new Widget(42)); // 分配 1:Widget 对象 // 分配 2:控制块9.2 make_shared 的单块分配
make_shared通过一次分配同时容纳对象和控制块。标准库会分配一块足够大的连续内存,在内存头部放置控制块,紧接着放置被管理对象:
auto sp = std::make_shared<Widget>(42); // 仅一次分配: // +----------------+------------------+ // | 控制块 | Widget 对象 | // +----------------+------------------+ // 对象指针指向右侧首地址这种布局带来显著优势:
- 堆分配次数从 2 次降为 1 次,减少分配器调用和内存管理开销。
- 控制块和对象在内存中相邻,缓存局部性更好,访问对象时控制块可能已经在同一缓存行。
- 异常安全更简单:一次分配成功就表示对象和控制块都已就绪。
9.3 make_shared 的代价
单块分配的代价是:当 shared count 归零时,对象被销毁,但内存块不能释放,因为控制块仍然和对象共享同一块内存。直到 weak count 也归零时,整块内存才能还给分配器。
如果存在大量 weak_ptr 长期持有,make_shared 会导致对象虽然已销毁,但其占用的内存仍然无法回收。对于对象内存占用非常大的场景,这个代价可能超过它带来的收益,此时可以改用直接new的方式,让控制块和对象分离,使对象内存可以尽快释放。
9.4 何时使用哪种方式
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 一般对象创建 | make_shared | 一次分配、缓存友好、异常安全 |
| 对象巨大且可能有大量 weak_ptr | new + shared_ptr | 对象销毁后内存可立即回收 |
| 需要自定义删除器 | new + shared_ptr | make_shared 不支持自定义删除器 |
| 需要自定义分配器 | allocate_shared | 控制块和对象都使用用户分配器 |
| 对象构造参数可能抛出异常 | make_shared | 异常时自动清理,无需手动 delete |
10. 引用计数带来的性能开销
10.1 原子操作的真实成本
引用计数并非免费的午餐。每一次拷贝、每一次析构、每一次赋值都涉及对控制块的原子操作。在多核处理器上,原子操作虽然比早期的锁廉价,但依然比普通加减法昂贵得多,因为它需要:
- 阻止指令重排(内存屏障)。
- 获取缓存行的独占权(可能触发缓存一致性协议 MESI 的状态转换)。
- 在争用激烈时产生自旋或总线锁。
在单线程性能敏感的循环里,频繁拷贝 shared_ptr 可能成为热点:
// 每次迭代都触发一次原子递增和一次原子递减 for (int i = 0; i < 100000; ++i) { std::shared_ptr<Widget> tmp = global_sp; // 原子 ++ process(tmp); } // 原子 --优化手法是:在循环外拷贝一次,循环内通过引用或裸指针传递;或者优先使用移动语义减少原子操作。
std::shared_ptr<Widget> local = global_sp; // 只递增一次 for (int i = 0; i < 100000; ++i) { process(local); // 通过引用传递,无原子操作 }10.2 空间开销
一个 shared_ptr 实例通常是两个裸指针的大小,在 64 位系统上是 16 字节。控制块本身的典型大小在 24 到 48 字节之间,取决于实现和是否包含删除器、分配器及 weak count。对于大量小对象的场景,控制块的空间开销可能接近甚至超过对象本身。
10.3 缓存局部性
shared_ptr 每一次操作都要访问控制块,而控制块和被管理对象通常不在同一缓存行(除非使用 make_shared)。大量 shared_ptr 操作会导致频繁的缓存未命中。在需要极致性能的场景,可以考虑使用侵入式引用计数(对象内部自带计数),其缓存局部性通常优于外挂控制块。
11. 常见陷阱与最佳实践
11.1 陷阱一:裸指针多重构造
同一个裸指针构造多个 shared_ptr,是引用计数体系中最典型的错误,必须铭刻于心:
Widget* raw = new Widget(); std::shared_ptr<Widget> a(raw); std::shared_ptr<Widget> b(raw); // 两个控制块,双重释放!正确做法是创建一个 shared_ptr 后,所有后续 shared_ptr 都从它拷贝或移动构造。
11.2 陷阱二:循环引用导致泄漏
两个对象互相持有 shared_ptr 会在离开作用域后计数无法归零。应使用 weak_ptr 打破循环,但前提是业务逻辑允许其中一方不拥有对方。
11.3 陷阱三:在构造函数中调用 shared_from_this
enable_shared_from_this 的内部 weak_ptr 只在对象被 shared_ptr 接管后才初始化,构造函数内调用shared_from_this()会抛出bad_weak_ptr。正确的模式是使用工厂函数或延迟到其他成员函数中调用。
11.4 陷阱四:在析构过程中访问过期的 weak_ptr
当 shared count 已经归零、对象正在析构时,其他线程可能持有 weak_ptr 并尝试lock()。lock 会失败并返回空指针,这个行为是安全的,但业务代码必须正确处理空结果,不能假设 lock 成功。
11.5 陷阱五:误用 use_count
use_count()的返回值在多线程环境下没有任何实时保证。不要用它做任何资源决策,它只适合调试和日志。
11.6 陷阱六:把 shared_ptr 传入需要裸指针的 C 接口
void legacy_api(Widget* widget); // C 接口,不管理生命周期 std::shared_ptr<Widget> sp = std::make_shared<Widget>(); legacy_api(sp.get()); // 可以,但要保证 sp 在 legacy_api 返回前一直存活 // 不能把 sp.get() 交给一个会在后续异步持有的接口,否则对象可能已销毁11.7 陷阱七:shared_ptr 指向栈对象
shared_ptr 默认用 delete(或自定义删除器)销毁对象。如果给它一个栈对象的指针却没有提供不删除的删除器,析构时会调用 delete 栈地址,这是未定义行为。
11.8 最佳实践汇总
- 优先使用
make_shared和make_unique创建智能指针。 - 一个裸指针只能交给 shared_ptr 一次。
- 打破循环引用时使用 weak_ptr。
- 返回 this 时使用
enable_shared_from_this,且不要自己 new 一个 shared_ptr 包 this。 - 参数传递时:如果函数只是使用对象而不保存所有权,用裸指针或引用;如果函数要保存所有权,用 shared_ptr 值传递。
- 性能敏感路径尽量减少 shared_ptr 的拷贝,优先移动或引用传递。
- 多线程写同一个 shared_ptr 实例时,务必使用
atomic<shared_ptr<T>>或互斥锁。
12. 深入实现:主流标准库的差异与共同点
12.1 三巨头概览
| 特性 | libstdc++ (GCC) | libc++ (Clang) | MSVC STL |
|---|---|---|---|
| 原子计数类型 | atomic 内置 | atomic 内置 | atomic 内置 |
| 控制块释放策略 | weak count 归零释放 | weak count 归零释放 | weak count 归零释放 |
| make_shared 单块分配 | 支持 | 支持 | 支持 |
| weak count 表示 | weak count + 1 | weak count + 1 | weak count + 1 |
| 类型擦除方式 | 虚函数 | 虚函数 | 虚函数 |
三家实现虽然细节不同,但都遵循标准语义。理解「weak count + 1」这个细节很重要:初始时 weak count 被设置为 1,表示「有一个 shared_ptr 存在」;当 shared count 归零后,析构流程会额外递减一次 weak count,使得控制块可以在所有 weak_ptr 消失后释放。这种表示避免了「有 shared_ptr 但 weak count 为 0」的非法状态。
12.2 为什么标准不约束实现
C++ 标准委员会刻意不规定控制块的具体布局,原因在于不同的平台和缓存体系下,最优的内存布局和原子操作策略可能完全不同。标准只需要保证语义和线程安全,留给实现者充分的优化空间。这也是为什么学习 shared_ptr 时,理解「语义」比记忆「某个库的源码」更重要的原因。
13. 与其他智能指针的对比与选择指南
13.1 unique_ptr:独占所有权,零额外开销
unique_ptr 不涉及引用计数,它在大多数情况下零开销,析构时直接销毁对象。当所有权只有一个时,应该优先使用 unique_ptr,需要共享时才转换为 shared_ptr。
13.2 weak_ptr:弱观察者
weak_ptr 无法单独管理对象,必须依附于 shared_ptr。它适合表达「可选的、不拥有所有权的观察引用」。
13.3 shared_ptr:共享所有权,引用计数开销
shared_ptr 只在真正需要多个所有者时才使用。滥用 shared_ptr 会让所有权模型模糊化,并带来不必要的原子开销。一个好的经验法则是:如果能在编译期确定所有权,就不要使用运行期的引用计数。
13.4 选择决策流程
- 对象只有一个明确所有者:使用
unique_ptr。 - 需要多个所有者,且生命周期可以共享:使用
shared_ptr。 - 需要观察但不阻止销毁:使用
weak_ptr。 - 需要极度性能且对象自身可嵌入计数:考虑侵入式引用计数。
14. 总结
shared_ptr 的引用计数原理可以浓缩为一句话:用控制块集中管理两套原子计数,shared count 归零销毁对象,weak count 归零销毁控制块,所有计数修改都通过原子操作保证多线程安全。
本文沿这条主线展开,详细讨论了控制块的内部结构、构造和拷贝移动赋值过程中的计数变化、weak_ptr 与弱计数的关系、enable_shared_from_this 的原理、线程安全边界、make_shared 的一次分配优化、性能开销来源以及七个常见陷阱。
理解 shared_ptr 不只是记住 API,更是理解 C++ 对象所有权模型的精妙之处:通过一个看似简单的计数器,C++ 在不引入垃圾回收器的前提下,提供了确定性的资源释放时机和安全的共享所有权。掌握了引用计数的原理,你就能在大型项目中自信地管理对象生命周期,避开双重释放、循环引用和竞态条件等暗礁。
延伸阅读建议:
- 《Effective Modern C++》中关于智能指针的 Item 18-22。
- C++ 标准中 shared_ptr 与 atomic 相关的章节。
- libstdc++、libc++ 或 MSVC STL 的 shared_ptr 源码,理解其控制块实现。
- C++20 引入的
std::atomic<std::shared_ptr<T>>的使用方式。