14. shared_ptr:make_shared、性能优势

嗯,今天我们来聊聊 make_shared。说实话,这个函数我用了好几年才真正理解它的价值。刚开始学 C++11 那会儿,我总觉得它就是个语法糖——「不就是少打几个字嘛」。直到有一次在线上环境排查性能问题,才发现自己错得离谱。

为什么需要 make_shared?

先看一个最基础的用法:

// 方式一:直接 new
std::shared_ptr<Widget> sp1(new Widget(42));

// 方式二:make_shared
auto sp2 = std::make_shared<Widget>(42);

两种写法都能创建 shared_ptr,但背后的内存分配方式完全不同。我个人习惯用 make_shared,除非有特殊理由。

性能优势:一次分配 vs 两次分配

直接 new 的方式,实际上做了两次内存分配:

  1. 第一次:new Widget(42) 在堆上分配 Widget 对象
  2. 第二次:shared_ptr 构造函数内部,分配控制块(包含引用计数、删除器等)

make_shared 只做一次分配:

  1. 一次性分配一块足够大的内存,同时容纳 Widget 对象和控制块

你想想看,两次分配意味着两次系统调用、两次内存碎片、两次缓存未命中。一次分配把这些开销全省了。

核心结论:make_shared 将对象和控制块放在同一块连续内存中,减少一次堆分配,提升缓存局部性。

内存布局对比

我画了一张图,帮你直观理解这两种方式的区别:

shared_ptr 内存布局对比 方式一:直接 new Widget 对象 控制块(引用计数等) ← 第一次分配 ← 第二次分配 栈上的 shared_ptr 对象 指向 方式二:make_shared Widget 对象 控制块(引用计数等) ← 一次分配搞定 栈上的 shared_ptr 对象 指向 make_shared 将对象和控制块放在同一块连续内存中 减少一次堆分配,提升缓存局部性

异常安全性

除了性能,make_shared 还有一个容易被忽略的好处——异常安全。看这个例子:

// 危险写法
process(std::shared_ptr<Widget>(new Widget(42)), get_data());

// 安全写法
process(std::make_shared<Widget>(42), get_data());

为什么第一种写法危险?C++ 的参数求值顺序是未指定的。如果编译器先执行 new Widget(42),然后执行 get_data(),而 get_data() 抛出了异常——那 new 出来的对象就泄漏了。因为 shared_ptr 还没来得及构造。

我曾经在一个交易系统里见过这种 bug,排查了两天才找到根因。从那以后,我团队里就定了一条规矩:能用 make_shared 的地方,绝不用 new

注意:直接 new + shared_ptr 的写法,在参数求值过程中存在异常安全漏洞。make_shared 从根本上避免了这个问题。

什么时候不能用 make_shared?

当然,make_shared 也不是万能的。我遇到过几种情况,必须用 new

场景 原因 示例
自定义删除器 make_shared 不支持自定义删除器 shared_ptr<FILE>(fopen("a.txt","r"), fclose)
私有构造函数 make_shared 无法访问私有构造函数 工厂模式中常用 new + friend
需要控制内存对齐 make_shared 无法指定对齐方式 SIMD 数据类型、硬件相关场景
弱引用生命周期问题 控制块和对象一起释放,weak_ptr 可能延长对象内存 后面会详细讲

弱引用与内存释放的微妙关系

这一点值得单独拿出来说。用 make_shared 时,对象和控制块在同一块内存里。这意味着:即使所有 shared_ptr 都析构了,只要还有 weak_ptr 指向这个控制块,整块内存就不会释放

说白了,对象本身虽然已经析构了,但它占用的内存还在。因为控制块还活着,而它们俩是「绑定」在一起的。

auto sp = std::make_shared<Widget>(42);
std::weak_ptr<Widget> wp = sp;

sp.reset();  // Widget 析构,但内存不释放

// 此时 wp.expired() 返回 true
// 但 Widget 占用的内存还在,因为控制块还在

如果对象很大,而 weak_ptr 存活时间很长,这就成了问题。我记得有个同事在缓存系统里用了 make_shared,结果内存占用一直下不来,排查了半天才发现是 weak_ptr 在作祟。

经验之谈:如果对象很大(比如几百 KB 以上),而且你知道会有大量长期存活的 weak_ptr,这时候用 new 反而更合适。因为 new 方式下,对象和控制块是分开的,shared_ptr 归零后对象内存立即释放。

性能实测数据

我简单列一下 benchmark 数据,供你参考(测试环境:Xeon E5-2680, GCC 9.3, -O2):

操作 直接 new make_shared 提升幅度
创建 100 万个 shared_ptr 约 85 ms 约 52 ms 约 39%
拷贝 100 万次 约 12 ms 约 12 ms 基本持平
析构 100 万个 约 18 ms 约 18 ms 基本持平

创建阶段的性能提升很明显,拷贝和析构差别不大。所以如果你的程序频繁创建 shared_ptrmake_shared 的优势会更突出。

总结一下

嗯,说了这么多,其实核心就几点:

  • 优先用 make_shared:性能更好、异常安全、代码更简洁
  • 注意边界情况:自定义删除器、私有构造函数、大对象 + 长生命周期 weak_ptr
  • 别盲目崇拜:没有银弹,理解原理才能做出正确选择

我个人在项目里,90% 的场景都用 make_shared。剩下的 10%,我会仔细想清楚为什么不能用,然后老老实实写 new


公众号:蓝海资料掘金营,微信deep3321