news 2026/9/9 8:01:29

shared_ptr 引用计数原理详解:从控制块到线程安全的完整剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
shared_ptr 引用计数原理详解:从控制块到线程安全的完整剖析

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 的核心价值就是用一个可见的、可追踪的计数器,把「谁持有、谁释放」的模糊约定变成机器可验证的客观事实。callerprocess各自持有一份 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 经典引用计数的三条规则

任何引用计数机制都遵循三条最基本的规则:

  1. 构造时计数加一:每当一个新的 shared_ptr 指向某个对象时,该对象的强引用计数增加 1。
  2. 析构时计数减一:每当一个 shared_ptr 不再指向该对象(离开作用域、被 reset、被重新赋值等),强引用计数减少 1。
  3. 计数归零时销毁对象:当强引用计数变成 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&lt;Widget&gt; 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 + 1W 不变或 +1(视实现)
拷贝构造 shared_ptrS + 1不变
移动构造 shared_ptr不变不变
销毁 shared_ptrS - 1不变S 归零时是W 也归零时是
从 weak_ptr 创建 shared_ptr(lock)S + 1不变
销毁 weak_ptr不变W - 1W 归零时是

这张表是理解后续所有行为的基础。值得特别注意的是:某些实现中从裸指针创建第一个 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&lt;Alloc&gt; ::template rebind_alloc&lt;ControlBlockImpl&gt;; cb_alloc cb_allo(allocator); this-&gt;~ControlBlockImpl(); cb_allo.deallocate(this, 1); } };

这段代码展示了几个核心设计:

  • 原子计数std::atomic<long>保证多线程增减安全。
  • 虚函数类型擦除:基类提供统一的销毁接口,子类持有具体的删除器和分配器。
  • 延迟销毁控制块release_shared在 shared count 归零时先销毁对象,再调用release_weak递减 weak count;只有 weak count 也归零时才销毁控制块本身。

4.3 为什么必须用原子操作

引用计数的增减操作必须满足「读-改-写」的原子性。假设没有原子操作,两个线程同时执行p1p2的析构:

  • 线程 A 读取 count = 2,准备减一写回 1。
  • 线程 B 也读取 count = 2,准备减一写回 1。
  • 两个线程都把 1 写回,最终 count = 1,但实际上两个 shared_ptr 都已经析构,对象的强引用应该是 0。

这种经典的「丢失更新」会直接导致内存泄漏或对象永远不销毁。原子操作通过底层硬件指令(如 x86 的lock前缀或 ARM 的ldxr/stxr)保证读-改-写过程不会被其他核心穿插。

内存序的选择也有讲究:计数增减通常使用memory_order_relaxedmemory_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 = 1

use_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,那么ab互相持有强引用,两者离开作用域后计数都不会归零,形成经典的内存泄漏。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 = 2

6.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_ptrnew + shared_ptr对象销毁后内存可立即回收
需要自定义删除器new + shared_ptrmake_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_sharedmake_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 + 1weak count + 1weak 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>>的使用方式。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 7:57:56

Spring Cloud微服务实战:从零搭建注册中心、网关与Feign调用链路

干 Java 开发的&#xff0c;基本绕不开 Spring Cloud。我最早接触微服务的时候&#xff0c;被注册中心、网关、配置中心、负载均衡、熔断降级这些概念砸得头晕。网上资料多&#xff0c;但东一篇西一篇&#xff0c;真正想从零到一搭一套能跑的 Spring Cloud 微服务系统&#xff…

作者头像 李华
网站建设 2026/9/9 7:57:33

DDS选件嵌入AWG:突破存储限制的信号生成新范式

Spectrum 仪器这次推出 DDS 选件&#xff0c;很多人第一反应是“无非又多了一个信号源模式”&#xff0c;但实际用下来&#xff0c;DDS&#xff08;直接数字合成&#xff09;嵌入任意波形发生器&#xff08;AWG&#xff09;之后&#xff0c;解决的是一类长期被存储深度和播放时…

作者头像 李华
网站建设 2026/9/9 7:54:07

边缘计算规模化落地指南:四大特征、技术路线与避坑实战

说实话&#xff0c;这两年做边缘计算相关项目的朋友&#xff0c;普遍有一个很强烈的体感&#xff1a;方案POC&#xff08;概念验证&#xff09;阶段啥都好&#xff0c;一到规模化复制就翻车。单点演示跑得很溜&#xff0c;一上量就暴露出运维成本高、硬件五花八门、算力浪费严重…

作者头像 李华
网站建设 2026/9/9 7:50:04

AI Agent时代企业即时通讯软件怎么选?五条新标准+评估表

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 7:49:33

CodeBuddy vs Cursor:国产AI编程助手能否真正替代?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 7:48:34

OpenCode:轻量级Agent控制面架构与MCP协议实践

1. 项目概述&#xff1a;OpenCode 不是“又一个代码助手”&#xff0c;而是 Agent 控制面的实体化落地OpenCode 这个项目名在 GitHub 上刚出现时&#xff0c;很多人第一反应是“又一个 AI 编程插件”——毕竟带 Code 的开源项目太多了&#xff0c;从 Copilot 到 Cursor&#xf…

作者头像 李华