资讯动态

std::shared_ptr与std::unique_ptr核心区别与性能对比详解

发布时间:2026/10/21 9:42:41 来源:尧图企业网站定制
“std::shared_ptr和std::unique_ptr有什么区别”——这个问题十多年前我在面试现场被问过一次去年我自己面别人时又把它抛了出去。说它简单吧回答成“一个能拷贝一个不能”也就二十秒说它难吧能把“为什么unique_ptr不能拷贝却可以移动”“shared_ptr的引用计数到底存在哪”“weak_ptr绕开循环引用的本质是什么”讲透的候选人我这些年遇到的确实不多。作为一个天天和C代码库打交道的从业者我越来越觉得智能指针不只是“自动释放内存的工具”它本质上是一套你写给下一位维护者的“所有权契约”。选错智能指针代码照样能跑但半年后接手的人会在层层叠叠的拷贝、reset、裸指针混用里耗尽耐心。这篇就把这两种智能指针从原理、性能、坑点到排查思路完整过一遍适合正在学C的初学者也适合想系统梳理智能指针设计思路的开发者。1. 先搞清楚“所有权”这个核心概念1.1 为什么裸指针不够用很早以前我们用裸指针管理堆内存写法无非是new/delete配对。问题在于“配对”这件事完全靠人的纪律而人的纪律在异常路径、提前return、多人协作面前是极其脆弱的。某公司的一个服务端模块曾因为一个异常分支里漏了delete线上内存像漏水的桶两周后才从监控曲线里看出来。裸指针还有一个更隐蔽的问题它不表达意图。函数返回一个指针调用方到底是“借用一下”还是“接管过去”没有约定就只能看注释、猜代码猜错了就是double free或者悬垂指针。智能指针做的事本质上是把“什么时候释放内存”这件事从人手里收走交给确定性的规则要么独占unique要么计数共享shared。这不仅是内存安全的问题更是代码可读性的问题——读者看到类型就知道所有权归属不用再费力去翻调用链。1.2 两种智能指针的一句话总结用一个生活化的类比unique_ptr像一把只有一把钥匙的门门只能由一把钥匙打开钥匙被复制则门作废shared_ptr像一副扑克牌的管理者每个持牌人都有一份“还有多少人在玩”的记录最后一个人离开时把牌收起来。这句总结背后是两个完全不同的设计目标。unique_ptr追求零开销、无歧义的独占所有权shared_ptr追求安全的共享所有权代价是额外的控制块和原子操作。理解这两点后面所有细节都能顺理成章地对应上。2. shared_ptr和unique_ptr的核心差异拆解2.1 拷贝与移动从语法层面就能看出的性格这一条是表面最容易观察到的差异。unique_ptr禁止拷贝只允许移动shared_ptr随时可以拷贝也可以移动。语法背后的原因很直接unique_ptr的独占语义不允许两个指针同时指向同一个对象而拷贝恰恰会产生两个指针指向同一对象的效果所以编译器直接把它禁掉。std::unique_ptrFoo p1 std::make_uniqueFoo(); // std::unique_ptrFoo p2 p1; 编译报错拷贝构造被delete std::unique_ptrFoo p3 std::move(p1); // 正确所有权转移p1变为nullptr std::shared_ptrFoo s1 std::make_sharedFoo(); std::shared_ptrFoo s2 s1; // 合法引用计数变为2 std::shared_ptrFoo s3 std::move(s1); // 合法但注意s1变为nullptr这里特别要提醒shared_ptr的移动并非“零开销”的天然属性。移动构造确实不会增加引用计数省了一次原子加一操作但很多人在实际代码里把拷贝和移动混用导致本可以用移动的地方写成了拷贝白白浪费原子操作的开销。后面性能部分再细说。2.2 所有权语义谁负责销毁对象这是两种智能指针最本质的差异。unique_ptr保证“任何时刻只有一个指针拥有这个对象”当unique_ptr析构时它一定释放对象shared_ptr则维护一个引用计数只有当最后一个持有者析构或reset时对象才被释放。实际编码中这个差异影响深远。一个函数想“借”一个对象看看unique_ptr做不到因为借就意味着同时存在两个指针语义冲突。这时要么传裸指针、要么传引用要么干脆把unique_ptr通过移动交出去再拿回来。而shared_ptr可以随便拷贝给任意函数每个函数都能安全地延长对象的生命周期。我见过一种常见错误把shared_ptr当成“万能指针”到处传连只读函数都收shared_ptr参数。这种写法让生命周期变得模糊——你永远不知道哪个角落还持有一份拷贝对象该释放时不释放不该释放时被意外释放。合理的做法是读操作传const引用需要参与生命周期管理才传shared_ptr。2.3 内存与性能shared_ptr的隐形成本很多人以为“智能指针不就是多了个计数器嘛”但shared_ptr的成本远不止一个整数。第一个成本是控制块。shared_ptr除了持有裸指针外还要分配一个独立的控制块来存引用计数。这意味着shared_ptr的内存占用是“对象内存 控制块内存”两次分配。即使使用make_shared优化也只是把两块内存合并成一次分配控制块本身仍在。第二个成本是原子操作。引用计数的增减是原子操作通常是atomic increment/decrement在多线程环境下这比普通变量的加减贵一个数量级。拷贝一个shared_ptr就要做一次原子加一析构再做一次原子减一如果代码里频繁拷贝shared_ptr性能损耗会非常可观。相比之下unique_ptr是零开销的。它的尺寸等于一个裸指针假设默认删除器无控制块无原子操作拷贝被禁止之后连计数都省了。标准库作者在设计时明确追求“与裸指针相同的时间和空间开销”。2.4 删除器一个常被忽略的差异点删除器是智能指针“如何释放对象”的策略。unique_ptr的删除器是类型的一部分这就带来两个结果第一unique_ptr可以支持零开销的、编译期确定的删除器第二不同类型删除器的unique_ptr互不兼容哪怕指向同一个对象类型。shared_ptr正好相反删除器不参与类型是在构造时存入控制块的。这意味着你可以在运行时为shared_ptr指定不同的删除器不影响类型兼容性。一个常见场景两个模块对同一个资源有不同的释放方式用shared_ptr可以统一类型用unique_ptr只能通过类型抹除比如function包装或自定义基类来处理。auto deleter1 [](Foo* p) { std::cout delete A; delete p; }; auto deleter2 [](Foo* p) { std::cout delete B; delete p; }; std::unique_ptrFoo, decltype(deleter1) up1(new Foo(), deleter1); // std::unique_ptrFoo, decltype(deleter1) up2(new Foo(), deleter2); 报错类型不同 std::shared_ptrFoo sp1(new Foo(), deleter1); std::shared_ptrFoo sp2(new Foo(), deleter2); // 两个都是shared_ptrFoo类型兼容这一点在写泛型代码、做资源封装时非常有用。我遇到过的一个实际案例某个配置文件解析器需要支持“默认删除”和“内存池回收”两种释放策略用shared_ptr统一类型后就避免了模板爆炸。3. 深入shared_ptr的引用计数机制3.1 引用计数到底存在哪这个问题很多人答不上来。shared_ptr内部有两个指针一个指向对象一个指向控制块。控制块里存储引用计数use_count、弱引用计数weak_count、删除器和分配器信息。如果用make_shared创建对象和控制块在同一个内存块里一次分配。如果用new创建再传给shared_ptr则是两次分配。这两种方式的差异不仅体现在速度上还体现在内存布局上make_shared把对象和控制块放一起能改善缓存局部性但代价是一旦weak_ptr存在即使引用计数归零控制块也不能立即释放对象内存要等到所有weak_ptr析构后才释放。这个问题很隐蔽做内存优化时容易踩。3.2 引用计数的增减时机引用计数增加发生在拷贝构造、拷贝赋值、拷贝shared_ptr作为函数参数时。引用计数减少发生在析构、reset、重新赋值时。值得强调的是拷贝赋值。很多人写sp1 sp2时只注意到引用计数增加了没注意到sp1原来所指对象的计数要先减少。赋值操作的顺序是先减旧、再加新如果新旧是同一个对象就要避免重复增减。标准库对这个边界情况处理得很干净但你自己实现类似逻辑时就要特别当心自赋值。另一个容易忽略的细节是“临时对象”。sp std::make_sharedFoo()里临时shared_ptr的引用计数短暂地变成1然后又归零。这段生命周期虽然极端短暂但控制块的创建和析构开销是实打实的。热路径上频繁创建临时shared_ptr会对性能有明显影响。3.3 use_count函数能不用就不用我见过不少代码喜欢用use_count() 1来判断“我是唯一持有者”。这个判断在单线程下没问题在多线程下就是竞态条件——判断和后续操作之间另一个线程可能已经拷贝了这个shared_ptr或者已经析构了最后一个持有者use_count瞬间变化。如果确实需要“唯一性”检查C17提供了std::shared_ptr::unique()但它同样是不可靠的本质上只是use_count() 1的封装。正确的做法是在设计层面避免这种检查要么用unique_ptr保证唯一性要么引入同步机制保护“检查操作”这个复合动作。3.4 循环引用shared_ptr最经典的坑两个shared_ptr互相持有对方就形成了循环引用引用计数永远无法归零内存泄漏。经典场景是双向链表、父子节点互相持有指针、观察者模式。struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; }; auto n1 std::make_sharedNode(); auto n2 std::make_sharedNode(); n1-next n2; n2-prev n1; // 此时两个节点引用计数都为2析构时都无法归零泄漏解决办法是引入weak_ptr一边用shared_ptr持有另一边用weak_ptr破环。weak_ptr不增加引用计数只提供一个“安全查看”的通道需要访问时通过lock()临时提升为shared_ptr。我用weak_ptr的体感是它不仅仅是“破环工具”更是一种重要的设计模式。当你希望对象能感知“我是否还活着”但不希望被持有者反控生命周期时weak_ptr就是正确答案。观察者模式里主题对象持有观察者的weak_ptr观察者销毁后自动失效比裸指针要安全得多。4. 何时该用哪种从实际场景反推选择标准4.1 适合unique_ptr的场景凡是所有权明确、不需要共享、生命周期可以精确控制的地方优先用unique_ptr。典型场景工厂函数返回一个独立对象、容器中独占元素、局部资源的所有者。工厂函数是最典型的例子。一个createWidget()如果返回unique_ptr语义很清晰调用者接管了这个对象的生命周期且只有一个人能接管。C11之后返回unique_ptr非常高效因为返回值优化和移动语义让整个过程没有拷贝发生。容器中的元素也适合unique_ptr。std::vectorstd::unique_ptrFoo比std::vectorFoo更灵活很多情况下可以避免对象移动时的开销而且允许容纳非可拷贝类型。语义上vector拥有每个元素的所有权perfect。4.2 适合shared_ptr的场景只有真正需要“多方共享所有权”时才用shared_ptr。典型场景多个子系统同时使用同一个配置对象、任务队列里的任务快照、缓存系统共享某个计算结果。我见过的反面教材是一个简单的数据处理流水线每个函数都收shared_ptr参数其实只是向下游传递同一个对象没有任何一个函数需要“持有到最后一个人用完”的语义。这种代码把unique_ptr的活硬用shared_ptr干白白承担了原子操作和两次分配的开销。用一个判断标准如果这个对象在所有使用它的函数中只有一个地方负责释放用它就用unique_ptr。只有当“最后谁释放”这个问题在编译期无法回答需要运行时动态决定时才考虑shared_ptr。4.3 weak_ptr的正确打开方式weak_ptr本身不拥有对象它只是shared_ptr的“观测窗口”。在需要打破循环、实现缓存、实现“可能已经消失的依赖项”时weak_ptr是正确答案。缓存系统是weak_ptr的经典应用。你希望某个对象只要还被别人用着就留在缓存里一旦没人用了就被清理。单纯用shared_ptr做缓存会因缓存自身持有一份引用而永久存活用weak_ptr做缓存就能做到“无人使用即失效”。class Cache { std::unordered_mapstd::string, std::weak_ptrExpensiveObj map; public: std::shared_ptrExpensiveObj get(const std::string key) { auto sp map[key].lock(); if (!sp) { sp std::make_sharedExpensiveObj(key); map[key] sp; } return sp; } };这段代码的妙处在于缓存不强制延长对象生命周期但lock()能保证同一时刻只有一份有效对象存在。两个线程同时get同一个key时虽然都可能创建新对象但由于这里只展示了单线程逻辑多线程版本还需要加锁但核心思路是通用的。4.4 两个容易混淆的边界情况第一unique_ptr能否转成shared_ptr可以而且是免费的。因为从独占到共享的所有权转义是安全的移动一个unique_ptr给shared_ptr控制块从这个时刻开始创建。但反过来不行——从shared_ptr无法拿到unique_ptr因为共享的语义无法变回独占。第二裸指针能否放进shared_ptr能但是要直接构造不要先new再赋值。直接构造时控制块从空开始计数为1先new再赋值如果中间有异常路径new出来的裸指针就成了孤儿。这是C里典型的资源泄漏隐患。// 反例 Foo* raw new Foo(); std::shared_ptrFoo sp; // ... 中间可能有异常或提前returnraw就泄漏了 sp.reset(raw); // 正例 std::shared_ptrFoo sp(new Foo());其实最稳妥的方案永远是std::make_shared它把new和shared_ptr构造打包在一起从源头杜绝这个问题。5. 从性能角度对比什么时候该担心开销5.1 一次拷贝的成本模型讲性能不能只看O(1)要看常数。shared_ptr一次拷贝的成本大约是一次原子加一操作可能伴随缓存行竞争多个线程操作同一个控制块时。在毫无竞争的情况下这个操作很快但一旦多个线程高频拷贝同一个shared_ptr控制块所在缓存行会成为事实上的锁性能雪崩是可能的。unique_ptr的移动是纯指针转移不涉及原子操作编译器甚至可以把它优化成一个普通的寄存器搬运。几十纳秒的差异单次看不出来但放到每秒几百万次的调用路径上就很明显了。我曾经在一个高吞吐的消息分发系统里把参数从shared_ptr拷流传参改为const引用吞吐量直接提升了约15%而逻辑完全没变。5.2 make_shared与直接new的取舍make_shared的优点一次内存分配缓存友好异常安全。缺点控制块和对象在同块内存只要存在任意weak_ptr控制块就不能释放连带对象内存也无法立即释放。如果你在极低内存环境里用shared_ptr配合大量weak_ptr做缓存这个缺点会被放大。直接new再交给shared_ptr的优点控制块和对象分离对象可以在引用计数归零时立即释放即使weak_ptr还活着。缺点两次分配异常风险需要额外小心。总结起来一个原则默认用make_shared如果确实关注“对象尽早释放”且weak_ptr生命周期明显长于对象再考虑分离分配。5.3 shared_ptr的线程安全要讲清楚这一点网上说法很多也很容易误导。标准答案是shared_ptr的控制块本身是线程安全的即多个线程同时拷贝、析构同一个shared_ptr不会数据竞争引用计数的原子操作保证了数量正确但被指向的对象本身不是线程安全的多个线程同时修改同一个对象仍然是数据竞争。常见的误用是“我用shared_ptr就是为了线程安全”。一个对象被两个线程同时写你给它包一层shared_ptr并不会解决并发写的问题该加锁还是加锁该用atomic还是用atomic。shared_ptr只解决“对象不会在使用的过程中被提前释放”的问题。5.4 一个实际的性能测试参考思路如果你想自己测一测两者差距我建议做一个简单的实验定义一个轻量struct分别用shared_ptr和unique_ptr在容器里push_back一万次然后再全部清空用chrono计时。由于unique_ptr没有原子操作、没有控制块分配它的总时间通常只有shared_ptr的一半甚至更少。这个结果的意义不在于“shared_ptr很慢所以别用”而在于帮你建立成本直觉shared_ptr不是免费的。当你写一个被百万次调用的函数时shared_ptr的每次拷贝都意味着原子加一这个成本会实打实地体现在profile里。6. 常见问题与排查技巧实录6.1 智能指针与裸指针混用的隐患最常见的一个场景函数接收裸指针内部却把它存进了shared_ptr。如果调用方不知道你的函数“接管”了所有权调用方还在用裸指针操作对象就会产生双重释放或悬垂。解决思路接口层就明确所有权语义。接收并接管所有权用unique_ptr做参数或返回值仅借用不接管用引用或裸指针。两者混用时要非常小心明确注释、封装修饰都是手段。6.2 智能指针与vector一起用时的性能问题std::vectorstd::shared_ptrFoo这种组合在增删时会产生频繁的引用计数增减尤其是做排序、swap这类操作时。每次元素交换都是一次shared_ptr拷贝一次原子加一和一次析构减一。如果用std::vectorstd::unique_ptrFoo排序时的swap是移动操作zero overhead。如果确实只能用shared_ptr又想减少计数操作可以用std::vectorstd::shared_ptrFoo::swap来交换元素吗不行swap也是拷贝语义。更彻底的办法是改用指针索引排序或者把shared_ptr容器换成自定义的move-only包装类型。实际开发中绝大多数容器场景用unique_ptr更合适。6.3 循环引用的排查思路如果怀疑内存泄漏是循环引用导致的一个比较原始的排查办法给类加一个析构函数在析构里打日志。程序运行结束后如果析构日志没出现说明对象没有被释放。再用内存分析工具比如valgrind或其替代品看是否有不可达的内存在持续增长反向查找谁持有谁。另一种思路是代码审查阶段看成员变量类型。凡是出现两个类互相包含对方的shared_ptr就要警惕循环。标准做法是所有权关系设计成单向的反向引用一律用weak_ptr。6.4 智能指针和异常安全这一点平时不显眼但在构造函数里new了两个资源时就会暴露问题。比如struct A { Foo* foo new Foo(); Bar* bar new Bar(); // 如果bar分配失败抛异常foo就会泄漏虽然这只是个例子实际要写裸指针管理如果改成智能指针就完全不会出现这个问题 };用智能指针成员能保证构造失败时已构造的成员正常析构不需要自己写catch和清理代码。这是智能指针在异常安全上最实际的优势。我建议所有拥有动态资源的类优先用智能指针做成员。6.5 enable_shared_from_this的正确使用回调场景里类需要把自身的shared_ptr传递给异步函数直接shared_from_this()要求对象必须已经由shared_ptr管理。如果对象在栈上或由unique_ptr管理调用时会抛出bad_weak_ptr异常。继承std::enable_shared_from_thisT后类内部可以通过shared_from_this()安全获取自己的shared_ptr身份。这里有个坑如果对象是用std::shared_ptrFoo(new Foo())创建的虽然foo最终由shared_ptr管理但在构造阶段控制块尚未建立在构造函数里调用shared_from_this()同样会抛异常。最佳实践是构造时用make_shared并且不要在构造函数内部尝试获取shared_from_this().7. 从C11到C23智能指针家族的使用演变7.1 早期版本到现代C的路径C11第一次把unique_ptr和shared_ptr带进标准库时很多项目还习惯性地用auto_ptr。auto_ptr的拷贝语义是“转移所有权”但它偷偷摸摸地拷贝、在容器里插入后会静默改变源对象的值坑了一批人。C11之后正确的路径是凡是曾经用auto_ptr的地方一律换成unique_ptr。unique_ptr的移动语义在语法层面杜绝了“静默偷取”编译器会在误用时直接报错。这是C从“靠纪律”到“靠类型系统约束”演进的典型缩影。7.2 C14和C17的补充C14给make_unique补上了之前make_unique只能自己手写或使用第三方实现。C17则加入了shared_ptr对数组的支持std::shared_ptrFoo[]可以被正确删除不再需要自定义删除器来处理数组元素。这在处理动态数组时省了不少事。C17还有一个细节std::weak_ptr的模板参数可以和shared_ptr不完全相同这给类型转换带来了一些便利但实际业务代码中用得不多。7.3 C20/23之后的新语法影响C20的std::atomicstd::shared_ptrT是值得注意的。它提供了对shared_ptr本身的原子操作可以直接在多线程环境下安全地读取和写入shared_ptr而不用手动加锁。不过它对性能的影响要重点考虑——它内部可能需要锁机制会进一步放大shared_ptr的操作成本所以不是“全能替代锁”的银弹。C23引入了std::move_only_function它和unique_ptr搭配非常自然。如果你在回调里捕获一个unique_ptr移动语义能让这个捕获高效且明确。时代变了智能指针的用法也在变。但最核心的决策框架——“独占还是共享”——自始至终没有变过。8. 我的一些实践心得8.1 代码规范的优先级高于个人偏好智能指针本身没有绝对的对错但一个团队不统一用法就一定有错。如果团队里有人习惯用unique_ptr传参有人习惯shared_ptr传参代码库会很快变得混乱接手的人需要不断猜测“这里用shared_ptr是不是因为后面要共享还是只是个人习惯”。我建议团队在代码规范里明确几条返回值里独占资源用unique_ptr共享资源用shared_ptr函数参数只要不是需要“持有”一律用const引用或裸指针成员变量中拥有关系用unique_ptr非拥有关系用weak_ptr或裸指针。这几条就能避免80%的智能指针乱象。8.2 写代码之前先问自己“谁释放”我每次写一个涉及动态内存的类都会先画出这个对象的所有权和引用关系。释放路径清不清晰比接口好不好看重要得多。所有权清晰的代码内存管理通常不会出问题所有权模糊的代码再好的测试也补不齐运行时的坑。这种“先画出关系再写代码”的做法表面上是慢了几分钟实际上省掉了后续若干小时的调试时间。尤其当项目膨胀、多个模块互相引用时所有权图的约束力远比注释强大。8.3 最后分享一个小习惯在前几天排查一个内存问题的时候我注意到团队里的排查方法还停留在“肉眼找new和delete配对”。其实这种情况完全可以在编译阶段就拦住大部分开启编译器的静态分析开启sanitizer在测试环境跑起来时多关注ASAN的告警。另外一个很实际的好习惯每次写完一个类在析构函数里打一条日志跑完测试看日志数量是否与构造数量一致。土办法但坚持下来真的能发现不少循环引用和所有权混乱的问题。智能指针不是银弹它不会把烂设计变成好设计但它能让你在设计混乱时仍然保持内存不出大问题。把unique_ptr当作默认选择把shared_ptr当作“确实需要共享所有权”的特例把weak_ptr当作“需要观察但不占有”的工具这样用下去代码的安全性和可读性都会有一个明显的提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑