文章目录

C++智能指针std::shared_ptr控制块内存布局分析

发布于 2026-04-26 15:16:39 · 浏览 6 次 · 评论 0 条

C++智能指针std::shared_ptr控制块内存布局分析

std::shared_ptr 的核心在于引用计数机制,而这个机制的物理载体就是“控制块”。深入理解控制块的内存布局,有助于优化程序性能并避免潜在的内存问题。


1. 理解控制块的基本构成

控制块并不是存储在 std::shared_ptr 对象内部的,而是动态分配在堆上。它的主要职责是管理资源的生命周期。一个标准的控制块通常包含以下核心成员:

  • 引用计数:记录当前有多少个 std::shared_ptr 指向该资源。当该计数归零时,资源被销毁。
  • 弱引用计数:记录有多少个 std::weak_ptr 观察该资源。当该计数归零时,控制块本身被销毁。
  • 删除器:用于释放资源的函数或函数对象(默认是 delete)。
  • 分配器:用于分配控制块本身内存的分配器(默认是 new)。

2. 分析默认构造下的内存布局

当我们使用 new 关键字构造一个 std::shared_ptr 时,内存通常分为两块独立的区域:

  1. 对象内存:存储实际管理的对象数据。
  2. 控制块内存:存储上述的引用计数和删除器等信息。

在这种布局下,std::shared_ptr 自身通常只占用两个指针的大小(具体取决于实现,如 GCC 和 MSVC):

  • 指向对象的指针。
  • 指向控制块的指针。

以下 Mermaid 图示展示了当两个 std::shared_ptr 共享同一个对象时的内存关系:

graph LR subgraph "Stack Area" SP1["shared_ptr p1\n(ptr_to_obj, ptr_to_ctrl)"] SP2["shared_ptr p2\n(ptr_to_obj, ptr_to_ctrl)"] end subgraph "Heap Area" OBJ["Managed Object\n(Data members)"] CTRL["Control Block\n(use_count, weak_count,\ndeleter, allocator)"] end SP1 -->|"points to"| OBJ SP1 -->|"manages"| CTRL SP2 -->|"points to"| OBJ SP2 -->|"manages"| CTRL

观察上图逻辑
p1p2 都直接指向 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 的内存布局:

graph LR subgraph "Stack Area" SP["shared_ptr\n(ptr_to_obj, ptr_to_ctrl)"] end subgraph "Heap Area (Single Block)" CB_CTRL["Control Block\n(use_count, weak_count)"] CB_DATA["Managed Object\n(Data members)"] end CB_CTRL -- "Physically adjacent to" --> CB_DATA SP -->|"points to data part"| CB_DATA SP -.->|"internally links to"| CB_CTRL

计算内存公式
假设控制块大小为 $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 使用。由于控制块和对象在同一块内存中,系统必须等到控制块也被销毁(即弱引用计数也归零)后,才能一次性释放整块内存。

操作建议

  1. 如果对象非常大,且生命周期由 std::weak_ptr 严格控制(例如缓存系统),建议使用 new 构造,以便对象内存能被及时回收。
  2. 对于小对象或大多数常规场景,优先使用 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;
}

评论 (0)

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

扫一扫,手机查看

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