
内存泄漏这东西,平时不疼不痒,一到线上就咬人。我印象最深的一次是排查一个跑了三天才崩的服务进程,RSS 曲线像台阶一样一级一级往上爬,valgrind跑起来又慢得让人绝望,最后定位到的是一个catch分支里漏掉的delete。那之后我把项目里所有裸指针的new/delete挨个过了一遍,能换成C 智能指针的全部换掉,维护成本直接降了一个量级。智能指针不是什么新东西,C11 就进了标准库,但直到今天我在代码评审里还是经常看到两种极端:一种是完全不用,满屏new配delete;另一种是无脑用shared_ptr,把一个本来生命周期很清晰的局部对象硬生生加了一层原子引用计数。这篇就按我自己的实际使用经验,把unique_ptr、shared_ptr、weak_ptr三件套讲透,包括它们的底层实现逻辑、性能账怎么算、接口签名怎么写、以及那些只有踩过才知道的坑。不管你是刚学 C 基础入门,还是准备面试八股,这些内容都用得上。1. 裸指针的问题从来不是忘记 delete这么简单很多人对内存泄漏的理解停留在申请了没释放,这其实是最容易发现、也最容易修的一类。真正麻烦的是另外几种情况,它们才是智能指针存在的根本理由。1.1 异常路径与提前 return:裸指针最脆弱的两个时刻看一段很典型的代码,假设我们在做资源管理,先创建对象 A,再做一件可能抛异常的事情:void process() { Widget* w new Widget(); doSomethingRisky(); // 可能抛异常 delete w; }只要doSomethingRisky()抛了异常,delete w就永远不会执行。同样的问题也出现在提前return上——函数中间加了一个判断分支直接返回,栈上的指针变量没了,堆上的对象还在。这类 bug 在代码评审里极难被发现,因为它依赖运行时的控制流。用unique_ptr改写之后,这个问题的性质就变了:void process() { auto w std::make_uniqueWidget(); doSomethingRisky(); // 抛异常也安全 }unique_ptr是一个栈对象,它的析构函数在栈展开(stack unwinding)过程中一定会被调用,这是语言层面的保证,不依赖你的记性。这就是所谓 RAII(资源获取即初始化)的核心价值:把资源的释放绑定到对象的生命周期上,而不是绑定到某一行代码是否被执行上。1.2 所有权不明确带来的重复释放与悬垂指针比泄漏更危险的是重复释放。当两个模块都持有同一个裸指针,谁也说不清谁该负责释放时,要么都释放导致 double free,要么都不释放导致泄漏。这种问题通常不会在单元测试里暴露,而是在某个边界条件下突然崩溃。智能指针解决的其实是所有权语义的表达问题。unique_ptr明确表达只有一个所有者,编译期就断绝了拷贝的可能;shared_ptr表达多个持有者共同拥有,最后一个走的关灯。一旦你把接口签名写清楚,调用方自己就知道该不该接手这个对象的生命周期。1.3 一个容易被忽略的成本:代码评审的心智负担我做过统计,在一个中等规模的 C 项目里,涉及手动内存管理的函数,评审时间平均是其他函数的 1.5 到 2 倍。原因很简单,评审人必须沿着所有分支走一遍,确认每条路径上都释放了。而智能指针把这件事从需要验证变成了结构上不可能出错。不过这里要提醒一句:智能指针不是银弹,它把正确性从运行时推到了设计和编译期,代价是你必须真的想清楚所有权。用错了shared_ptr,你会得到一堆互相引用、永远不释放的对象,比泄漏更难查。2. unique_ptr:零运行时开销,但灵活性被严重低估unique_ptr是我在项目里用得最多的智能指针,大概占到八成以上。它的设计非常克制——默认情况下它就是一个裸指针的大小,没有任何额外开销。2.1 为什么它的大小等于一个裸指针unique_ptr的结构大致是这样的:一个指针成员,加一个删除器成员。默认删除器std::default_deleteT是一个空的类,没有任何数据成员。C 有一个叫空基类优化(EBCO, Empty Base Class Optimization)的机制,标准库实现通常把删除器作为基类,于是空基类不占空间,整个unique_ptr就只剩下那个指针成员。实测一下:#include memory #include cstdio struct Widget { int a, b, c; }; int main() { printf(raw : %zu\n, sizeof(Widget*)); // 8 printf(unique: %zu\n, sizeof(std::unique_ptrWidget)); // 8 printf(shared: %zu\n, sizeof(std::shared_ptrWidget)); // 16 }在 64 位平台上,unique_ptr是 8 字节,shared_ptr是 16 字节。这个差异在单个对象上无所谓,但如果你有一个std::vectorstd::shared_ptrNode存了几百万个节点,内存占用就是实打实的两倍差距,而且每次拷贝还要动原子计数器。2.2 移动语义让它能放进容器unique_ptr不能拷贝,但可以移动。这意味着它可以放进std::vector,只是要注意容器操作时的语义:std::vectorstd::unique_ptrWidget vec; vec.push_back(std::make_uniqueWidget()); // 右值,移动进去 auto w std::make_uniqueWidget(); // vec.push_back(w); // 编译错误,不能拷贝 vec.push_back(std::move(w)); // 转移所有权 // 此后 w nullptr这里有个实战经验:在 C14 之后,容器里放unique_ptr已经完全可行了,早期 C11 因为缺少某些重载会有点别扭,但基本也能用。真正需要shared_ptr的场景,是当你不确定什么时候释放、或者有多个模块需要共享时。2.3 转移所有权的接口应该怎么写如果你的函数需要接管一个对象,签名应该直接接受unique_ptr值:class Session { public: explicit Session(std::unique_ptrSocket sock) : sock_(std::move(sock)) {} private: std::unique_ptrSocket sock_; };调用方必须显式std::move,这个动作本身就是一次自我说明:我不再拥有这个对象了。如果只是使用而不接管,传引用或裸指针即可,不要传const unique_ptrT,那会让调用方困惑,也限制了参数只能是智能指针:void use(const Widget w); // 推荐 void use(const Widget* w); // 可接受,需判空 void use(const std::unique_ptrWidget w); // 不建议,除非要传 nullptr 语义提示:如果函数需要表达这个参数可以为空的语义,用裸指针T*加注释说明不持有所有权是完全可以的,Google 的 C 规范也是这么建议的。智能指针的存在不是要把裸指针彻底消灭,而是让拥有和不拥有在代码里一眼可辨。2.4 数组与 make_unique 的版本差异std::make_unique是 C14 才加入的。如果你还在 C11 环境里(比如某些嵌入式工具链或者老编译器),需要自己写一个:template typename T, typename... Args std::unique_ptrT make_unique_helper(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }另外unique_ptr支持数组特化std::unique_ptrT[],析构时会调用delete[]而不是delete:auto buf std::make_uniquechar[](1024); buf[0] x;这个特化不支持operator*和operator-,只支持operator[],用起来还算顺手,但对于动态大小的缓冲区,我个人更倾向直接用std::vectorchar,除非是交给 C 接口用的场景。3. shared_ptr 的引用计数不是免费的:控制块、原子操作与内存账shared_ptr是我见过被误用最多的一类,很多人的心理是不确定就上 shared_ptr,反正出事概率低。这个想法很危险,因为它的代价不只是一点点内存。3.1 控制块到底是什么,什么时候被创建一个shared_ptr实例有两个指针:一个指向被管理的对象,一个指向控制块。控制块里通常包含强引用计数、弱引用计数、删除器、分配器(如果有的话)。关键点在于:控制块是在第一个shared_ptr构造时创建的,而且只创建一次。这就解释了一个经典的坑——用同一个裸指针构造两个shared_ptr:Widget* raw new Widget(); std::shared_ptrWidget a(raw); std::shared_ptrWidget b(raw); // 灾难:两个独立控制块 // a 和 b 各自析构时都会 delete raw,双重释放这段代码能编译,运行时也不一定立刻崩,但它是明确的内存错误。正确做法是用make_shared或者从另一个shared_ptr拷贝构造:auto a std::make_sharedWidget(); auto b a; // 共享同一个控制块,引用计数变成 2最隐蔽的变体是通过this构造。如果你在一个类内部想拿到自己的shared_ptr,必须继承std::enable_shared_from_this:class Node : public std::enable_shared_from_thisNode { public: std::shared_ptrNode getSelf() { return shared_from_this(); // 正确 // return std::shared_ptrNode(this); // 错误,会产生第二个控制块 } };注意:shared_from_this()只有在对象已经被shared_ptr管理之后才合法。在构造函数里调用它会抛std::bad_weak_ptr,因为那时weak_ptr成员还没被初始化。这是个非常常见的翻车点,尤其在做异步回调注册的时候。3.2 make_shared 省了一次分配,但也埋了一个隐藏成本std::make_sharedT(args...)会在一次内存分配里同时放下对象和控制块,而std::shared_ptrT(new T(...))需要两次分配。从性能和缓存局部性来看,make_shared明显更优。但它有一个副作用容易被人忽略:因为对象和控制块在同一块内存上,只要还有weak_ptr引用着这个控制块,整块内存就不能被归还给分配器。也就是说,如果对象很大,而你有一批长期存活的weak_ptr,被占住的内存量会明显高于两次分配的方案。我遇到过一次真实案例:一个缓存模块用make_shared创建大对象,同时用一个weak_ptr列表做失效检查。对象逻辑上早就释放了,但内存不降,最后改成shared_ptr(new T)的两段式分配才解决。所以:场景推荐写法原因对象较小,无长期 weak_ptrmake_shared一次分配,更快对象很大,或有长期 weak_ptrshared_ptrT(new T)内存可及时归还需要自定义删除器shared_ptrT(ptr, deleter)make_shared不支持自定义删除器3.3 引用计数的原子操作成本强引用计数的增减是原子操作,这是刻意的设计,因为shared_ptr的拷贝可以在多线程里安全进行。但原子操作有代价——在一些平台上,无竞争的原子递增大约比普通递增慢几倍,有竞争时更慢,因为要处理缓存行同步。实际影响有多大?我做过一个简单的压测,一个循环里对同一个shared_ptr反复拷贝和释放一亿次,shared_ptr版本比unique_ptr::move版本慢了一个数量级。当然这个测试本身很极端,但它说明一件事:在热路径上频繁拷贝shared_ptr是要付出代价的。工程上的应对方式很简单:函数参数用const shared_ptrT或裸指针/引用,避免拷贝需要延长生命周期时才拷贝一份能用unique_ptr的地方不要用shared_ptr还有一点必须说清楚:shared_ptr只保证引用计数本身是线程安全的,不保证指向对象的内容是线程安全的。这个区别在面试里被问烂了,但在实际写代码时还是会有人搞混。4. weak_ptr 与循环引用:析构顺序里藏着的门道weak_ptr是三个里面最少被主动使用的,但它是解决循环引用的唯一标准手段。4.1 循环引用是怎么形成的考虑一个父子结构:struct Node { std::shared_ptrNode parent; std::shared_ptrNode child; }; auto p std::make_sharedNode(); auto c std::make_sharedNode(); p-child c; // c 引用计数 2 c-parent p; // p 引用计数 2 // 离开作用域,p 和 c 各自减到 1,永远不归零,内存泄漏这两个对象互相持有强引用,谁都不会先放手。解决办法是把其中一条边改成weak_ptr,通常是子指向父或观察者指向被观察者的那条:struct Node { std::shared_ptrNode parent; // 或者这里用 weak_ptr std::weak_ptrNode child; // 取决于谁的生命周期更长 };选择哪条边改成弱引用的判断标准很直接:问自己谁能独立于对方存在。父节点删掉了,子节点应该跟着走,那子指向父的引用就该是弱的;反过来如果子节点是主体,父只是索引,那反过来。4.2 weak_ptr 的正确用法是 lock,不是 expired 解引用这是我在代码评审里纠正最多的一个点。先看不推荐的写法:if (!wp.expired()) { wp.lock()-doSomething(); // 中间可能被其他线程释放 }expired()返回 false 和解引用之间存在时间窗口,多线程下这个窗口足够另一个线程把最后一个shared_ptr释放掉,于是你操作了一个已经被销毁的对象。正确写法是lock()——它要么返回一个有效的shared_ptr(同时把引用计数加一,保证使用期间对象存活),要么返回空:if (auto sp wp.lock()) { sp-doSomething(); // sp 是强引用,使用期间对象一定活着 }这本质上是一个检查并使用的原子化操作,一行代码就消除了竞态。4.3 观察者模式里的标准搭配weak_ptr最典型的应用是观察者列表。被观察者如果持有强引用,观察者就永远无法析构,这是设计上的死锁:class Subject { public: void subscribe(const std::shared_ptrObserver ob) { observers_.push_back(ob); } void notify() { for (auto it observers_.begin(); it ! observers_.end(); ) { if (auto ob it-lock()) { ob-onEvent(); it; } else { it observers_.erase(it); // 顺手清理已销毁的观察者 } } } private: std::vectorstd::weak_ptrObserver observers_; };这段代码里的erase是一个很实用的顺带效果——用弱引用存观察者,列表会自己变干净。如果用强引用,观察者对象必须显式退订,而任何一次忘记退订都会导致泄漏。4.4 弱引用计数与过期后的控制块控制块里有两个计数器:强计数和弱计数。强计数归零时对象被析构,但只要弱计数不为零,控制块本身还活着——这是lock()能安全判断是否已过期的前提。这个细节解释了前面make_shared的内存问题,也解释了为什么用完weak_ptr不用管是不对的:大量长期存活的weak_ptr会让控制块堆积,虽然不是对象本身的内存,但量大了也是负担。定期清理观察者列表这类操作,不只是逻辑需要,也是内存需要。5. 自定义删除器:让智能指针接管非 new 出来的资源智能指针的通用性比很多人想的强,它管理的不一定是new出来的内存,只要是需要一个释放动作的资源都可以。5.1 unique_ptr 的删除器会影响类型unique_ptr的第二个模板参数是删除器类型,这直接影响对象的静态类型:struct FileCloser { void operator()(FILE* f) const { if (f) std::fclose(f); } }; using FilePtr std::unique_ptrFILE, FileCloser; FilePtr fp(std::fopen(data.bin, rb));注意这里的类型是std::unique_ptrFILE, FileCloser而不是std::unique_ptrFILE,两者不能互相赋值。如果删除器是无捕获的 lambda,它有一个隐式转换到函数指针的能力,类型会变得很长,通常建议直接用decltype:auto deleter [](FILE* f) { if (f) std::fclose(f); }; std::unique_ptrFILE, decltype(deleter) fp(std::fopen(a.txt, r), deleter);提示:带捕获的 lambda 作为删除器时,会实实在在占用unique_ptr的空间(捕获了几个变量就多大),所以那种场景下unique_ptr就不再是零开销了。如果对大小敏感,用无捕获 lambda 或者独立的函数对象结构体。5.2 shared_ptr 的删除器不影响类型shared_ptr的删除器是运行时存进控制块的,所以模板参数不变,类型始终是std::shared_ptrT:std::shared_ptrFILE fp(std::fopen(a.txt, r), [](FILE* f) { if (f) std::fclose(f); });这一点让shared_ptr在处理异构资源时更灵活,代价是每个实例多了一份删除器的存储和控制块的间接跳转。我在项目里用它管过 socket 句柄、C 库句柄、以及一些第三方库返回的裸指针。判断标准是:只要这个资源有明确的一次性释放动作,并且释放时机应该由所有权决定,就可以交给智能指针。5.3 别名构造函数:一个少有人知道的特性shared_ptr有一个别名构造函数,它允许你共享某个对象的所有权,但让get()返回另一个指针。典型场景是共享一个结构体,同时希望shared_ptr指向其中的某个成员:struct Frame { Header header; uint8_t data[4096]; }; auto frame std::make_sharedFrame(); std::shared_ptrHeader hdr(frame, frame-header); // hdr 引用计数与 frame 共享,但 hdr.get() 返回 frame-header这个特性在写零拷贝的协议解析代码时非常好用,可以避免额外的拷贝和生命周期管理。它的坑在于:别名指针指向的对象必须是被管理对象的一部分,否则你就在管理一块不存在或者会被提前释放的内存。5.4 从原始 C 接口接管时的注意事项从 C 接口返回裸指针时,有两个必查项:这个指针是不是可能为nullptr?unique_ptr可以安全持有空指针,析构时删除器会被调用,所以你的删除器必须能处理空指针。释放函数是不是配套的?比如某些库提供xxx_create和xxx_destroy两个函数,必须成对使用,不能直接用delete。表里对比一下常见资源的接管方式:资源类型释放方式推荐智能指针new出来的对象deleteunique_ptr/make_sharednew[]数组delete[]unique_ptrT[]C 库句柄库提供的close/destroyunique_ptrT, Deleter需要共享的缓冲区引用计数归零后释放shared_ptr 自定义删除器观察者/缓存引用不释放weak_ptr6. 接口设计:智能指针出现在函数签名里该怎么写这部分是最容易产生争议、也最能体现一个 C 使用者成熟度的地方。我总结了一套自己在项目里坚持的规则,可以在团队里直接复用。6.1 参数位置的四条规则函数参数涉及所有权时,按下面的优先级选择:只读使用,不涉及所有权→const T。这是默认选择,最快也最清晰。需要表达可以为空→T*,并在注释里说明不持有所有权。函数内部可能持有引用或延长其生命周期→const std::shared_ptrT,如果内部要保存,再拷贝一份。函数要接管所有权→std::unique_ptrT按值传递,调用方显式std::move。第 3 条要展开说一下。如果内部只是临时使用,传const shared_ptrT是合适的,因为拷贝会改变引用计数。但如果内部要保存,就必须拷贝,否则析构后你的引用就悬空了。这时候更清晰的做法是按值传递,让调用方在调用点就std::move进去,减少一次拷贝:class Worker { public: void setTask(std::shared_ptrTask task) { // 按值,调用方决定拷贝还是移动 task_ std::move(task); } private: std::shared_ptrTask task_; };6.2 返回值应该直接给 unique_ptr工厂函数返回unique_ptr是最佳实践,C17 的保证拷贝省略(guaranteed copy elision)让这个过程零拷贝:std::unique_ptrShape createShape(ShapeType t) { switch (t) { case ShapeType::Circle: return std::make_uniqueCircle(); case ShapeType::Square: return std::make_uniqueSquare(); default: return nullptr; } }调用方拿到的是一个独占指针,如果确实需要共享,调用方自己转成shared_ptr就行:std::shared_ptrShape shared createShape(ShapeType::Circle);unique_ptr到shared_ptr的隐式转换是标准允许的,而且这个转换只会创建一次控制块,不会重复分配。注意:反过来不行,shared_ptr不能转成unique_ptr,因为所有权已经分散了。所以工厂返回unique_ptr是更灵活的选择,它给了调用方决定权。6.3 类成员的所有权表达成员变量的选择同样有规律可循:成员语义推荐类型独占拥有,定义类时就知道类型直接放值T member_;独占拥有,需要多态或者延迟创建std::unique_ptrT独占拥有,可能为空std::unique_ptrT,判空使用共享拥有std::shared_ptrT引用外部对象,不负责生命周期T*或T观察外部对象,需要检测失效std::weak_ptrT这里第一条值得强调:如果对象是独占的、类型又是确定的,直接放值比放unique_ptr更好。放值意味着一块内存、无间接跳转、缓存友好,而且不需要担心空指针。很多从 Java 或 C# 转过来的开发者习惯性地把所有对象都放指针,这在 C 里是白白付出性能代价。6.4 多态场景下析构函数必须是虚函数这是一个老生常谈但依然高频的错误。用unique_ptrBase持有Derived对象时,如果Base的析构函数不是虚的,派生部分的析构不会被调用:struct Base { virtual ~Base() default; // 必须 virtual void run() 0; };shared_ptr的情况略有不同:它在构造时会捕获删除器的类型信息,所以即使基类析构不是虚的,make_sharedDerived转成shared_ptrBase之后依然能正确析构。但这不该成为你不写虚析构的理由,因为unique_ptr不支持这个特性,而且依赖这个行为会让代码的可读性变差。7. 几个我真实踩过的坑与排查思路7.1 构造函数里抛异常导致的内存泄漏这个坑在引入智能指针之前很常见:构造函数里已经 new 了一部分成员,后面某个成员初始化时抛异常,已分配的内存不会被释放。标准库对make_shared和make_unique做了处理,但如果你的类里同时管理着多个裸资源,还是要小心:class Holder { public: Holder() : a_(std::make_uniqueA()), b_(std::make_uniqueB()) {} // B 抛异常时,a_ 会被正确析构 private: std::unique_ptrA a_; std::unique_ptrB b_; };成员按声明顺序初始化,任意一个抛异常,已经构造完成的成员都会按逆序析构。所以只要所有资源都交给智能指针,构造函数抛异常就是安全的。这也是我坚持成员资源必须用智能指针的原因之一。顺便说一个初始化顺序的坑:成员初始化列表的顺序不影响实际初始化顺序,实际顺序永远是声明顺序。如果b_的初始化用到了a_,而a_声明在后面,就会出问题。有些编译器会给出-Wreorder警告,建议打开。7.2 weak_ptr 在单例和缓存中的过期判断我在做一个对象池时遇到过一次对象明明已销毁,但weak_ptr判断还有效的诡异现象。排查后发现是对象池内部的强引用列表没清干净,对象一直没真正销毁。这个问题的排查思路值得记录:先在lock()成功的分支打日志,确认引用计数是否真的没归零用shared_ptr::use_count()在关键节点打印计数,观察变化曲线检查是否所有持有者都正确用了弱引用,尤其是容器和回调use_count()主要用于调试,不要拿它作为业务逻辑的判断依据,因为多线程下这个值在你读到的瞬间就可能已经变了。但在单线程调试阶段它非常好用。7.3 析构顺序导致的隐式依赖多线程程序里有个隐蔽的问题:全局shared_ptr的析构时机不确定。如果某个工作线程还在运行,而它使用的全局资源已经析构,就会崩溃。这类问题的表现是程序退出时偶尔崩溃,很难复现。我的处理办法是:不要在全局使用需要复杂析构的资源,如果必须用,就用一个显式的生命周期管理类,在main退出前手动清理:int main() { { App app; // 所有资源在这个作用域内 app.run(); } // 显式析构,顺序可控 return 0; }7.4 排查内存问题的工具链经验光靠读代码找内存问题效率很低。我常用的组合是:AddressSanitizer:编译加-fsanitizeaddress -g,能直接报出 double free、use after free 和内存泄漏的分配栈,速度比valgrind快很多,适合日常开发Valgrind:更全面的内存检查,但慢一个数量级,适合在 CI 里跑冒烟测试use_count() 日志:针对引用计数类问题,这个组合比工具更直接ASan 的一个小技巧是配合ASAN_OPTIONSdetect_leaks1:fast_unwind_on_malloc0使用,后者能让栈回溯更准,代价是慢一些。还有一点值得提醒:启用 ASan 时,make_shared的那些优化不会被关闭,所以如果你怀疑是shared_ptr控制块的问题,ASan 依然能帮你定位到具体的内存块和分配位置,不必为了排查特意改成两次分配。7.5 关于智能指针的一个认知转变用得久了,我对智能指针的理解从防止泄漏的工具变成了表达所有权的语言。前者是被动防御,后者是主动设计。当你在写一个类的时候第一反应是这个成员的所有权归谁、生命周期由谁决定,代码结构会自然地变得清晰,很多并发和析构问题在设计阶段就被排除了。我也见过另一个极端,团队强制规定禁止使用裸指针,结果代码里到处是shared_ptr,引用计数成了一个隐形的全局状态,性能下降还查不出原因。合理的规则应该是:默认用unique_ptr,确实需要共享时才升级到shared_ptr,引用外部对象或表达观察关系时用裸指针和weak_ptr。这四个工具各司其职,没有哪个是过时的或者危险的,关键是你知道自己在表达什么。最后分享一个小习惯:我在做代码评审时,只要看到一个shared_ptr出现在函数参数里,就会问一句这里为什么不能是unique_ptr或者引用。十次里有六七次,提问之后对方会发现确实不需要共享。这个习惯本身不花什么成本,但长期下来能省掉不少引用计数的开销和潜在的循环引用风险。