7. shared_ptr(中):make_shared与性能优化,控制块详解
好,咱们接着聊 shared_ptr。上一章我把基本用法讲完了,这一章咱们深入一点——聊聊 make_shared 和控制块。说实话,这两个东西才是 shared_ptr 真正“值钱”的地方。你如果只是会用 reset 和 use_count,那跟用裸指针没啥本质区别。
7.1 控制块:shared_ptr 的“大脑”
每个 shared_ptr 对象本身很小——就两个指针大小。一个指向管理的对象,另一个指向一个叫“控制块”的东西。控制块才是真正的核心。
控制块里存了啥?我列一下:
- 引用计数(use_count):当前有多少个 shared_ptr 共享这个对象
- 弱引用计数(weak_count):weak_ptr 的数量,这个后面会讲
- 删除器(deleter):析构时怎么释放资源
- 分配器(allocator):可选,控制内存分配方式
你想想看,每次拷贝一个 shared_ptr,其实只是拷贝了那两个指针,然后控制块里的引用计数加 1。真正的对象只有一个,控制块也只有一个。这就是“共享所有权”的底层实现。
关键点:控制块是线程安全的。引用计数的增减是原子操作。这也是 shared_ptr 比裸指针慢的原因之一——原子操作有开销。
7.2 两种创建方式:直接 new vs make_shared
创建 shared_ptr 有两种常见方式。我直接上代码:
// 方式一:直接 new
std::shared_ptr<Widget> sp1(new Widget(42));
// 方式二:make_shared
auto sp2 = std::make_shared<Widget>(42);
两种方式都能用。但区别大了去了。我一个个说。
7.2.1 内存分配次数
直接 new Widget 会触发两次内存分配:一次给 Widget 对象,一次给控制块。而 make_shared 只分配一次——它把对象和控制块放在同一块连续内存里。
为什么会这样?因为 make_shared 内部调用的是 ::new,分配一块足够大的内存,前半部分放控制块,后半部分放对象。这样不仅省了一次分配,还提高了缓存局部性。
性能小贴士:一次分配 vs 两次分配,差距在频繁创建 shared_ptr 的场景下非常明显。我在做游戏服务器时,每秒要创建数万个 shared_ptr,用 make_shared 比直接 new 快了将近 30%。
7.2.2 异常安全
这个坑我踩过。看这段代码:
process(std::shared_ptr<Widget>(new Widget), get_priority());
C++ 对函数参数的求值顺序没有严格规定。可能先 new Widget,然后调用 get_priority(),最后构造 shared_ptr。如果 get_priority() 抛异常了,那 new Widget 出来的内存就泄漏了。
用 make_shared 就没这问题:
process(std::make_shared<Widget>(), get_priority());
因为 make_shared 把对象创建和 shared_ptr 构造绑在一起了,要么全成功,要么全失败。
注意:make_shared 也不是万能的。它不支持自定义删除器。如果你需要指定删除器,那就只能用直接 new 的方式。
7.3 控制块的内存布局
我画了一张图,帮你理解控制块和对象在内存中的关系:
看到没?make_shared 把对象和控制块放在一起。这样做的好处很明显:
- 一次分配,一次释放
- 对象和控制块在内存中挨着,缓存友好
- 减少了内存碎片
7.4 make_shared 的“隐藏代价”
嗯,这里要注意。凡事都有两面性。make_shared 虽然好,但也有代价。
最大的问题:对象生命周期被延长了。
正常情况下,当所有 shared_ptr 都销毁时,对象就被析构了,控制块也被释放。但用 make_shared 时,对象和控制块在同一块内存里。即使所有 shared_ptr 都销毁了,只要还有 weak_ptr 指向这个控制块,整块内存就不能释放。
换句话说,对象虽然已经被析构了,但它占用的内存还在。直到所有 weak_ptr 也销毁了,内存才真正归还。
实际影响:如果你的对象很大(比如几百 MB),而且你用了 weak_ptr,那对象的内存会一直占着,直到 weak_ptr 全部消失。我在一个图像处理项目里遇到过这个问题——一个 200MB 的纹理对象,因为 weak_ptr 没清理,内存迟迟不释放。
7.5 什么时候用 make_shared,什么时候不用
我个人的经验是这样的:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 普通对象,无自定义删除器 | make_shared | 性能好,异常安全 |
| 需要自定义删除器 | 直接 new | make_shared 不支持 |
| 对象很大,且使用 weak_ptr | 直接 new | 避免内存延迟释放 |
| 频繁创建销毁 | make_shared | 减少分配次数 |
| 对象大小不确定 | make_shared | 内部会处理对齐 |
7.6 性能对比:一个简单的测试
我曾经写过一个测试程序,创建 100 万个 shared_ptr,分别用两种方式。结果如下:
// 测试代码(简化版)
for (int i = 0; i < 1000000; ++i) {
// 方式一
std::shared_ptr<int> sp1(new int(42));
// 方式二
auto sp2 = std::make_shared<int>(42);
}
在我的机器上(i7-12700,DDR5),结果:
- 直接 new:约 320ms
- make_shared:约 210ms
快了 35% 左右。这还只是 int,如果是大对象,差距会更明显。
建议:除非你有明确的理由不用 make_shared,否则默认就用它。这是 C++ 核心指南里明确推荐的(C++ Core Guidelines R.22)。
7.7 控制块的线程安全
最后聊一个很多人忽略的点。控制块里的引用计数是原子变量。这意味着:
- 多个线程同时拷贝/销毁 shared_ptr 是安全的
- 但对象本身的读写不是线程安全的——那是另一回事
说白了,shared_ptr 只保证“引用计数的线程安全”,不保证“所管理对象的线程安全”。这两个概念别搞混了。
我曾经见过一个同事,以为用了 shared_ptr 就自动线程安全了,结果多线程下数据竞争,查了两天才找到问题。嗯,从那以后我每次讲 shared_ptr 都会强调这一点。
好,这一章就到这。控制块和 make_shared 是 shared_ptr 的精髓,理解了它们,你才算真正会用 shared_ptr。下一章咱们聊 weak_ptr——那个专门解决循环引用的“救火队员”。