☰
C++内存管理:make_shared与new的抉择,五大场景必须用new
2026/10/5 3:56:44 网站建设 项目流程

如果你去参加一次现代 C++ 的代码评审,看到auto p = new Foo()这行,大概率会有人当场坐不住。这倒不是矫情——过去十几年,make_shared、make_unique已经把裸new在绝大多数场景里挤出了舞台。但你要是因此认定new已经彻底没用了,那下个项目里照样会撞上无处下手的尴尬。今天就把这个问题一次聊透:make 系列凭什么叫板new,以及哪些场景下new依旧是那个绕不开的答案。

这篇文章适合所有写过new的 C++ 开发者。无论你是刚从 C 风格转型、正在看懂别人代码里的智能指针,还是已经用make_unique写了一年多代码但说不清内部原理,都值得往下看。我会尽量把原理、坑和选型逻辑揉在一起讲,而不是只丢几条干巴巴的规则。

1. new 到底"错"在哪:这要从 RAII 和所有权说起

1.1 裸 new 的三大硬伤

先说个事实:new本身并不是洪水猛兽。int* p = new int(42);这行代码在 C++ 里完全正确,也谈不上危险。真正危险的是人们在使用new之后的几十年里反复犯的几类错误。

第一类是忘记delete。这几乎是每个 C++ 程序员的入门第一课惨痛记忆。代码写得多了,分支一复杂,总有那么一条路径绕过了delete,于是一个对象就这么静默地活在堆上。单个对象看着无所谓,可一旦出现在长期运行的服务进程里,就是积少成多的内存膨胀。

第二类是重复delete,或者释放后继续使用。这类问题的调试难度远超内存泄漏,因为它往往不在分配点附近崩溃,而是等到堆结构被破坏、或者恰好有别的对象复用了那块内存之后才炸。真正定位起来,需要 valgrind、ASan 轮番上阵,成本极高。

第三类是我认为最阴险的:异常安全。new和delete之间夹杂了业务逻辑,只要逻辑中抛出了异常,释放动作就可能被跳过。很多老项目里所谓的内存泄漏,根本不是忘了delete,而是"异常路径上的delete没有执行到"。

后面两点其实指向同一个本质:裸new把堆对象的生命周期管理责任完全交给了人,而人恰恰是最不可靠的组件。每次手动delete都是在赌,赌自己记得住所有执行路径、赌异常不会来捣乱、赌未来维护这段代码的同事也能和你保持同样的纪律。这种赌局偶尔会赢,但只要输一次,代价就是一次线上事故。

1.2 智能指针为什么能根治这个问题

C++ 解决这类问题的核心思想叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。这个名词听起来唬人,但翻译成大白话就一句话:把资源的生死绑定到一个栈上对象的构造和析构上。栈对象的析构是编译器保证的,无论中途抛出异常、提前 return,还是正常走到函数末尾,析构都会被调用。那么资源自然跟着释放。

智能指针就是这个思路的具体产物:std::unique_ptr表示独占所有权,std::shared_ptr表示共享所有权 + 引用计数,std::weak_ptr作为观察者介入而不增加生命周期绑定。你把new出来的指针丢进智能指针里,剩下的事情它替你兜底。

但故事到这儿还没完。有心人应该已经发现:std::unique_ptr<T>(new T)这行代码里,new依然存在。智能指针只解决"指针已经进入智能指针管理之后"的释放问题,可如果new到unique_ptr之间出了岔子呢?这个缝隙,正是make_unique和make_shared要填上的核心缺口。

2. make_unique:很多人以为是性能优化,其实是异常安全的补课

2.1 一场由求值顺序引发的内存泄漏

先看一个非常经典的反面教材:

void process(std::unique_ptr<Resource> a, std::unique_ptr<Resource> b); // 危险用法:new 出来了,但还没真正进入智能指针管理 process(std::unique_ptr<Resource>(new Resource()), std::unique_ptr<Resource>(new Resource()));

在 C++17 之前,函数实参的求值顺序是未指定的。编译器可以先算第一个new Resource(),再算第二个new Resource();也可以反过来,甚至两个new交错执行。假如第一个new成功了、第二个new抛出了异常,会发生什么?第一个新创建的对象还只是一块裸内存,因为它的地址还没来得及传给对应的unique_ptr构造函数。没有智能指针会替它释放,于是它就这么凭空泄漏了。

有人可能会说:那我不用这种写法不就行了。问题是大型代码库里,这种"临时构造智能指针 + 背后藏裸 new"的写法到处都是,尤其是出现在构造函数初始化列表、函数参数拼接、容器插入等场景时,肉眼根本防不过来。

换成make_unique,情况立刻不同:

process(std::make_unique<Resource>(), std::make_unique<Resource>());

每个make_unique返回的临时对象都是完整的unique_ptr,任何异常发生时,已经构造好的临时对象都会以自己的析构函数收场。不需要你操心求值顺序,也不需要你记得把它存到变量里。

2.2 C++14 才补上 make_unique 的原因

这里有个很多人不知道的历史细节:std::make_shared从 C++11 就进了标准库,但std::make_unique一直到 C++14 才被正式加入。为什么标准委员会先做了难的、却漏了简单的?

原因挺现实:C++11 制定时,make_unique被认为没啥存在感——unique_ptr本身没有引用计数、没有控制块,unique_ptr<T>(new T)的语法也不算太痛苦,所以一度被搁置。后来 Herb Sutter 等人在社区里反复呼吁,大家才意识到:make_unique根本不是性能优化,它是异常安全和代码一致性的补课。于是 C++14 把它补了进来。

另外,make_unique在性能上没有任何代价。它的实现本质就是一行转发构造:

template<typename T, typename... Args> std::unique_ptr<T> make_unique(Args&&... args) { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); }

所以它和unique_ptr<T>(new T)在产物上是等价的。既然正确性更高、写法更短、风格更统一,那就没有理由继续写new T。这也是为什么我总跟团队说:先别讨论性能,先把能白捡的安全拿到手。

3. make_shared 的"一次分配"是优点,也是坑

3.1 内存布局:对象和控制块的三种摆放方式

shared_ptr要比unique_ptr复杂得多,因为它除了管理对象本身,还要管理一个控制块。控制块里存着强引用计数、弱引用计数,可能还有删除器、分配器等信息。

用shared_ptr<T>(new T)时,通常发生两次内存分配:

  • 第一次:new T为对象本身分配内存。
  • 第二次:控制块单独分配内存。

用std::make_shared<T>(args...)时,编译器会把对象和控制块塞进同一次分配的内存里,一次 malloc 搞定一切。带来的直接好处有两点。第一,少一次内存分配,整个创建和释放过程更快;第二,对象和控制块在内存中紧挨着,访问相邻内存的缓存局部性更好,这对频繁创建小shared_ptr的场景可能带来明显收益。

但天下没有免费的午餐。一次分配意味着控制块的生命周期和对象内存的生命周期被硬绑在了一起。这个问题放在 3.2 小节重点说。

3.2 强引用归零后,大对象内存为什么还赖在堆上

理解make_shared的隐坑之前,先弄清楚引用计数归零的完整过程。

shared_ptr的控制块里有两个计数:强引用计数和弱引用计数。当强引用计数归零时,对象会被析构;但控制块本身还活着,因为弱引用计数还没归零。弱引用归零后,控制块才被释放。

问题来了:如果用make_shared分配,对象和控制块在同一个内存块里。强引用归零、对象析构之后,这块内存并不能归还给堆分配器,因为控制块还在,而控制块紧挨着对象。于是整个内存块(包括对象占用的那一段)就只能继续占着,直到所有weak_ptr也失效。

极端一点说:一个 500MB 的大对象,make_shared创建后在局部作用域里迅速使用完,强引用归零,但全局某个地方还留着一个weak_ptr。你原本以为大对象已经释放了,实际它是析构了,但空间没有还给内存池。如果这个weak_ptr一直活着,这 500MB 就这么一直"被扣"在那里。

我见过的一个真实场景是缓存服务:全局map<string, weak_ptr<CacheEntry>>保留弱引用,请求来了创建shared_ptr,处理完释放强引用。条目本身几十 MB,用make_shared创建后,进程内存随着请求量一路涨。排查到最后,问题不在业务泄漏,而在上面这个机制。

对策也很直接:对这类"对象极大 + weak_ptr 长期存活"的场景,放弃make_shared,回到shared_ptr<T>(new T)。这样对象和控制块分开分配,强引用归零时对象内存立即归还,控制块再小也不过几十字节,留着无伤大雅。

3.3 自定义删除器、分配器和 make_shared 的边界

make_shared还有一个天生短板:它不支持自定义删除器。因为make_shared内部只能用默认的delete来销毁对象,你没法在调用时传入fclose、munmap或者其他释放逻辑。

如果资源不是来自new,而是来自fopen、CreateFile、mmap,那么直接拿make_shared就成了一件不可能的事。这种时候要么用带删除器的shared_ptr构造,要么用allocate_shared配合自定义分配器。

我这里总结一个简单的对比表,方便查阅:

特性make_sharedshared_ptr (new T)allocate_shared
内存分配次数1 次2 次由分配器决定
异常安全高需配合 RAII 使用高
自定义删除器不支持支持不支持(但可定制分配器)
对象极大且 weak_ptr 长期存在对象内存不释放对象内存在强引用归零后立即释放取决于分配器实现
缓存局部性好一般取决于分配器实现

记住这张表的最后一行的"坑",比记住make_shared的好处更值钱。

4. 真正绕不开 new 的五个场景

4.1 接管现有原始指针(包括 this)

现实项目里,有很大一部分指针不是你自己new出来的,而是某个老接口、第三方库、C 风格回调交给你的。比如:

SomeObject* raw = some_c_api_create(); if (!raw) { throw std::runtime_error("some_c_api_create failed"); } std::shared_ptr<SomeObject> owner(raw, [](SomeObject* p) { some_c_api_destroy(p); });

这里的owner接管了raw的所有权,并在析构时调用对应的销毁函数。make_shared和make_unique无论如何都做不到这件事,因为对象根本不由你创建。

还有一种常见情形是在对象内部把自己交给外部管理。比如某些事件系统要求组件注册一个自指针:

class Listener : public std::enable_shared_from_this<Listener> { public: void registerSelf() { auto self = shared_from_this(); // 依赖对象由 shared_ptr 管理 } };

这里虽然没直接写new,但它隐含的要求是:对象必须先被一个shared_ptr管理,shared_from_this才能工作。你可以用make_shared创建,也可以shared_ptr<T>(new T)创建,但永远不能用裸new之后直接撒手。这个场景想表达的是:管理现有指针、管理 this,是 make 系列替代不了的一类需求。

4.2 自定义删除器:万物皆可 RAII

make_unique和make_shared都只能管理"用new创建并用delete销毁"的资源。可现实中资源远不止内存。文件句柄、互斥锁、socket、mmap 区域,甚至数据库连接,都算资源。

举个实际例子,用智能指针管理一个 mmap 出来的共享内存映射:

#include <sys/mman.h> struct MmapDeleter { size_t length; void operator()(void* p) const { munmap(p, length); } }; size_t map_len = 4096; void* addr = mmap(nullptr, map_len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); std::unique_ptr<void, MmapDeleter> mapped(addr, MmapDeleter{map_len});

这段代码里,mmap返回的是裸指针,释放逻辑是munmap,make_unique完全无法表达。唯一合理的方式就是裸指针 + 自定义删除器。

再比如管理fopen返回的FILE*:

std::shared_ptr<FILE> fp(fopen("data.txt", "r"), fclose);

这种写法在很多老代码库中非常常见,而且完全正确。不要为了"现代化改造"而强行改成make_shared,因为make_shared根本没有这个能力。我们应该做的是承认并理解这个边界。

4.3 大对象配合长期存活的 weak_ptr:内存归还的硬需求

这个场景在第 3 章已经剖析过原理,这里再说说决策层面。如果对象的体积巨大,并且它的weak_ptr会被某个长期存活的容器持有(缓存、注册表、观察者列表),那么用make_shared就会造成"析构但不归还"的隐性占用。

此时直接:

std::shared_ptr<BigObject> sp(new BigObject(...));

对象内存会在强引用归零后立即归还,控制块单独留着等弱引用清空。内存条使用率难道不是越高越好?感情上是,但事实不是。长期运行的服务里,这种"多扣住不放"的块一旦累积,轻则内存监控告警,重则被运维判定为泄漏,迫使你加班排查。与其到时候怀疑人生,不如一开始就按需选择。

4.4 与 C API 和第三方库的交接

任何用 C++ 写过插件、接入过 C 库的人,都会对下面这种模式有强烈共鸣:

// C 风格事件循环,用户数据指针是一个 void* void on_event(void* user_data) { auto* obj = static_cast<MyClass*>(user_data); // 处理完,要求我们自己释放 delete obj; }

在这种流程里,对象起初就是通过new创建的,因为要传给 C 接口当作void*使用。你不能在这个环节用make_unique,也不能期待shared_ptr替你解决,因为 C 那边只认指针,不认智能指针。

正确做法是:创建时用new或者make_unique,在传给 C 接口时release()掉所有权;回调时再迅速用一个智能指针把指针包回来,确保异常路径也能释放:

void on_event(void* user_data) { std::unique_ptr<MyClass> guard(static_cast<MyClass*>(user_data)); guard->handle(); } // 正常或异常退出,guard 都会析构并 delete

这个模式里,new是接口边界的硬通货,智能指针是边界内部的安保系统,两者互相配合,谈不上谁替代谁。

4.5 老标准代码与重载了 operator new 的类型

最后说两件容易被忽略的边角场景。

第一,老代码。std::make_unique是 C++14 才有的,如果你的项目还停留在 C++11 标准(某些嵌入式、车载、军工项目确实有这种约束),那么代码里大量std::unique_ptr<T>(new T)不是"坏味道",而是一种不得已的正确写法。改造之前先确认标准版本,别看到new就条件反射式地要换成make_unique。

第二,重载了operator new/operator delete的类型。make_unique内部调用的还是new T,所以大部分情况下它依然会进入你重载的分配函数。但如果你需要的是 placement new,即在已经分配好的内存地址上构造对象,那问题就彻底不同了:

void* arena = get_shared_buffer(); T* p = new (arena) T(args...); // placement new,不分配内存,只构造

这种"构造"动作是make_shared/make_unique永远表达不了的。释放时也绝不能直接delete p,而要手动调用析构函数或配合自定义删除器。这个场景虽然少见,但一旦遇到,你对"new必须消亡"的执念反而会变成 bug 的温床。

5. 生产环境里的选型策略:我的默认规则和踩坑记录

5.1 一条可以抄走的决策流程

我给自己定过一条规则,也用这条规则做过不少评审,收益一直很稳定。现在把它开放出来:

// 伪代码式的决策思路 if (需要独占所有权) { use make_unique; // 默认 if (需要自定义删除器 || 接管已有指针) use unique_ptr(ptr, deleter); } else if (需要共享所有权) { use make_shared; // 默认 if (对象极大 && weak_ptr 长期存在) use shared_ptr(new T); if (需要自定义删除器) use shared_ptr(ptr, deleter); } else { // 纯观察,不持所有权 use weak_ptr / raw pointer; } // 手动 delete 只允许出现在"指针刚从 C 接口/回调里拿出来的一瞬间"

这套流程的核心是:默认走 make 系列,破例必须给出理由。破例不是坏事,但破例最好写在注释里。我见过太多"用shared_ptr<T>(new T)只是因为当初随手写的"代码,后面维护的人也不知道该不该改成make_shared。一旦你把理由写清楚,后续改造就有依据了。

5.2 坑一:make_shared 的大对象内存迟迟不归还

前文已经把这个坑的原理讲透了,这里补充一下排查经历。

当时一个后台服务的内存曲线持续爬坡,用 valgrind 和 ASan 都查不出传统泄漏。后来我打印了weak_ptr::use_count(),发现大量weak_ptr虽然对应的强引用已经是 0,但lock()还能返回?不,lock()返回的是空指针,这没问题。真正的问题出在/proc/<pid>/smaps里堆内存居高不下。

后来在代码里 grep 了一遍所有make_shared的位置,其中一个频繁创建大CacheEntry的调用让我起了疑心。试改成shared_ptr<CacheEntry>(new CacheEntry(...))之后,内存曲线肉眼可见地稳了。这个案例之所以印象深刻,是因为它提醒我:"没有内存泄漏"和"内存使用合理"是两件事。make_shared 没有泄漏,只是延迟归还,但延迟可以看出 bug 的错觉。

5.3 坑二:在异常路径里裸 new 的连锁反应

还有一次踩坑是在一个音频处理模块里。代码大致长这样:

void process(Data&& d) { auto p = new GraphNode(d.input); try { runPipeline(p); } catch (...) { delete p; throw; } delete p; }

看到try/catch里补了一个delete,这位同事觉得自己已经考虑周全了。实则不然。后来runPipeline内部在某个地方把p地址传给了无锁队列,异步线程拿着这个指针继续跑,主线程却已经delete了。局面瞬间变成典型的 use-after-free,而且极其难查。

如果当初直接用std::shared_ptr<GraphNode>管理,异步线程哪怕延迟消费,强引用计数也会保证对象还活着。这种问题不是非得make_shared才能解决,但它说明:当你有任何跨线程、跨生命周期传递的需求时,裸new+ 手动delete的本质缺陷就会暴露。这不是使用姿势问题,而是所有权模型的问题。

5.4 一个简单的性能对比结论

总有人会问"make_shared 到底快多少"或者"make_unique 是不是比 new 慢"。我的实测经验是:

  • make_unique和unique_ptr<T>(new T)基本没有性能差距,不要在两者之间纠结性能。
  • make_shared相比shared_ptr<T>(new T),由于少一次分配,通常有明显优势,尤其在频繁创建/销毁小型共享对象时。不同平台的 malloc 实现不同,收益也不同,但方向几乎是确定的。
  • 真正影响大的是分配次数和内存局部性,而不是"函数是不是魔法"。如果性能敏感,更好的做法是直接上内存池、对象池,而不是纠结new和make_shared的语法差异。

所以在绝大多数业务代码里,基于性能选make_shared是个红利,但不是核心理由。核心理由从来都是安全、一致、省心。

6. 新标准正在给这个问题添答案:for_overwrite 与分配器版本

6.1 make_unique_for_overwrite:省掉一次不必要的清零

C++20 引入了make_unique_for_overwrite和make_shared_for_overwrite。它们和普通 make 系列的区别只在初始化语义上:普通版本会对T做值初始化,内建类型会被清零;overwrite 版本只做默认初始化,也就是对于 POD 类型不保证清零。

很多人会觉得这有什么意义?意义很大。一些场景下,你立刻就要往缓冲区填满数据,比如读文件、收网络包、解码图像,前面的清零动作纯属白做:

// 传统写法:先清零再被覆盖 auto buf = std::make_unique<std::byte[]>(1024 * 1024); // C++20 写法:跳过清零,直接用后续数据覆盖 auto buf = std::make_unique_for_overwrite<std::byte[]>(1024 * 1024);

性能敏感路径上,这种优化实打实。但请注意,for_overwrite不解决自定义删除器的问题,它只是在初始化语义上多了一个选项。所以 new 的边界并没有因此缩小多少,只是"堆上创建"这件事本身被打磨得更精细了。

6.2 allocate_shared / allocate_unique:进入特定内存池

另一个值得关注的方向是allocate_shared。它允许你传入一个分配器,让对象和控制块都从指定内存池里分配,而不是走全局堆。

std::pmr::monotonic_buffer_resource pool; std::pmr::polymorphic_allocator<std::byte> alloc(&pool); auto sp = std::allocate_shared<SomeHeavyObject>(alloc, /* args */);

这在高性能服务器、游戏引擎、低延迟交易系统里很常见:对象不直接使用malloc,而是从线程局部内存池分配,避免锁竞争和堆碎片。注意标准库没有allocate_unique,但有allocate_shared依赖的完整机制。真需要独占 + 分配器时,往往要自己小封装一下,或者直接用unique_ptr+ 自定义删除器。

这个方向本质上是把"如何分配内存"这件事从new上剥离出来,交给更灵活的策略。但无论怎么灵活,只要你想让某个对象由智能指针管理,而它的创建方式又绝非默认delete能释放,new的那一口边界就永远存在。

6.3 我的最终体会

新标准确实一直在加新工具,for_overwrite、allocate_shared、polymorphic_allocator都在把"创建对象"这件事做得更细、更安全、更可定制。但你要是问new会不会被彻底替代,我的答案是:不会,也不应该被彻底替代。new会从一个默认的创建方式收敛成一个隐式的基础设施:make 系列最终也会调用它,接管旧指针时依然绕不开它,自定义分配器和删除器也建立在理解它的基础之上。真正重要的不是"用不用new",而是"用new的意图和边界是否清晰"。

就我个人而言,写新代码时默认make_unique/make_shared,碰到老接口、自定义删除器、大对象 + 长期弱引用这些场景时,我会安静地写回new,并且加一行注释注明为什么这里不能改用 make。这种"先默认、再破例、注释说清理由"的秩序感,比死记任何规则都更可靠。你如果正在被make_shared和new的选择困扰,不妨也按这个思路做,把安全交给机制,把判断留给自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询