C++ std::shared_ptr 控制块的内存布局与开销分析
std::shared_ptr 是 C++ 智能指针家族中最常用的成员之一,它通过引用计数实现自动生命周期管理。但许多开发者只知其用法,却忽略了其背后控制块(Control Block) 的精确内存布局与性能开销。本文将从内存分配、成员组成、不同构造方式的差异三个角度,拆解控制块的全部细节,并给出实际优化建议。
1. 理解控制块:什么是它?
当一个 shared_ptr 对象诞生时,它不仅持有一个指向被管理对象的指针,还会在堆上单独分配一个控制块(除非使用 std::make_shared 将两者合并)。控制块记录了以下关键信息:
- 引用计数 (
use_count):当前shared_ptr实例的个数。 - 弱计数 (
weak_count):当前weak_ptr实例的个数(加上一个“哨兵”值,用于正确触发删除)。 - 删除器 (
deleter):用于销毁对象时的自定义清理函数(默认是delete)。 - 分配器 (
allocator):用于控制块自身内存分配的定制器(可选)。 - 虚函数表指针(可选):当存在自定义删除器或分配器时,控制块内部会通过虚函数实现类型擦除,从而引入 vptr。
2. 控制块的内存布局
用 Mermaid 图直观展示典型的控制块结构(以默认删除器和默认分配器为例):
注意:
use_count和weak_count通常使用std::atomic<long>实现,保证多线程安全。在 64 位平台上,每个原子变量占 8 字节。
3. 两种构造方式的内存差异
3.1 使用 new + std::shared_ptr<T>(new T(...))
std::shared_ptr<Widget> sp(new Widget);
此方式会进行两次内存分配:
- 在堆上分配
Widget对象。 - 在堆上单独分配控制块。
此时内存布局:
堆内存: [ Widget实例 ]
+-------------------+
| 控制块 |
| - use_count |
| - weak_count |
| - (vptr? 无) |
| - 默认删除器 |
+-------------------+
Widget 对象和控制块在不同的连续内存区域,彼此独立。
3.2 使用 std::make_shared<T>(args...)
auto sp = std::make_shared<Widget>(args...);
此方式只进行一次内存分配,将 Widget 对象与控制块存放在同一块连续内存中:
堆内存: [ 控制块 (部分成员) ][ Widget实例 ]
实际布局取决于标准库实现,但常见做法是:
+-------------------------------+
| use_count (8B) |
| weak_count (8B) |
| vptr (0B, 默认无) |
| 删除器 (0B, 默认是 trivial) |
| Widget 实例 (sizeof(Widget)) |
+-------------------------------+
控制块位于内存起始处,对象紧随其后。这样做的好处是减少一次内存分配,且更好的局部性(控制块和对象在同一个缓存行附近)。
4. 空间开销量化
4.1 基础控制块大小(无自定义删除器/分配器)
假设采用 64 位平台,sizeof(long) = 8,sizeof(void*) = 8。控制块基础成员:
| 成员 | 大小(字节) |
|---|---|
use_count (std::atomic<long>) |
8 |
weak_count (std::atomic<long>) |
8 |
| vptr | 0(无虚函数) |
| 默认删除器(无状态) | 0(空类,特殊优化) |
| 默认分配器(无状态) | 0 |
| 合计 | 16 |
但控制块自身可能还需要一些内存对齐开销,实际可能为 16 或 24 字节(考虑后续对象的对齐要求)。
4.2 添加自定义删除器
auto deleter = [](Widget* p) { /* 清理日志 */ delete p; };
std::shared_ptr<Widget> sp(new Widget, deleter);
此时控制块需要存储删除器的副本(或指针)。如果删除器是无状态 lambda(空捕获),则大小为 1 字节(最小对象大小),但经过 std::function 或类型擦除后,实际存储可能为:
| 成员 | 大小(字节) |
|---|---|
| use_count | 8 |
| weak_count | 8 |
| vptr | 8(用于虚函数调用删除器) |
| 删除器对象 | 占用额外空间(取决于删除器类型,例如函数指针 8B,lambda 可能 1B 或更多) |
| 小计(vptr + 删除器) | ≥ 16 |
| 合计 | ~32 或更大 |
4.3 添加自定义分配器
同样,分配器也会被存储在控制块中,引入 vptr 和分配器对象副本。
4.4 std::make_shared 的额外空间优势
当使用 make_shared 时,控制块和对象在同一个动态内存块中。但注意:即使对象生命周期已经结束(use_count 降为 0),只要还有 weak_ptr 存在(weak_count > 0),控制块所在的那块内存就不能释放。因此,如果对象很大,且 weak_ptr 生命周期远超 shared_ptr,则会造成内存延迟释放,反而浪费空间。
5. 时间开销分析
5.1 构造开销
- 两次分配 vs 一次分配:
new+ 构造shared_ptr需要两次malloc/operator new调用,而make_shared只需要一次。后者在有大量短生命周期对象时具有明显优势。 - 删除器/分配器类型擦除:自定义删除器会引入 vptr 查找,调用删除器时通过虚函数间接跳转,略微增加延迟。
5.2 拷贝/赋值开销
每次拷贝 shared_ptr 时,必须原子递增 use_count。std::atomic 的 fetch_add 在弱一致性模型中通常使用 lock 前缀或 std::memory_order_relaxed,但即使是 relaxed 也有一定开销。在高并发场景下,频繁的原子操作可能成为瓶颈。
类似地,weak_ptr 的拷贝涉及 weak_count 的原子操作。
5.3 析构开销
当一个 shared_ptr 析构时,原子递减 use_count。如果递减后为 0,则启动删除器销毁对象(如果是 make_shared,对象可能还没有释放,但控制块中存储的对象内存可以调用析构函数)。随后,若 weak_count 也为 0,则释放控制块内存。
如果使用 make_shared,控制块和对象内存同时释放;若单独 new,则分别释放两次。
6. 弱引用与额外计数
weak_ptr 不增加对象的引用计数,但会增加控制块的弱计数。设计弱计数的目的:当 use_count 变为 0 时,对象已经销毁(对于非 make_shared 情况,内存立即释放),但控制块还需保留,直到所有 weak_ptr 也销毁后,才能释放控制块内存。因此弱计数实际上是控制块自身的引用计数。
weak_count 的初始值通常比实际 weak_ptr 数量多 1(防止对象已销毁但仍有 pending 的 lock() 操作)。具体实现细节可以参考 libstdc++ 或 libc++ 源码。
7. 实际优化建议
7.1 默认使用 std::make_shared
- 优点:减少一次内存分配,提高局部性,开销更低。
- 适用场景:绝大多数情况,特别是对象大小较小或中等的场景。
7.2 何时避免 make_shared
- 对象极大 + 存在长期
weak_ptr:前文提到的内存延迟释放会导致极长一段时间内大块内存无法回收。此时应使用new+shared_ptr,让对象内存随use_count归零立刻释放,仅保留小控制块供weak_ptr使用。 - 需要自定义删除器或分配器:
make_shared不支持自定义删除器(但可通过allocate_shared使用分配器)。
7.3 尽量使用无状态 lambda 删除器
无状态 lambda 可以作为函数指针传递,从而避免虚函数和额外存储开销。但需注意:当作为删除器传给 shared_ptr 时,标准库能否优化取决于实现。通常,如果 lambda 不捕获任何东西,它可以被降级为函数指针,控制块中存储的只是一个函数指针(8 字节),而不需要 vptr。
7.4 避免不需要的 weak_ptr 传递
每个 weak_ptr 的创建和销毁都会更新弱计数,尽可能使用引用传递或 shared_ptr 本身,而不是频繁创建临时 weak_ptr。
8. 小结(此处无总结语,直接结束)
以上内容涵盖了 std::shared_ptr 控制块的内存布局、两种构造方式的差异、空间与时间开销,以及实际优化建议。掌握这些细节,有助于编写更高效、可预测的 C++ 程序。

暂无评论,快来抢沙发吧!