std::shared_ptr在C++11引入后成为管理动态资源的重要工具,而创建shared_ptr的两种典型方式,std::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