文章目录

C++ std::shared_ptr 控制块的内存布局与开销分析

发布于 2026-05-28 04:19:01 · 浏览 33 次 · 评论 0 条

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 图直观展示典型的控制块结构(以默认删除器和默认分配器为例):

graph TD CB["Control Block (堆内存)"] CB --> RC["引用计数 (use_count) \n size: sizeof(atomic) 或 atomic"] CB --> WC["弱计数 (weak_count) \n size: sizeof(atomic)"] CB --> VP["虚函数表指针 (vptr) \n 仅当有自定义删除器/分配器时存在 \n size: sizeof(void*)"] CB --> DT["删除器对象 (deleter) \n size: 取决于类型"] CB --> AL["分配器对象 (allocator) \n size: 取决于类型"]

注意:use_countweak_count 通常使用 std::atomic<long> 实现,保证多线程安全。在 64 位平台上,每个原子变量占 8 字节。


3. 两种构造方式的内存差异

3.1 使用 new + std::shared_ptr<T>(new T(...))

std::shared_ptr<Widget> sp(new Widget);

此方式会进行两次内存分配

  1. 在堆上分配 Widget 对象。
  2. 在堆上单独分配控制块。

此时内存布局:

堆内存:     [    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) = 8sizeof(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_countstd::atomicfetch_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++ 程序。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文