C++智能指针std::shared_ptr控制块内存布局分析
std::shared_ptr 的核心在于引用计数机制,而这个机制的物理载体就是“控制块”。深入理解控制块的内存布局,有助于优化程序性能并避免潜在的内存问题。
1. 理解控制块的基本构成
控制块并不是存储在 std::shared_ptr 对象内部的,而是动态分配在堆上。它的主要职责是管理资源的生命周期。一个标准的控制块通常包含以下核心成员:
- 引用计数:记录当前有多少个
std::shared_ptr指向该资源。当该计数归零时,资源被销毁。 - 弱引用计数:记录有多少个
std::weak_ptr观察该资源。当该计数归零时,控制块本身被销毁。 - 删除器:用于释放资源的函数或函数对象(默认是
delete)。 - 分配器:用于分配控制块本身内存的分配器(默认是
new)。
2. 分析默认构造下的内存布局
当我们使用 new 关键字构造一个 std::shared_ptr 时,内存通常分为两块独立的区域:
- 对象内存:存储实际管理的对象数据。
- 控制块内存:存储上述的引用计数和删除器等信息。
在这种布局下,std::shared_ptr 自身通常只占用两个指针的大小(具体取决于实现,如 GCC 和 MSVC):
- 指向对象的指针。
- 指向控制块的指针。
以下 Mermaid 图示展示了当两个 std::shared_ptr 共享同一个对象时的内存关系:
观察上图逻辑:
p1 和 p2 都直接指向 Managed Object,同时它们内部都保存了指向 Control Block 的指针。无论通过哪个指针修改对象,操作的都是同一块内存。
3. 验证布局差异的代码演示
我们可以编写一段简单的 C++ 代码来查看指针地址,从而验证控制块与对象是否独立。
编写以下代码并编译:
#include <iostream>
#include <memory>
struct Test {
int data;
};
int main() {
// 创建一个 shared_ptr,使用 new 构造
std::shared_ptr<Test> p1(new Test());
// 获取对象的原始地址
Test* raw_ptr = p1.get();
// 获取控制块的地址(非标准方法,仅用于演示原理,依赖于 GCC 实现)
// 注意:实际开发中严禁使用内部函数 __get_deleter
auto ctrl_block_addr = reinterpret_cast<void*>(p1);
// 这里的 ctrl_block_addr 实际上是指针内部指向控制块的指针,
// 为了演示地址差异,我们打印对象地址和控制块内部引用计数的地址。
// (注意:直接获取控制块地址没有标准API,此处仅作逻辑示意)
// 使用 make_shared 创建
auto p2 = std::make_shared<Test>();
std::cout << "Object address (p1): " << raw_ptr << std::endl;
std::cout << "Object address (p2): " << p2.get() << std::endl;
return 0;
}
分析输出结果:
你会发现对象地址和控制块所在的内存区域是截然不同的。p1.get() 返回的是对象的地址,而 p1 内部维护的另一个指针则指向那个隐藏的控制块。
4. 掌握 std::make_shared 的内存布局优化
使用 std::make_shared 会触发一种优化:单次分配。
编译器会将“控制块”和“对象数据”分配在同一块连续内存中。通常的布局顺序为:控制块在前,对象数据在后。
这种布局不仅减少了内存分配次数(从 2 次变为 1 次),还提高了缓存命中率。
下图展示了 std::make_shared 的内存布局:
计算内存公式:
假设控制块大小为 $S_{ctrl}$,对象大小为 $S_{obj}$。使用 std::make_shared 分配的总内存块大小 $S_{total}$ 约为:
$$ S_{total} = S_{ctrl} + S_{obj} + \text{padding} $$
其中 padding 是为了内存对齐可能填充的字节。
5. 对比两种方式的特性差异
为了更直观地理解两种构造方式的区别,请参考下表。
| 特性 | 使用 new 构造 |
使用 std::make_shared 构造 |
|---|---|---|
| 内存分配次数 | 2 次(1次对象,1次控制块) | 1 次(连续内存块) |
| 内存布局 | 对象和控制块分离 | 对象紧邻控制块 |
| 构造性能 | 较慢(两次堆操作) | 较快(一次堆操作) |
| 缓存局部性 | 较差(数据分散) | 较好(数据连续) |
| 内存释放时机 | 当强引用归零,对象内存立即释放 | 即使强引用归零,只要存在弱引用,整块内存都无法释放(包括对象部分) |
6. 解决弱引用导致的内存占用问题
虽然 std::make_shared 性能更好,但它有一个副作用:对象内存的延迟释放。
当强引用计数归零时,对象被析构,但其占用的内存不会立即归还给系统,因为控制块还需要被 std::weak_ptr 使用。由于控制块和对象在同一块内存中,系统必须等到控制块也被销毁(即弱引用计数也归零)后,才能一次性释放整块内存。
操作建议:
- 如果对象非常大,且生命周期由
std::weak_ptr严格控制(例如缓存系统),建议使用new构造,以便对象内存能被及时回收。 - 对于小对象或大多数常规场景,优先使用
std::make_shared以获得性能优势。
编写如下代码验证此行为:
#include <iostream>
#include <memory>
class BigData {
public:
BigData() { std::cout << "Constructor\n"; }
~BigData() { std::cout << "Destructor\n"; }
char data[1024]; // 占用较大内存
};
int main() {
// 使用 make_shared
std::shared_ptr<BigData> sp = std::make_shared<BigData>();
std::weak_ptr<BigData> wp = sp;
// 释放 shared_ptr
sp.reset();
std::cout << "shared_ptr reset. Weak ptr still exists.\n";
// 此时 BigData 的析构函数已被调用,但其占用的 1024 字节内存仍未释放
// 因为 weak_ptr 还活着,控制块(与其在同一内存块)必须存活
// 释放 weak_ptr
wp.reset();
std::cout << "weak_ptr reset. Memory now freed.\n";
return 0;
}
暂无评论,快来抢沙发吧!