导读:本期聚焦于北京GEO公司创作的《make_shared和直接new有什么区别?深入分析内存分配优化的内部机制》,敬请观看详情。一次堆分配和两次堆分配,这两个方案在shared_ptr的底层实现中代表了完全不同的内存布局策略。make_shared通过一次连续内存分配同时容纳对象和控制块,减少堆分配调用次数,提升缓存局部性;直接new则需要进行两次独立分配,对象与控制块分离,释放时机更灵活。本文从内存布局、引用计数机制、异常安全以及weak_ptr对内存释放的影响等角度展开,解释为什么大多数场景推荐make_shared,以及哪些情况下必须回到直接new。还会分析控制块中强引用计数与弱引用计数的协同工作,以及make_shared可能造成的大内存延迟释放问题。通过代码示例和内部结构拆解,帮助读者理解这一优化背后的取舍。

std::shared_ptr在C++11引入后成为管理动态资源的重要工具,而创建shared_ptr的两种典型方式,std::make_shared和直接new再包装,差异远不止语法糖。二者在内存分配策略上有着根本不同,直接决定对象生命周期、异常安全性以及内存释放时机。理解这些内部机制,有助于在复杂系统中做出正确选择。

make_shared和直接new有什么区别?深入分析内存分配优化的内部机制

内存分配次数的本质差异

直接使用std::shared_ptr<T>(new T)会发生两次堆内存分配。第一次由new T分配对象本身,第二次由shared_ptr构造函数分配控制块,控制块中保存强引用计数、弱引用计数以及删除器等信息。这两个内存区域相互独立,释放时也分别处理。

而std::make_shared<T>则通过一次连续的堆分配,将控制块和对象放在同一块内存中。标准库通常会分配一个足够容纳控制块和对象的结构,然后在该内存上通过placement new构造对象。控制块紧邻对象存储,二者地址连续,减少了一次堆分配调用。对于频繁创建和销毁shared_ptr的场景,少一次malloc或new调用意味着更低的时间开销和更少的内存碎片。

// 直接new方式,两次分配
std::shared_ptr<int> sp1(new int(42));

// make_shared方式,一次分配
auto sp2 = std::make_shared<int>(42);

从底层看,std::make_shared的实现通常会申请一块大小为sizeof(T) + sizeof(control_block)的内存,然后先构造控制块,再在控制块之后的地址上构造T对象。这种连续布局使得CPU缓存命中率更高,因为引用计数更新和对象访问往往发生在相邻内存地址。

但这种优化也有代价:连续内存块意味着对象的析构和控制块的内存释放被绑定在一起。当强引用计数归零时,对象会被析构,但整块内存不能立即归还给堆,因为弱引用计数可能还不为零,需要保留控制块供weak_ptr查询。只有弱引用计数也归零后,整块内存才会被释放。

控制块与对象生命周期的绑定机制

shared_ptr的控制块通常包含两个计数器:强引用计数和弱引用计数。强引用计数记录当前有多少个shared_ptr指向对象,弱引用计数记录当前有多少个weak_ptr观察该资源。当强引用计数归零时,对象被销毁;当弱引用计数也归零时,控制块本身被销毁。

在直接new方式中,对象和控制块是两块独立内存。强引用计数归零后,对象内存可以立即释放,控制块则继续存活,直到弱引用计数也归零。这意味着如果存在大量weak_ptr,但shared_ptr已经全部销毁,对象占用的那部分内存可以被及时回收,仅保留较小的控制块。

在make_shared方式中,对象和控制块被封装在同一块连续内存中。强引用计数归零时,只能调用对象的析构函数,但不能释放整块内存,因为控制块还需要为weak_ptr服务。只有当弱引用计数也归零后,整块内存才会被统一释放。如果对象本身占用内存很大,而weak_ptr又长时间存在,比如用weak_ptr实现缓存或观察者模式,那么对象的内存会被延迟释放,可能造成内存占用峰值升高。

// 演示weak_ptr延长make_shared内存占用
std::weak_ptr<BigObject> weak;
{
    auto sp = std::make_shared<BigObject>();
    weak = sp;
} // sp离开作用域,强引用计数归零,BigObject析构
// 但内存块仍未归还,weak仍持有控制块
// 直到weak被销毁或重置,整块内存才释放

这种绑定机制是make_shared优化的核心权衡。对于小对象或weak_ptr生命周期可控的场景,性能优势明显;对于大对象和长生命周期weak_ptr,直接new可能更合适,因为它能更早释放对象占用的堆内存。

异常安全与分配失败场景

直接new方式存在一个经典的异常安全陷阱。考虑函数参数求值顺序的不确定性,如果代码写成foo(std::shared_ptr<int>(new int(42)), bar()),编译器可能先执行new int(42),再执行bar(),最后构造shared_ptr。如果bar()抛出异常,new int(42)分配的裸指针就泄漏了,因为没有shared_ptr接管它。

而std::make_shared将对象创建和shared_ptr构造封装在同一个函数调用中,不会出现中间裸指针暴露给异常的情况。即使后续参数求值抛出异常,已经分配的内存也能由内部机制安全回收,避免资源泄漏。这也是C++核心指南推荐优先使用make_shared的原因之一。

// 异常不安全写法
foo(std::shared_ptr<int>(new int(42)), bar());
// 若bar()先执行并抛出异常,new int(42)泄漏

// 异常安全写法
foo(std::make_shared<int>(42), bar());
// 对象创建和智能指针封装为一体,无泄漏风险

此外,make_shared在分配失败时直接抛出std::bad_alloc,而直接new再构造shared_ptr可能因为两次分配中的第二次失败导致第一次分配的资源泄漏。例如new T成功,但shared_ptr的控制块分配失败,此时构造函数抛出异常,已经分配的对象内存无法释放。make_shared将两次分配合并为一次,从根本上避开了这种中间状态。

性能实测与选型建议

从性能角度看,make_shared的优势主要体现在两方面:一是减少堆分配调用次数,二是提升缓存局部性。堆分配通常涉及锁竞争或系统调用,成本较高;连续内存布局还能降低缓存未命中率,尤其在多线程高频创建shared_ptr时效果更明显。

不过,现代malloc实现往往对小内存分配有高效缓存,直接new的两次小分配未必比make_shared的一次大分配慢很多。实际性能差异取决于对象大小、分配器实现以及weak_ptr使用模式。如果对象很大,make_shared的延迟释放可能导致峰值内存更高;如果对象很小且无weak_ptr,make_shared几乎总是更优。

在工程实践中,可以遵循以下规则:默认使用std::make_shared,它更安全且通常更快;当需要自定义删除器、需要控制对象与内存块分离、或者存在大量长期存活的weak_ptr指向大对象时,再考虑直接new并包装成shared_ptr。理解这些内部机制,比盲目套用规则更重要。

总结来说,make_shared和直接new的区别本质上是内存分配策略的差异:一次连续分配与两次独立分配。前者优化了分配性能和异常安全,但牺牲了对象内存的及时释放;后者更灵活,但需要手动处理异常风险。根据具体场景选择合适的创建方式,是C++资源管理的关键细节。

make_shared直接new内存分配优化修改时间:2026-09-29 19:41:43

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0929/63524.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。