ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

shared_ptr深度解析:从引用计数到循环引用,掌握现代C++内存管理核心

shared_ptr深度解析:从引用计数到循环引用,掌握现代C++内存管理核心

1. 为什么说shared_ptr是现代C++的"内存管理底线"

先讲个我早年实际踩过的坑。有个项目跑着跑着内存就飙到几个G,排查了两天,最后定位到一处回调代码:某个对象被注册进事件管理器之后,生命周期没人管了。事件管理器持有裸指针,对象提前析构之后回调还在触发,于是function里拿着悬空指针,行为随机崩溃,不崩溃就泄漏。说白了就是"谁负责释放、什么时候释放"这件事,整个项目里没有任何一个人说得清楚。

shared_ptr解决的就是这个核心问题:让对象的所有权被多方智能地共享。它允许多个shared_ptr实例指向同一个堆对象,内部用引用计数记录当前有多少个"持有者"。当最后一个持有者被销毁、重置或赋值时,引用计数归零,对象的内存被自动释放。它引入的核心概念是"所有权",而不仅仅是一个指针包装器。

有人可能会问:那这和Java、Python的垃圾回收有什么区别?区别很大。C++的引用计数是确定性的——计数归零的瞬间析构函数立刻执行,资源(文件句柄、互斥锁、网络连接)立刻被释放,不存在GC的暂停和延迟;代价是你要自己处理循环引用问题。这正是掉坑里最常见的地方,后面专门讲。

如果你是从C++98一路用过来的,可以把shared_ptr理解为一个带计数器的手动释放版本。原来每个堆对象要在堆上拆出一小块内存存引用计数,多个指针对象共享这一个计数。计数器为0时,对象析构、内存归还、计数内存一并归还。这一整套逻辑,C++11起已经内置在标准库里,头文件就是那个最经典的内存头文件。

现在回到我们开头整理的标题:这是一篇"学习笔记",也就是总结性的实战记录。我要写的内容会把这本书里的重点提炼成一条完整的认知路径:先明白它解决的问题,再搞清楚内部实现原理,然后走一遍基础代码写法,接着重点剖析循环引用这个坑,再补充定制删除、数组支持、enable_shared_from_this等进阶细节,最后聊聊性能开销和线程安全边界。这个东西可以说是现代C++里"用起来门槛最低、精通门槛不低"的组件之一,但啃完这篇你就能绕过绝大多数人对它产生的误解。

2. shared_ptr的内部机制:控制块和引用计数是怎么工作的

2.1 引用计数的内存布局

要真正理解shared_ptr,必须清楚两个概念:控制块(control block)和被管理对象(managed object)。这两个东西在内存里是两个独立区域。

控制块是shared_ptr的实现核心,它至少包含:

  • 强引用计数(strong count):当前有多少个shared_ptr指向同一个对象
  • 弱引用计数(weak count):当前有多少个weak_ptr指向同一个控制块(weak_ptr就是为了配合shared_ptr而存在的)
  • 删除器(deleter)的拷贝或引用
  • 分配器(allocator)的相关信息(如果需要)

被管理对象就是真正由new创建的那个堆对象,比如你的std::vector<int>,你的MyClass实例。

当你这样写:

auto sp = std::make_shared<int>(42);

编译器和标准库会做一次内存分配,把这int对象和控制块放在一次分配的连续内存里。这样做有两个好处:

  1. 只做一次堆分配,而不是"对象一次、控制块一次",性能更好。
  2. 对象和控制块在内存里位置相邻,缓存局部性更好。

如果换成:

std::shared_ptr<int> sp(new int(42));

则至少要发生两次堆分配:一次给int,另一次给控制块。多一次分配就多一次失败的可能、多一次free的负担。

这引出一个关键点:创建shared_ptr的两种方式,内部布局是有差别的。第二种方式中,只要存在任何weak_ptr,即使强引用计数已经归零,控制块也无法释放——因为弱引用计数还没归零。但用make_shared之后,对象和控制块是一次性分配的大块内存,控制块存活期等于整块内存存活期。于是出现了一个知名特性:用make_shared时,即使引用计数归零,如果仍存在weak_ptr,对象所占的那块内存基本要等到所有weak_ptr都销毁才能真正归还给系统。这在某些内存极敏感的场景下是需要知道的细节。

2.2 为什么引用计数必须是原子的

你可能会想:引用计数不就是个整数加加减减吗,有什么难的?我可以明确告诉你,标准库里绝对不允许你用普通int来做这件事。

原因在于多线程。假设线程A和线程B同时持有指向同一对象的shared_ptr,A在循环里把它赋值给shared_ptr other = sp,B此刻析构另一个引用同一对象的shared_ptr。如果计数是非原子的:

  • A读计数,B读完计数后判断为1,执行delete,内存被释放。
  • A再写回计数+1,此时操作的是一个已经释放的内存地址,未定义行为。

原子整数操作保证了"读取-修改-写入"这一系列动作的原子性,计数不会出现中间状态,AB两线程要么先看到2再减为1,要么先看到1再加为2。所以std::shared_ptr的引用计数本身就是线程安全的,你不需要额外加锁来保护计数本身。

但是注意:引用计数安全的范围仅限控制块和计数。被指向的那个对象本身是否线程安全?不是。也就是说,多个线程通过各自的shared_ptr同时调用同一对象的非const成员函数,数据竞争依然存在,该加锁还得加锁。后面我会专门展开这个"线程安全的边界"。

2.3 拷贝、移动和析构分别发生了什么

代码最能说明问题:

// 初始状态:一个shared_ptr指向对象 std::shared_ptr<MyType> sp1 = std::make_shared<MyType>(); // 此时强计数为1 // 拷贝构造:另一个shared_ptr共享所有权 std::shared_ptr<MyType> sp2 = sp1; // 强计数变为2,sp1和sp2都指向同一对象 // 对sp2赋值一个新的shared_ptr std::shared_ptr<MyType> sp3 = std::make_shared<MyType>(); sp2 = sp3; // sp2放弃了对旧对象的引用,旧对象强计数减1,变回1 // sp2开始指向新对象,新对象强计数从1变为2

这里有一个初学者经常困惑的点:移动构造或移动赋值不会增加计数。比如:

std::shared_ptr<MyType> sp1 = std::make_shared<MyType>(); std::shared_ptr<MyType> sp2 = std::move(sp1);

sp2接管了sp1指向的对象,sp1变成空指针。移动过程不修改引用计数,因为它不是创建新的所有权,只是把所有权从一个实例"移交"给另一个实例。计数从1到1不变。理解这一点,对写移动语义相关的代码很有帮助。

再看析构:

{ auto sp1 = std::make_shared<MyType>(); auto sp2 = sp1; // 出花括号作用域时:sp2先析构,计数从2变1 // 然后sp1析构,计数从1变0,触发对象析构和内存释放 }

析构函数做的事情一句伪代码就可以概括:

if (--strongRefCount == 0) { // 调用删除器(默认是delete) destroyObject(); if (--weakRefCount == 0) { freeControlBlock(); } }

这个顺序很重要:先消灭对象,再消灭控制块。不能让控制块先消失,否则无法完成计数归零的判断。

3. shared_ptr基础用法:从创建到释放的正确姿势

3.1 创建:make_shared优先,new也能用但别滥用

这不是玄学,是异常安全问题。

看这个经典例子:

void process(std::shared_ptr<A> pa, std::shared_ptr<B> pb); process(std::shared_ptr<A>(new A), std::shared_ptr<B>(new B));

如果编译器求值顺序是:先new A,再new B,然后构造shared_ptr A,然后构造shared_ptr B。此时如果new B抛异常,A的裸指针就已经泄漏了——因为它还没被shared_ptr接管。

用make_shared就没有这个窗口期,因为make_shared把"分配+构造shared_ptr"合为一个动作,每次调用自洽:

process(std::make_shared<A>(), std::make_shared<B>());

即使第二个make_shared抛异常,第一个make_shared构造出来的shared_ptr已经足够安全地自行释放了。

因此我的建议是:默认一律写make_shared,不要写"shared_ptr p(new T)"。只有下面几种情况才必须绕过make_shared:

  • 需要自定义删除器(因为make_shared默认用delete)
  • 需要传自定义分配器
  • 管理的对象是T[]数组(C++17之前;C++17之后make_shared可以识别数组)

3.2 使用细节:reset、swap、operator bool

reset()是shared_ptr的"放弃所有权"操作。它的使用方式很值得注意:

std::shared_ptr<int> sp = std::make_shared<int>(42); sp.reset(); // sp变为空,如果这是最后的引用,对象被释放

也可以带参数:

std::shared_ptr<int> sp = std::make_shared<int>(42); sp.reset(new int(100)); // 放弃旧对象所有权,接管新对象

还有一种更常见的场景:你想临时让指针置空,同时让引用计数递减。很多人直接写sp = nullptr,其实也是等价的,std::shared_ptr的赋值运算符专门处理了空指针的情况。你完全可以按sp = nullptr来理解reset。

use_count()会返回当前强引用计数。但我要提醒一个坑:use_count是即时快照,不能拿来做什么精确逻辑判断。在多线程环境里,你调用if (sp.use_count() > 1)的瞬间,另一个线程可能已经新拷贝了它,或者释放了它。所以只有当代码明确处于单线程上下文、或者判断的目标只是"日志输出、调试观察"时,才适合用use_count。真正的线程间"只读、若仅一个使用者则独占"判断需求,标准解法是用std::atomic_load或直接重构所有权模型。

get()返回裸指针,这是"危险接口"的代名词。后面专门开一节讲,这里只要记住一条铁律:不要通过get()把裸指针传出去然后让另一个独立shared_ptr接管它,也不要对get()返回的指针调用delete。

3.3 同一块内存,不要交给两个shared_ptr管理

很多人会在处理函数参数时犯错误:

int* ptr = new int(10); // 错误示例 std::shared_ptr<int> sp1(ptr); // 再在另一个地方 std::shared_ptr<int> sp2(ptr);

这会导致两个完全独立的控制块,各持有一个相同的内存地址。等到各自计数归零,同一个int被delete两次,直接未定义行为。绝大多数时候运行时会报double free,但某些底层分配器可能吞掉这个信号,产生更隐蔽的错误。

正确做法是:只从一个裸指针初始化一个shared_ptr,之后所有"复用"都是拷贝这个shared_ptr本身,而不是用具针去构造新的shared_ptr。如果权限场景下你需要保存"非拥有性观察者",用weak_ptr或者裸指针都行,但绝不能再用裸指针去构造shared_ptr。

3.4 用auto和lambda配合时常见的生命周期陷阱

有一类代码在项目里特别常见:

std::function<void()> createTask(std::shared_ptr<Buffer> buf) { return [buf]() { process(buf); }; }

lambda按值捕获shared_ptr,会让引用计数加1,从而保证回调执行时对象还活着——这是正确用法。

有坑的写法是:

std::function<void()> createTask(Buffer* buf) { return [buf]() { process(buf); }; }

这里捕获的是裸指针,执行回调时如果外部已经释放这个Buffer,就是悬空指针。所以在现代C++项目里,"回调里持有裸指针"基本是红线。但凡可能有异步执行,一律用shared_ptr捕获所有权。

4. 循环引用和weak_ptr:shared_ptr最大的坑与解法

4.1 循环引用是怎么产生的

这是所有C++从业者迟早要碰到的经典问题:两个对象互相持有对方的shared_ptr。听起来很简单,但造成的后果很严重——两边引用计数永远不可能归零。

一个最典型的模型:

struct Node { std::shared_ptr<Node> next; std::shared_ptr<Node> prev; };

如果两个节点next和prev互指:

  • 第一个Node被第二个Node的prev引用,第二个Node被第一个Node的next引用。
  • 即便你外部把所有指向这两个节点的shared_ptr都置空,它们内部依旧互相引用着对方。
  • 强计数的值至少是1,谁都不会触发析构。

说白了这是一个"死锁"式内存泄漏:对象真的没人用了,但谁也没法第一个放手。这跟两个人互相扶着手说"你先松我先松"一样,永远僵持。

类似的场景还出现在父子关系中、事件监听器注册中、图结构遍历中。凡是"你中有我,我中有你"的所有权设计,都可能踩进这个坑。

4.2 weak_ptr怎么解开循环

weak_ptr的定位就是"非拥有型观察者"。它可以临时观察shared_ptr管理的对象,但不增加强引用计数。它像一个你随时可以过去敲门看看有没有人的工具:对象还在,你就能临时借到一个shared_ptr;对象没了,你就得到一个空引用。

把上面Node改一改:

struct Node { std::shared_ptr<Node> next; std::weak_ptr<Node> prev; // 持有但不拥有 };

prev指向的Node,不会因为prev而无法析构。当外部不再持有第二个节点时,它的强计数可以正常归零并析构,随后第一个节点对其prev的访问会失败——weak_ptr的expired()返回true。

使用weak_ptr的常规姿势是:

if (auto sp = weak.lock()) { // 借用成功,此刻对象一定存活 use(sp); } else { // 对象已被释放 }

lock()返回一个真正意义上的shared_ptr,持有期间对象必然不会析构。这一小段代码也体现了weak_ptr的设计目的:它不是用来直接解引用的,而是提供"借用所有权"的通道。

4.3 排查循环引用的思路:一个真实排查过程复盘

我有一次排查一个服务端模块自带的连接池泄漏,现象很奇怪:从池里拿出来的连接对象在"关闭"后,居然还活跃在日志里,但外部引用已经全部清零。排查链路大概是这样的:

第一步,我先在整个模块里搜索了所有与连接对象相关的使用点,列出所有std::shared_ptr<Connection>的持有者。这一步可以静态做,grep加人脑追踪就够。结果是:连接池的一个管理类持有所有空闲连接;业务层调用时又拷贝一份临时共享。

第二步,我检查业务层临时shared_ptr释放后,连接是否应该归还池中。日志显示并没有触发归还逻辑。于是怀疑在"放回池中"这一步回调里出了问题。

第三步,看管理类和连接对象之间是否互持。最终定位到管理类在连接对象的析构函数中被回调,而连接对象又被管理类持有一份——它们互相形成了环。

解决办法就是引入weak_ptr做解耦:连接对象的析构函数不要持有管理类所有权,只借用一个观察者指针;或者让管理类对连接对象只持有weak_ptr,在归还时先lock再操作。我选了后者,理由是这个连接池设计上应该允许连接对象"自然死亡",而不是被池强制维持。

顺带说一个工具层面的经验:在gdb里观察引用计数并不方便,但可以让一个全局原子计数器的值配合断点输出。还有一个更偷懒的助手——在类的析构函数里打印一条日志,如果析构函数始终没被调用,而引用都已经归零,那基本可以认定有循环引用。

5. 定制删除器、数组管理和enable_shared_from_this

5.1 删除器:不只是delete

默认情况下,shared_ptr析构时执行delete。但现实中我们管理的资源并不全是new出来的堆对象:

  • 一个由fopen创建的文件句柄
  • 一个通过CreateXXX获取的系统句柄
  • 一个用malloc分配且需要free的内存块
  • 一个带特殊清理机制的内部缓存

shared_ptr允许在创建时传入一个删除器,在计数归零时回调它。

// 例子:管理文件句柄,自动关闭 std::shared_ptr<FILE> filePtr(fopen("test.txt", "r"), &fclose); // 同样,管理malloc出来的内存 std::shared_ptr<char> mem(static_cast<char*>(malloc(100)), &free);

注意两点:

  1. 删除器是控制块的一部分。计数归零时,先调用删除器清理资源,再释放控制块。
  2. 删除器类型在shared_ptr的类型中没有体现,也就是说shared_ptr<int>可以带delete删除器,也可以带free删除器。这让你在函数签名里可以直接用shared_ptr类型本身传递"安全句柄"。

自定义删除器最常见的应用是:让第三方库里的C接口指针(如某个通信上下文对象)在C++环境下安全销毁。只要在拿到裸指针那刻就包一个shared_ptr,就能把"C风格内存管理"整个封装进一个RAII实体。

5.2 shared_ptr和数组:C++17的分水岭

在C++17的标准中,std::shared_ptr<T[]>是合法的。默认删除器是delete[],和C++98时代的裸数组堆分配完全匹配。

但是,C++11和C++14时代shared_ptr并不原生支持数组,因为那时模板参数推导仍然按单对象类型处理。很多人因此在老项目里用shared_ptr<std::vector<T>>来替代数组,或者用删除器std::shared_ptr<T>(new T[n], [](T* p){ delete[] p; })。我在C++11时期维护的代码库就是这种方式,后来迁到C++17后全部改成了shared_ptr<T[]>。

如果你现在写新代码且编译器支持C++17,建议直接用:

auto sp = std::make_shared<int[]>(10);

注意C++20后才支持make_shared直接创建数组对象(形如make_shared<int[10]>),而make_shared<int[]>(10)在C++20也完善了。如果是老标准,老老实实走删除器方案。

还有一个更让人意外的点:C++17之前shared_ptr管理数组对象用std::shared_ptr<T>也可以,但一定要传std::default_delete<T[]>作为删除器,否则编译会报错或发生delete/delete[]不匹配。我在做兼容支持时验证过:某些编译器对"shared_ptr (new T[n])"会报不可调用析构函数的错误,而另一些干脆安静地生成未定义行为。所以规范写法比运气重要得多,升级标准库也要同步检查这些用法。

5.3 enable_shared_from_this:把this变成shared_ptr的正确姿势

这是一个高频错误点。有人会这样写:

struct A { std::shared_ptr<A> getSelf() { return std::shared_ptr<A>(this); } };

问题很明显:在某个场景下,A对象之前已经由一个shared_ptr持有。现在getSelf返回了一个新的shared_ptr,它们各自拥有指向同一个A的两个独立控制块。等到两个控制块计数都归零时,A被析构两次,double free。

标准解法是让A继承std::enable_shared_from_this :

#include <memory> struct A : public std::enable_shared_from_this<A> { std::shared_ptr<A> getSelf() { return shared_from_this(); } };

enable_shared_from_this内部维护了一个weak_ptr成员。当A被一个shared_ptr接管时,这个成员被初始化,指向同一个控制块。shared_from_this()就是通过lock这个weak_ptr来返回一个新的shared_ptr。

注意两点:

  • 不要在A的构造函数里调用shared_from_this(),因为此刻管理A的shared_ptr还没建立,weak_ptr是空的,会抛出std::bad_weak_ptr。
  • 不要在栈对象(非shared_ptr管理)上调用shared_from_this(),同样会抛异常。

我的建议是:只有当你真的需要在对象自身代码里向外部传递自身的共享所有权时,再继承这个基类。不要为了"方便"而继承,它是设计信号,不是随手工具。

6. 性能开销、线程安全边界和选型建议

6.1 讲明白shared_ptr到底慢在哪

很多人以为shared_ptr就是个带计数的指针,开销可以忽略。实际上,它比裸指针和unique_ptr确实高一个量级。用一句话总结:shared_ptr是一次"分配+计数+析构分派"的总包。

具体开销来自四部分:

第一,控制块的堆分配。如果用make_shared,只分配一次;如果裸new构造shared_ptr,分配两次。堆分配本身是相对昂贵的操作。

第二,每次拷贝和赋值都要对原子变量做加减操作。原子操作在无竞争时不等于"免费",它编译后通常是lock前缀指令或互锁操作,比普通递增慢很多。虽然现代CPU上原子增减的代价已经大幅下降,但高频拷贝shared_ptr仍能明显拖慢热点路径。

第三,析构路径中存在间接分派。shared_ptr的删除器是一个类型擦除后的可调用对象,调用它需要经过一次间接函数指针,不像unique_ptr在编译期就能确定删除器类型。

第四,控制块本身带了弱计数、删除器指针等信息,内存占用比裸指针大得多。64位系统上,一个shared_ptr一般是16字节(两个指针大小),而裸指针只要8字节。

所以如果你在写极致性能的代码(每秒百万次拷贝指针的高并发路径),shared_ptr不该出现在最内层循环里。更好的做法是:外层持有shared_ptr,内部尽量传递引用或裸指针(并在合适的作用域内借用),减少拷贝次数。

6.2 线程安全边界:计数安全≠对象安全

这是面试里出现频率极高的一个问题,也是日常开发容易误解的地方。正确结论分三点:

  1. 多个线程可以同时读取同一个shared_ptr对象(比如比较大小、判断是否为空),这是安全的。
  2. 多个线程可以同时拷贝和析构各自独立的shared_ptr实例,只要这些实例指向同一个控制块,控制块内的引用计数操作是原子的,安全。
  3. 多个线程不能同时通过shared_ptr读写同一个被管理对象,除非你自己加锁,否则数据竞争。

换句话说:shared_ptr只保证"控制块本身的一致性",不保证"对象本身的一致性"。

举个例子,你有一个std::shared_ptr<std::vector<int>>,线程A调用push_back,线程B同时遍历这个vector。即使引用计数怎么加加减减都正常,vector内部的数据结构已经被线程A破坏,B的遍历行为是未定义的。

还有一个极其容易忽略的坑:同一个shared_ptr实例,不同线程一个读一个写,也是不安全的。比如线程A一直执行auto sp2 = sp;(读取sp去拷贝),线程B执行sp = make_shared<T>(...)(给sp赋值新对象)。如果这两个操作并发,sp内部的裸指针就可能被撕裂。解决方法是把共享的shared_ptr实例放在std::atomic<std::shared_ptr >里(C++20支持别名特化),或者用互斥锁保护。

6.3 什么时候不该用shared_ptr

这是我在带人做代码审查时,强调最多的一点。shared_ptr很好,但不是万能的。它对应的语义是"共享所有权",意味着每个持有者都可能是最后一个释放者,对象的析构时间因此变得不可预测。在要求确定性析构的代码里,这种非确定性就是一个风险。

我的选型建议是:

  • 如果对象只有一个明确的所有者,用unique_ptr,零额外开销,语义清晰。
  • 如果对象是某个容器直接持有、其他位置只是观察,用裸指针或引用,并在设计上约定生命周期由容器决定,别把观察者变成持有者。
  • 只有当对象确实需要被多个子系统异步地、独立地共享时,才用shared_ptr。
  • 如果只在极短的作用域内稳定借用共享对象,考虑传shared_ptr的引用或裸指针,减少拷贝计数。

这个判断不要被"智能指针越高档越好"的惯性带偏。反而我见过很多项目把shared_ptr当万能膏药,结果对象生命周期彻底失控,析构时机从三天一崩变成半天一崩。真正的好设计,是所有权关系在代码里一目了然:这份数据属于谁,谁负责清理,其他人是用引用还是观察者,都写得明明白白。

在我最近参与的一个网络框架重构中,把原来所有裸指针缓存全部改成shared_ptr后,回调悬空问题确实大幅减少;但与此同时,CPU缓存占用提升了,GC式析构延迟导致某些连接关闭后资源不能立即归还。最后路走下来,我们在"回调参数"和"连接生命周期管理"这两个核心场景保留了shared_ptr,而在"连接池内部存储"则改回了unique_ptr加显式释放接口。两两结合,既规避了悬空引用,又让内部资源释放时机可预测。这个取舍我认为比"一刀切用shared_ptr"更值得大家在实际项目中借鉴。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进