
裸指针在 C 里是个老生常谈的话题真正让它在工程里变得危险的不是它难用而是它太容易用错——申请了忘记释放、异常抛出后释放语句被跳过、两个对象互相持有时谁都走不掉。shared_ptr 共享指针就是冲着这几个场景来的它把谁来释放这件事从人的记忆转移到了对象的生命周期上用引用计数记录还有多少个拥有者最后一个拥有者离开时资源自动回收。这篇文章面向的是已经会写 C、但还没系统用过 shared_ptr 的同学也适合那些用过却踩过坑、想回头把原理补齐的人。我会从它到底智能在哪讲起拆开控制块、引用计数、强引用与弱引用的分工再落到基本用法的完整链路、make_shared 与直接 new 的差异、enable_shared_from_this 的实现逻辑最后把循环引用、自定义删除器、线程安全边界这些真刀真枪的问题拿出来逐个解决。文中每一段代码都可以直接复制运行每一处注意事项都来自我自己在项目里踩过的坑。1. 先把 shared_ptr 的本质搞清楚它到底智能在哪1.1 从裸指针的三个真实痛点说起在没有 shared_ptr 的年代一个资源被多个对象共享时你得自己约定谁负责 delete。这种约定一旦被打破要么提前释放导致悬空指针要么忘记释放导致内存泄漏。我见过最典型的一种写法是一个缓存类持有某块数据的裸指针同时业务模块也持有同一个指针两边都觉得自己只是借用结果谁都没释放跑一天内存涨到几个 G。第二个痛点是异常安全new之后如果中间某步抛出异常后面的delete永远不会执行哪怕你写得再规范也顶不住。第三个痛点是所有权传递不清晰函数参数写成T*时调用者根本不知道这个指针会不会被接管是要转移所有权还是只是临时读一下。shared_ptr 把这三点一次性解决了它用引用计数表达当前有多少个拥有者用 RAII 保证计数归零时一定执行释放用类型本身表达这个指针是共享所有权。你看到std::shared_ptrT就默认知道这里存在共享且不需要手动 delete。这不是语法糖而是把所有权语义写进了类型系统里。需要先说清楚一点shared_ptr 不是万能的它只解决共享所有权且生命周期不确定的场景。如果一个资源从头到尾只有一个拥有者用std::unique_ptr更轻、更快、语义也更明确。我自己的选型顺序一直是能用栈对象就用栈对象需要独占堆对象用 unique_ptr确实要共享才上 shared_ptr。很多人一上来所有指针都写成 shared_ptr等于把引用计数的开销和循环引用的风险无差别地铺满整个工程这是典型的用错场景。1.2 控制块shared_ptr 真正的核心shared_ptr 对象本身只有两个指针大小一个指向被管理的对象一个指向控制块。控制块是堆上的一块独立内存里面至少装着四个东西强引用计数、弱引用计数、被管理对象的指针或指针的副本以及一个类型擦除后的删除器。理解这一点非常关键因为后面所有的行为差异——use_count 的返回值、make_shared 为什么快、weak_ptr 为什么能观测对象——都由控制块决定。为什么要单独搞一个控制块而不是把计数直接放在对象里因为对象类型是用户定义的你不能要求每个被管理的类都内置一个计数成员那会污染业务类型而且碰到数组、C 接口返回的裸指针、别人写好的第三方类型时根本改不了。控制块通过类型擦除把删除器和指针存起来所以shared_ptrvoid也能正确析构这一点在管理自定义资源时特别有用。还有一个容易忽略的细节从同一个裸指针构造两个独立的 shared_ptr会生成两个独立的控制块各自记一份计数最后同一块内存被 delete 两次。这是崩溃级错误也是面试和 review 里的高频考点。int* raw new int(10); std::shared_ptrint a(raw); std::shared_ptrint b(raw); // 错误两个控制块double free注意只要一个裸指针交给 shared_ptr 托管就不要再把这个裸指针交给第二个 shared_ptr也不要在外面手动 delete。1.3 引用计数与强引用、弱引用的分工控制块里有两个计数强引用计数shared count和弱引用计数weak count。强引用计数管的是对象什么时候析构弱引用计数管的是控制块什么时候析构。这两个生命周期是分开的这是很多人第一次读源码时最困惑的地方。当你 copy 一个 shared_ptr强引用计数加一当一个 shared_ptr 析构强引用计数减一。强引用计数归零的那一刻被管理的对象被销毁执行删除器或 delete。但此时控制块可能还不能释放因为可能还有 weak_ptr 在通过它观测对象。weak_ptr 不增加强引用计数只增加弱引用计数它调用lock()时先检查强引用计数是否大于零大于零就返回一个新的 shared_ptr 并把强引用计数加一否则返回空。这套分工带来一个直接结果use_count()返回的是强引用计数的值它不包含 weak_ptr 的数量。所以你在调试时发现use_count()是 0 却仍有一块内存没释放很可能是弱引用还没清完这在排内存增长的案子时非常关键。2. 基本用法从创建到销毁的完整链路2.1 三种创建方式与推荐顺序shared_ptr 的创建方式主要有三种默认构造空指针、用裸指针构造、用 make_shared 构造。空构造没什么好说的重点在后面两种。用裸指针构造的写法是std::shared_ptrT p(new T(...))make_shared 的写法是std::shared_ptrT p std::make_sharedT(...)。我给出的推荐顺序非常明确优先 make_shared不要在能用的时候用裸指针构造。原因有三。第一是内存分配次数make_shared 把对象和控制块合并成一次分配裸指针构造需要两次。第二是异常安全func(std::shared_ptrT(new T), g())这种写法里如果 g() 抛出异常new 出来的对象可能泄漏而func(std::make_sharedT(), g())不存在这个窗口。第三是缓存友好对象和控制块相邻访问时命中率更高。make_shared 也不是没有缺点。它把对象和控制块绑在同一块内存上所以即使强引用计数归零、对象析构了只要还有 weak_ptr 存活整块内存就不能还给系统。如果你有个体积很大的对象同时又有长期的 weak_ptr 观测它用裸指针构造反而更合适因为对象内存可以单独释放。这个取舍在长连接、大缓冲区这类场景里是实打实的差别。#include memory #include iostream struct Big { int data[1024]; Big() { std::cout Big constructed\n; } ~Big() { std::cout Big destroyed\n; } }; int main() { auto p1 std::make_sharedBig(); // 一次分配 std::shared_ptrBig p2(new Big()); // 两次分配 std::weak_ptrBig w p1; p1.reset(); // 此时 Big 已析构但 make_shared 的整块内存仍被 w 占着 }2.2 引用计数的查看与生命周期演示理解生命周期最快的方法是打日志看 use_count 的变化。下面这段代码把创建、拷贝、传参、reset 四个动作的计数变化全打出来跑一遍就对计数什么时候加、什么时候减有肌肉记忆了。#include memory #include iostream struct Track { std::string name; Track(std::string n) : name(std::move(n)) { std::cout name 构造\n; } ~Track() { std::cout name 析构\n; } }; void takeByValue(std::shared_ptrTrack p) { std::cout 函数内 use_count p.use_count() \n; } void takeByRef(const std::shared_ptrTrack p) { std::cout 引用传参 use_count p.use_count() \n; } int main() { auto a std::make_sharedTrack(A); std::cout 创建后 count a.use_count() \n; // 1 auto b a; std::cout 拷贝后 count a.use_count() \n; // 2 takeByRef(a); // 不加计数 takeByValue(a); // 加一出函数减一 b.reset(); std::cout b reset 后 count a.use_count() \n; // 1 a.reset(); std::cout a reset 后对象应已析构\n; // 析构 }这里有个实际项目里很常见的性能问题函数参数写成std::shared_ptrT值传递会触发一次原子计数加一和一次减一。原子操作虽然不慢但它在高并发路径上会成为竞争热点因为多个线程同时操作同一个控制块时所有计数操作都要串行化。所以我的原则是只读参数一律用const std::shared_ptrT需要延长生命周期或共享所有权时才值传递或 move。2.3 常用成员函数速查shared_ptr 的接口不多但每个都有明确的使用场景和坑点我把它们整理成一张表方便对照查阅。成员函数作用使用要点get()返回裸指针只用于传给不接受 shared_ptr 的接口禁止 delete禁止再构造 shared_ptruse_count()返回强引用计数调试用多线程下只是瞬时快照不能当同步手段reset()释放当前所有权可换新对象无参调用等价于置空会减计数reset(p)改为托管 p会释放原来的所有权swap(other)交换两个 shared_ptr常用于 Pimpl 惯用法无异常operator bool判断是否持有对象if (p)比p.get() ! nullptr更地道operator*/operator-解引用空指针解引用是未定义行为get()的坑要单独强调。我在 code review 里见过有人为了调一个老接口写出oldApi(p.get()); delete p.get();这种操作直接把 shared_ptr 的自动管理打穿了。正确做法是让老接口接受裸指针但不要在里面接管所有权如果老接口内部会 delete那说明它的所有权语义和 shared_ptr 冲突应该包一层自定义删除器或者干脆复制数据。提示get()的返回值只在当前 shared_ptr 存活期间有效别把它缓存起来跨作用域使用。3. 原理拆解make_shared 为什么更快enable_shared_from_this 又是怎么回事3.1 make_shared 与直接 new 的内存布局差异前面提到 make_shared 只分配一次内存这里展开说清楚布局。用std::shared_ptrT(new T)构造时第一步new T在堆上分配对象本身第二步 shared_ptr 的构造函数再分配一块控制块控制块里存删除器、两个计数和被管理对象的指针。两次 new 意味着两次系统调用、两次锁、两次可能的分配失败点。make_shared 的做法是申请一块足够大的内存前面放控制块后面紧跟着对象然后在这块内存上做 placement new 构造对象。控制块里存的就不是一个独立的对象指针而是一个指向自己尾部对象的指针。这样一来分配只有一次释放也只有一次缓存局部性也好得多。但这带来一个前面提过的副作用释放时机被绑定了。正常情况下对象析构和执行 delete 是同时发生的而 make_shared 里对象析构和控制块的内存释放被拆成了两个阶段中间隔着 weak_ptr 的存活期。所以判断标准很简单对象大且存在长期 weak_ptr用裸指针构造分开分配其余情况一律 make_shared。3.2 引用计数的原子操作与线程安全边界控制块里的强引用计数和弱引用计数都是原子类型所以多个线程同时对不同的 shared_ptr 副本做拷贝和析构是安全的。这一点经常被误解成shared_ptr 是线程安全的两者的差别很大。准确的表述是shared_ptr 的控制块操作是线程安全的但同一个 shared_ptr 对象的读写不是线程安全的。如果你有一个全局的 shared_ptr一个线程在读拷贝另一个线程在写reset这就构成数据竞争需要加锁或者用std::atomicstd::shared_ptrTC20 起保护。而如果是多个线程各持有自己的副本只是销毁顺序不同那是完全安全的因为计数是原子的。std::shared_ptrWidget g; // 线程 A g std::make_sharedWidget(); // 写 // 线程 B auto local g; // 读与 A 竞争未定义行为 // 正确做法加锁 std::mutex m; { std::lock_guardstd::mutex lk(m); auto local g; }还有个细节引用计数减到零时执行删除器这个动作是在最后一个 shared_ptr 析构的那个线程里同步完成的不是在某个后台线程。所以如果你在析构函数里做了耗时操作需要意识到它可能拖慢那个恰好最后离开的线程。我在一个网络库项目里就遇到过一个连接的析构要关 socket 并写日志结果每次都是 IO 线程承担这部分开销后来把重活挪到了专门的回收队列里。3.3 enable_shared_from_this 的实现原理与典型误用一个类如果需要在成员函数里返回指向自己的 shared_ptr直接写std::shared_ptrT(this)是错的因为那会创建一个新的控制块导致 double free。正确的做法是让这个类继承std::enable_shared_from_thisT然后在成员函数里调用shared_from_this()。它的原理是这个基类里藏了一个weak_ptrT成员。当对象第一次被 shared_ptr 接管时准确说是在 shared_ptr 的构造函数里会检测到类型继承自 enable_shared_from_this然后把新生成的控制块里的弱引用填进那个 weak_ptr 成员。调用shared_from_this()时就是拿这个 weak_ptr 去 lock得到一个新的 shared_ptr。这解释了两个常见错误。第一如果对象不是被 shared_ptr 管理的比如是在栈上创建的那个 weak_ptr 是空的调用shared_from_this()会抛std::bad_weak_ptr。第二不能在构造函数里调用shared_from_this()因为在那个时刻 shared_ptr 还没构造完成weak_ptr 还没被填进去。#include memory #include iostream class Session : public std::enable_shared_from_thisSession { public: void start() { // 错误示范auto self std::shared_ptrSession(this); auto self shared_from_this(); std::cout use_count self.use_count() \n; } }; int main() { auto s std::make_sharedSession(); s-start(); // 此时 count 变成 2 Session stack; // stack.start(); // 抛 std::bad_weak_ptr }在异步编程里这个机制用得最多一个对象发起异步操作需要保证操作完成前对象不被销毁就在发起时捕获shared_from_this()回调结束后引用计数自然归零。这是 C 里实现对象自我保活的标准手法。4. 实操中必须踩过的坑循环引用与删除器4.1 循环引用是怎么把内存锁死的循环引用是 shared_ptr 最经典的问题也是它唯一需要使用者主动防御的设计代价。场景很常见父对象持有子对象的 shared_ptr子对象又持有父对象的 shared_ptr两个强引用互相撑着计数永远到不了零两块内存一起泄漏。struct Child; struct Parent { std::shared_ptrChild child; ~Parent() { std::cout Parent 析构\n; } }; struct Child { std::shared_ptrParent parent; // 循环就在这里 ~Child() { std::cout Child 析构\n; } }; int main() { auto p std::make_sharedParent(); auto c std::make_sharedChild(); p-child c; c-parent p; // 离开作用域两个析构函数都不会被调用 }跑一下你会发现两行析构日志一行都不打。原因是 p 的计数被 c-parent 多撑了一份c 的计数被 p-child 多撑了一份各自都到不了零。这类泄漏不会崩溃、不会报错只会让内存慢慢涨非常难查用 Valgrind 或者 ASan 的泄漏检测能抓出来但前提是你先怀疑到这里。4.2 weak_ptr 破环的实操写法解决循环引用的标准做法是把其中一条边改成 weak_ptr。改哪一条原则是谁的生命周期短、谁是观察者谁用 weak。在 Parent-Child 这个例子里通常是子对象观察父对象所以把 Child 里的shared_ptrParent改成weak_ptrParent。用了 weak_ptr 之后访问对象前必须先lock()因为对象可能已经没了。这个步骤不能省也不能用expired()判断之后再解引用因为两步之间对象可能被销毁存在竞态。正确写法是 lock 出来的临时 shared_ptr 判空。struct Child { std::weak_ptrParent parent; void doSomething() { if (auto p parent.lock()) { // 这里 p 保证有效 } else { // 父对象已销毁 } } ~Child() { std::cout Child 析构\n; } };weak_ptr 还有几个容易忘的点。它不支持operator*和operator-必须 lock它的use_count()返回的是它所观测控制块的强引用数它可以用在缓存里做弱引用表对象活着就复用死了就重新建这是 shared_ptr 在缓存设计中很实用的一种模式。4.3 自定义删除器管理文件句柄、数组、C 接口资源shared_ptr 的默认删除器是delete所以直接拿它管文件句柄、socket、C 库返回的资源是不行的必须传自定义删除器。删除器在构造时被类型擦除存进控制块因此它不会出现在 shared_ptr 的类型里——std::shared_ptrFILE无论删除器是什么类型都一样。这和 unique_ptr 不同unique_ptr 把删除器写进类型类型会更复杂但无运行时开销。#include memory #include cstdio struct FileCloser { void operator()(std::FILE* f) const { if (f) std::fclose(f); } }; int main() { std::shared_ptrstd::FILE fp(std::fopen(data.txt, r), FileCloser{}); if (fp) { // 使用 fp.get() 传给 C 接口 } // 作用域结束自动 fclose }管理数组时要注意默认删除器是delete而不是delete[]直接std::shared_ptrint(new int[10])是错的。C17 之后可以用std::shared_ptrint[](new int[10])或者std::make_sharedint[](10)删除器会自动匹配成delete[]。C17 之前就老老实实传std::default_deleteint[]()。还有一类特殊情况一个对象同时需要 shared_ptr 和 unique_ptr 管理或者一个类内部想拿自己的 shared_ptr 但删除逻辑要定制这时候用别名构造函数比较合适它能保留同一个控制块但把get()指向另一个对象常用于托管成员的一部分。注意自定义删除器必须是可拷贝的而且不能抛异常否则在引用计数归零时会触发 terminate。5. 常见问题排查速查与性能取舍5.1 典型问题速查表把前面散落的坑集中成一张表遇到问题可以直接对照定位。现象可能原因排查方向double free 崩溃同一裸指针构造了两个 shared_ptr检索裸指针是否被托管两次内存只涨不降循环引用画出对象引用图找强引用环use_count 为 0 内存未释放存在 weak_ptr检查弱引用所有者是否清空shared_from_this 抛 bad_weak_ptr对象非 shared_ptr 托管或在构造函数中调用确认创建方式移到 init 函数里调用多线程下计数异常同一 shared_ptr 被并发读写加锁或用 atomicshared_ptr大数据对象内存迟迟不释放make_shared 与控制块同块内存weak 延长占用改用裸指针构造分开分配数组析构崩溃删除器是 delete 而非 delete[]用 C17 的数组特化或默认删除器这张表覆盖了我实际工作中 90% 以上的 shared_ptr 问题。真正难的不是修复而是定位尤其是循环引用和弱引用持有这两个因为它们没有任何报错只能靠工具和经验。5.2 参数选择什么时候不该用 shared_ptr很多人默认所有需要指针的地方都用 shared_ptr这是个隐性的成本问题。shared_ptr 对象本身两个指针大小控制块加起来至少 16 字节起步每次拷贝要一次原子操作每次析构要一次原子操作加一次间接调用删除器。在性能敏感路径上这些开销会累积。我总结的判断标准是所有权是否真的需要共享。不需要共享的用 unique_ptr不需要堆分配的用栈对象只是临时观察的用裸指针或引用需要观测但不持有的用 weak_ptr。shared_ptr 应该出现在多个对象共同拥有同一个资源且释放时机由最后一个离开者决定的场景里比如共享的配置快照、被多个消费者引用的缓存项、异步任务中的对象保活。还有一个常见误区是在函数返回值上滥用。返回std::shared_ptrT会触发移动构造本身开销很小但如果一个函数只是查询数据返回 shared_ptr 就等于把所有权扩散出去了调用者一旦长期持有对象生命周期就被拉长。这种情况返回引用或值更合适只有确实要传递所有权时才返回 shared_ptr。5.3 我踩过几次坑之后的实操心得第一条心得是关于调试的排查内存问题时先在怀疑的对象析构函数里打日志。如果日志一直是成对出现说明生命周期正常如果某个对象的构造日志一直在涨但析构日志不动基本可以锁定泄漏再去查引用关系图。这比一上来就上工具快得多。第二条是关于 weak_ptr 的使用节奏不要在一个函数里反复lock()因为每次 lock 都会加一次原子计数而且多次 lock 之间状态可能变化。正确做法是开头 lock 一次判空后存到局部变量后续都用这个局部变量。这个习惯在回调里尤其重要。第三条是关于 shared_ptr 和容器的配合把 shared_ptr 放进std::vector时扩容会拷贝所有元素每次拷贝都是一次原子操作。如果容器很大建议用std::vectorstd::unique_ptrT或者用reserve提前预留容量减少不必要的计数抖动。我在一个批量任务队列上把 vector 换成 unique_ptr 容器后扩容相关的性能毛刺明显减少。第四条是关于自定义删除器的异常安全删除器里不要做可能失败的操作比如删除文件、发送网络包。删除器的执行时机是栈展开过程中一旦这里再抛异常程序直接终止。需要做清理动作的话把任务投递到一个清理队列让删除器只负责转移责任。最后再分享一个判断技巧用来快速识别代码里的循环引用隐患拿到一个共享指针的类关系图把每个 shared_ptr 成员画成实线箭头weak_ptr 画成虚线箭头。只要实线能连成一个环那里就是问题点。这个方法不需要任何工具review 的时候在纸上画两分钟就能找出大部分隐患比等到线上内存报警再回来debug要划算得多。对于后续想继续深入的读者可以顺着这条线往下挖C20 的std::atomicstd::shared_ptrT怎么解决并发读写问题std::weak_ptr在对象缓存和观察者模式里的具体实现以及自定义分配器和 shared_ptr 结合时控制块怎么走内存池。这几个方向都是把 shared_ptr 从会用推进到用对的关键台阶。