make_shared 和直接 shared_ptr< T >(new T)有什么区别?
shared_ptr内部除了对象指针,还要存什么什么是控制块,控制块里一般有什么
- 强引用计数
- 弱引用计数
- 删除器
- 分配器
shared_ptr<T>(new T)时,内存通常分几次申请make_shared<T>(...)时,内存通常怎么申请,为什么性能更好为什么
make_shared通常更安全,尤其是异常安全什么时候不适合用
1
make_shared
- 比如对象很大
- 弱引用长期存在
- 自定义删除器
- 需要把裸指针交给特殊接口
enable_shared_from_this是怎么工作的,为什么对象不能在构造函数里乱用shared_from_this()shared_ptr的引用计数为什么要是原子的,它线程安全到什么程度,哪些操作其实仍然不安全
追问 1
为什么说 make_shared 可能导致“对象明明析构了,但占用的大块内存不能立刻释放”?
追问 2
shared_ptr 引用计数减到 0 时,发生的是两件事还是一件事:
- 对象析构
- 控制块释放
这两个时机一样吗?
shared_ptr 自己这个对象通常并不大,典型实现里它手上主要就两样东西:一个是存储指针,也就是 get() 返回给你的那个指针;另一个是控制块指针,真正的引用计数、删除逻辑这些核心状态都在控制块里。典型实现说明里也明确写了,shared_ptr 通常只持有这两根指针。
所谓控制块,你可以把它理解成“共享所有权的后台管理对象”。里面一般会放这些东西:
一是被管理对象本身,或者至少有一个指向被管理对象的指针;
二是强引用计数,也就是当前有多少个 shared_ptr 在共同拥有它;
三是弱引用计数,也就是有多少个 weak_ptr 在观察它;
四是删除器,因为最后销毁对象时不一定都是简单 delete;
五是分配器,控制块自身怎么分配、怎么释放,也要有对应策略。cppreference 的 implementation notes 基本就是这么总结的。
然后我会接着讲两种写法的底层差异。
如果你写的是 shared_ptr<T>(new T),通常会发生至少两次分配:
第一次是 new T 给对象本体分配内存;
第二次是 shared_ptr 为控制块再分配一块内存。
因为对象和控制块是分开的,所以控制块里通常保存的是“对象指针”。
如果你写的是 make_shared<T>(args...),典型实现通常会一次性申请一大块内存,把“控制块”和“对象本体”一起放进去,对象直接在控制块预留的位置里原地构造。标准层面是“推荐这样做”,而现实里已知实现通常也确实这么做。这样少一次堆分配,局部性也更好,所以一般性能会更好。
所以如果面试官问“为什么性能更好”,我会说两点:
第一,少一次堆分配,少一次 allocator / malloc / free 相关开销。
第二,对象和控制块挨得更近,缓存局部性通常更好。
而且创建路径更短,实现也更容易做优化。make_shared 的说明里就明确提到它通常只做一次分配,而直接 shared_ptr(new T) 至少两次。
再往下一个很容易加分的点是异常安全。
make_shared 通常更安全,尤其是老标准语义下的“函数参数求值顺序”坑。cppreference 明确举了一个经典例子:像 f(shared_ptr<int>(new int(42)), g()) 这种写法,在旧规则下如果 new int(42) 之后、shared_ptr 控制块还没接上之前,g() 抛异常,就可能泄漏;而 make_shared<int>(42) 把“分配对象 + 建立 shared_ptr 所需内部结构”封成一个调用,安全得多。
当然,这里我会补一句更稳的话:
“在现代 C++ 里很多求值顺序问题已经比早期好很多了,但 make_shared 把对象创建和 shared ownership 建立合在一起,这条路径本身还是更整洁、更不容易写出异常泄漏代码。”
这样说既不死背旧八股,也显得你知道背景。
然后我会说,make_shared 也不是总该用,它有几个不太适合的场景。
第一个,大对象,而且弱引用会长期存在。
因为 make_shared 是把对象和控制块放在同一块内存里,所以当最后一个 shared_ptr 没了时,对象会析构,但只要还有 weak_ptr 活着,那整块内存还不能真正释放,大对象占的那部分空间也得一起等到弱引用清零。cppreference 也专门提醒了这点:如果 sizeof(T) 很大,而控制块被弱引用长期挂着,这可能不理想。
第二个,需要自定义删除器的时候。
shared_ptr<T>(ptr, deleter) 可以带自定义删除器,但 make_shared 这条接口本身不让你传 custom deleter。这个限制 cppreference 也明确写了。
第三个,你需要依赖某种特殊分配行为或者类专属 operator new 行为。
make_shared 的说明里提到它使用 ::new 进行对象构造,因此如果你类上对分配有特殊定制,和直接 new T 的行为可能不同。
第四个,构造函数访问权限问题。
shared_ptr<T>(new T(...)) 在当前作用域里如果能访问某个非公有构造函数,是可以成立的;但 make_shared<T>(...) 需要所选构造函数对它可访问,这一点也被 cppreference 单独列出来了。
你提到“需要把裸指针交给特殊接口”,这个我会这样回答:
如果某个老接口强依赖“你先自己 new,再把裸指针交给它接管”这种模型,那 make_shared 不方便,因为它不会把“单独裸分配出来的对象”交给你;它是把对象埋在控制块那块联合分配的内存里的。这里不能说绝对不能配合裸指针接口,但一般这种场景更容易走 new + shared_ptr 构造,或者干脆改用 unique_ptr/自定义生命周期方案。这个点更偏工程经验判断,而不是标准硬规则。其底层依据仍然是 make_shared 的单块分配模型。
然后是 enable_shared_from_this,这个很爱追问。
它的工作原理,本质上是对象内部藏了一个 weak_this。当某个 shared_ptr 第一次正确接管这个对象时,如果发现这个对象继承了 enable_shared_from_this,构造过程会把对象内部那个 weak_this 绑定到当前控制块上。之后你再调用 shared_from_this(),本质就是从这个 weak_this 提升出一个新的 shared_ptr,从而和原来的那一批 shared_ptr 共享同一个控制块。cppreference 在 make_shared 页面上把这段启用 shared_from_this 的逻辑都写出来了。
所以为什么不能在构造函数里乱用 shared_from_this()?
因为对象构造函数执行的时候,往往还没有任何 shared_ptr 完成对这个对象的接管,也就是那个内部 weak_this 还没被正确绑定到控制块。这时候去 shared_from_this(),要么失败,要么行为不符合预期。cppreference 给出的说明也能看出来,weak_this 的赋值是在 shared_ptr 构造接管对象时做的,而不是在对象普通构造函数一开始就天然可用。
再往下说到线程安全,这是面试里很容易答偏的点。
shared_ptr 的引用计数之所以要做成线程可协调的,就是因为多个线程可能同时复制、销毁不同的 shared_ptr 副本,而这些副本共享同一个控制块。如果计数不是线程安全维护的,就会出现把对象多删一次或者少删一次的问题。cppreference 明确说了:不同线程可以同时操作不同的 shared_ptr 对象,即使它们共享同一个控制块,也是安全的;这依赖的就是控制块计数机制。
但这里要注意,shared_ptr 的线程安全不是“什么都安全”。
正确说法是:
不同线程同时读写的是不同的 shared_ptr 实例,哪怕这些实例共享同一对象,成员函数调用通常没问题。
但如果多个线程同时去操作同一个 shared_ptr 变量对象本身,并且其中有人做了非 const 操作,比如 reset、赋值,那就是数据竞争,要额外同步,或者用 std::atomic<std::shared_ptr>。cppreference 这一条写得很明确。
而且再补一句更像有经验的人会说的话:
“shared_ptr 的线程安全主要是所有权管理这一层;它不保证所指对象本身线程安全。多个线程拿着各自的 shared_ptr 去同时改对象内容,该加锁还是得加锁。”
这个结论虽然很常识,但也是由 cppreference 那条“安全的是 shared ownership bookkeeping,不是你对象内部业务状态”延伸出来的。
收尾
“shared_ptr<T>(new T) 和 make_shared<T> 的核心区别,不只是少一次分配,而是控制块和对象的布局不同。前者通常对象和控制块分开分配,灵活一些,适合自定义删除器、特殊接管场景;后者通常把对象和控制块做成单块分配,性能更好、局部性更好、创建时也更不容易写出异常泄漏代码。但如果对象很大而弱引用又活得久,或者你需要自定义删除器、特殊分配行为,那就不一定适合 make_shared。”