“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_ptr<Foo> p1 = std::make_unique<Foo>(); // std::unique_ptr<Foo> p2 = p1; 编译报错,拷贝构造被delete std::unique_ptr<Foo> p3 = std::move(p1); // 正确,所有权转移,p1变为nullptr std::shared_ptr<Foo> s1 = std::make_shared<Foo>(); std::shared_ptr<Foo> s2 = s1; // 合法,引用计数变为2 std::shared_ptr<Foo> 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_ptr<Foo, decltype(deleter1)> up1(new Foo(), deleter1); // std::unique_ptr<Foo, decltype(deleter1)> up2(new Foo(), deleter2); 报错,类型不同 std::shared_ptr<Foo> sp1(new Foo(), deleter1); std::shared_ptr<Foo> sp2(new Foo(), deleter2); // 两个都是shared_ptr<Foo>,类型兼容这一点在写泛型代码、做资源封装时非常有用。我遇到过的一个实际案例:某个配置文件解析器需要支持“默认删除”和“内存池回收”两种释放策略,用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_shared<Foo>()里,临时shared_ptr的引用计数短暂地变成1,然后又归零。这段生命周期虽然极端短暂,但控制块的创建和析构开销是实打实的。热路径上频繁创建临时shared_ptr,会对性能有明显影响。
3.3 use_count函数:能不用就不用
我见过不少代码喜欢用use_count() == 1来判断“我是唯一持有者”。这个判断在单线程下没问题,在多线程下就是竞态条件——判断和后续操作之间,另一个线程可能已经拷贝了这个shared_ptr,或者已经析构了最后一个持有者,use_count瞬间变化。
如果确实需要“唯一性”检查,C++17提供了std::shared_ptr::unique(),但它同样是不可靠的,本质上只是use_count() == 1的封装。正确的做法是在设计层面避免这种检查:要么用unique_ptr保证唯一性,要么引入同步机制保护“检查+操作”这个复合动作。
3.4 循环引用:shared_ptr最经典的坑
两个shared_ptr互相持有对方,就形成了循环引用,引用计数永远无法归零,内存泄漏。经典场景是双向链表、父子节点互相持有指针、观察者模式。
struct Node { std::shared_ptr<Node> next; std::shared_ptr<Node> prev; }; auto n1 = std::make_shared<Node>(); auto n2 = std::make_shared<Node>(); 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,语义很清晰:调用者接管了这个对象的生命周期,且只有一个人能接管。C++11之后,返回unique_ptr非常高效,因为返回值优化和移动语义让整个过程没有拷贝发生。
容器中的元素也适合unique_ptr。std::vector<std::unique_ptr<Foo>>比std::vector<Foo>更灵活,很多情况下可以避免对象移动时的开销,而且允许容纳非可拷贝类型。语义上,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_map<std::string, std::weak_ptr<ExpensiveObj>> map; public: std::shared_ptr<ExpensiveObj> get(const std::string& key) { auto sp = map[key].lock(); if (!sp) { sp = std::make_shared<ExpensiveObj>(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_ptr<Foo> sp; // ... 中间可能有异常或提前return,raw就泄漏了 sp.reset(raw); // 正例 std::shared_ptr<Foo> 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::vector<std::shared_ptr<Foo>>这种组合在增删时会产生频繁的引用计数增减,尤其是做排序、swap这类操作时。每次元素交换都是一次shared_ptr拷贝,一次原子加一和一次析构减一。
如果用std::vector<std::unique_ptr<Foo>>,排序时的swap是移动操作,zero overhead。
如果确实只能用shared_ptr,又想减少计数操作,可以用std::vector<std::shared_ptr<Foo>>::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_this<T>后,类内部可以通过shared_from_this()安全获取自己的shared_ptr身份。
这里有个坑:如果对象是用std::shared_ptr<Foo>(new Foo())创建的,虽然foo最终由shared_ptr管理,但在构造阶段,控制块尚未建立,在构造函数里调用shared_from_this()同样会抛异常。最佳实践是构造时用make_shared,并且不要在构造函数内部尝试获取shared_from_this().
7. 从C++11到C++23:智能指针家族的使用演变
7.1 早期版本到现代C++的路径
C++11第一次把unique_ptr和shared_ptr带进标准库时,很多项目还习惯性地用auto_ptr。auto_ptr的拷贝语义是“转移所有权”,但它偷偷摸摸地拷贝、在容器里插入后会静默改变源对象的值,坑了一批人。
C++11之后正确的路径是:凡是曾经用auto_ptr的地方,一律换成unique_ptr。unique_ptr的移动语义在语法层面杜绝了“静默偷取”,编译器会在误用时直接报错。这是C++从“靠纪律”到“靠类型系统约束”演进的典型缩影。
7.2 C++14和C++17的补充
C++14给make_unique补上了,之前make_unique只能自己手写或使用第三方实现。C++17则加入了shared_ptr对数组的支持,std::shared_ptr<Foo[]>可以被正确删除,不再需要自定义删除器来处理数组元素。这在处理动态数组时省了不少事。
C++17还有一个细节:std::weak_ptr的模板参数可以和shared_ptr不完全相同,这给类型转换带来了一些便利,但实际业务代码中用得不多。
7.3 C++20/23之后的新语法影响
C++20的std::atomic<std::shared_ptr<T>>是值得注意的。它提供了对shared_ptr本身的原子操作,可以直接在多线程环境下安全地读取和写入shared_ptr,而不用手动加锁。不过它对性能的影响要重点考虑——它内部可能需要锁机制,会进一步放大shared_ptr的操作成本,所以不是“全能替代锁”的银弹。
C++23引入了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当作“需要观察但不占有”的工具,这样用下去,代码的安全性和可读性都会有一个明显的提升。