【底层机制】std::shared_ptr解决的痛点?是什么?如何实现?如何正确用?

std::shared_ptr是现代C++资源管理工具箱中另一件至关重要的工具,但其设计哲学和适用场景与 std::unique_ptr 截然不同。


1. 解决了什么痛点? (The Problem)

std::unique_ptr 完美解决了独占所有权的问题,但现实世界并非总是独占的。很多场景需要多个实体共享访问同一份资源,且资源的生命周期需要持续到最后一个使用者结束使用为止

std::shared_ptr 出现之前,实现这种"共享所有权"是极其棘手且容易出错的:

  1. 手动引用计数 (Manual Reference Counting) :开发者需要手动维护一个计数器,在每次有新的指针指向资源时递增,在每次使用结束时递减,并在计数器归零时 delete 资源。这个过程繁琐且极易出错(漏增、漏减)。
  2. 所有权不明确:和裸指针一样,很难知道到底有多少个共享者,以及谁应该是最后一个负责释放的人。
  3. 异常不安全:手动管理引用计数在异常发生时很难保证正确性。

std::shared_ptr 的核心价值在于:它通过自动化的引用计数技术,安全、高效地实现了资源的共享所有权模型,将开发者从手动管理共享资源生命周期的泥潭中彻底解放出来。


2. 是什么? (What is it?)

std::shared_ptr 是一个智能指针模板 ,它对其所指向的对象采用共享所有权(shared ownership) 模型。

"共享"意味着:

  • 多个 shared_ptr 可以同时"拥有"(指向)同一个对象。
  • 系统内部通过引用计数(reference counting) 来跟踪该对象的拥有者数量。
  • 当最后一个指向该对象的 shared_ptr 被销毁或重置时(即引用计数降为0),对象才会被自动销毁,其内存才会被释放。

它通常用于表示"如果还有人在用,它就活着;没人在用了,它就自动消失"的语义。


3. 内部是如何实现的? (Implementation)

std::shared_ptr 的实现比 unique_ptr 复杂,因为它需要管理两个实体:

  1. 指向的对象(Managed Object)。
  2. 控制块(Control Block),其中包含:
    • 引用计数器(Use Count) :记录有多少个 shared_ptr 正拥有该对象。
    • 弱引用计数器(Weak Count) :记录有多少个 weak_ptr 在观察该对象(稍后解释)。
    • 删除器(Deleter):通常是类型擦除(type-erased)的,用于定制销毁逻辑。
    • 分配器(Allocator):用于分配控制块本身,通常不需要关心。

其内存布局通常如下图所示:

cpp 复制代码
// 简化的 std::shared_ptr 实现概念
template<typename T>
class shared_ptr {
private:
    T* ptr;                 // 指向托管对象的指针
    ControlBlock* control_block; // 指向控制块的指针

public:
    // 构造函数 (通过 std::make_shared 创建是最高效的方式)
    template<typename... Args>
    explicit shared_ptr(Args&&... args) {
        // make_shared 会一次性分配内存,同时存放对象和控制块
        control_block = new ControlBlock();
        ptr = new (control_block->object_storage) T(std::forward<Args>(args)...);
        control_block->use_count = 1;
    }

    // 拷贝构造函数:共享所有权,引用计数+1
    shared_ptr(const shared_ptr& other) noexcept : ptr(other.ptr), control_block(other.control_block) {
        if (control_block) {
            ++control_block->use_count;
        }
    }

    // 析构函数:引用计数-1,若为0则销毁对象和control_block
    ~shared_ptr() {
        if (control_block) {
            --control_block->use_count;
            if (control_block->use_count == 0) {
                // 1. 调用析构函数销毁对象
                ptr->~T();
                // 2. 如果弱引用计数也为0,则销毁控制块
                if (control_block->weak_count == 0) {
                    delete control_block;
                }
            }
        }
    }

    // ... 移动构造、赋值运算符等其他成员
};

关键实现要点:

  • 引用计数 (Reference Counting):核心机制。拷贝时递增,析构时递减,减到零则销毁对象。
  • 控制块 (Control Block) :所有共享同一对象的 shared_ptr 都指向同一个控制块,这是它们协同工作的基础。
  • 原子操作 (Atomic Operations) :引用计数的增减必须是原子操作,以保证在多线程环境下是线程安全的。注意:这保证的是计数本身的安全,并不保证托管对象本身是线程安全的!
  • std::make_shared 优化std::make_shared<T>(...) 通常会进行一次单一的内存分配,来同时存储对象本身和控制块。这提高了性能(减少一次分配)和局部性(对象和控制块在一起)。这是强烈推荐的创建方式。
  • 类型擦除的删除器 :与 std::unique_ptr 将删除器作为模板参数不同,std::shared_ptr 的删除器是控制块的一部分,通过类型擦除技术存储。这意味着两个拥有不同删除器的 shared_ptr<T> 仍然是相同类型,可以放在同一个容器里。

4. 应该如何正确使用? (Best Practices)

基本用法

cpp 复制代码
#include <memory>

// 1. 创建:始终优先使用 std::make_shared
auto sp1 = std::make_shared<MyClass>(arg1, arg2); // 高效且安全

// 2. 拷贝:共享所有权,引用计数增加
auto sp2 = sp1; // sp1 和 sp2 现在共享同一对象,use_count == 2

// 3. 像普通指针一样使用
sp1->doSomething();
(*sp2).doAnotherThing();

if (sp1) { // 判断是否为空
    // ...
}

// 4. 手动放弃所有权 (不会影响引用计数)
sp1.reset();    // sp1 变为空,原对象的 use_count 减为 1
sp2.reset();    // sp2 变为空,use_count 减为 0,对象被销毁

std::weak_ptr 配合解决循环引用

这是 shared_ptr 最重要的合作伙伴,用于解决其最著名的陷阱------循环引用(Cyclic Reference)

问题场景:

cpp 复制代码
struct B;
struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed\n"; }
};
struct B {
    std::shared_ptr<A> a_ptr; // 循环引用!
    ~B() { std::cout << "B destroyed\n"; }
};

void leak() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b; // a.use_count -> 1, b.use_count -> 2
    b->a_ptr = a; // a.use_count -> 2, b.use_count -> 2
}
// 函数结束,a 和 b 析构,use_count 都从 2 减为 1。
// 因为引用计数永不为0,A 和 B 的对象永远无法被销毁 -> 内存泄漏。

解决方案:使用 std::weak_ptr weak_ptr 是一种"弱引用",它指向一个由 shared_ptr 管理的对象,但不增加其引用计数

cpp 复制代码
struct B;
struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed\n"; }
};
struct B {
    std::weak_ptr<A> a_ptr; // 将其中之一改为 weak_ptr
    ~B() { std::cout << "B destroyed\n"; }
};

void no_leak() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b_ptr = b; // b.use_count -> 2
    b->a_ptr = a; // a.use_count 仍然为 1! (weak_ptr 不增加计数)
}
// 函数结束:
// 1. 'a' 析构,use_count 从 1 减为 0 -> A 对象被销毁。
// 2. 'b' 析构,use_count 从 2 减为 1 -> 但因为 A 对象已死,b->a_ptr 失效,且最终 b.use_count 减为 0 -> B 对象被销毁。

weak_ptr 的使用: 它不能直接访问资源,必须通过 .lock() 方法尝试获取一个临时的 shared_ptr

cpp 复制代码
if (auto temp_shared_ptr = weak_ptr.lock()) {
    // 获取成功,说明对象还活着,可以安全使用 temp_shared_ptr
    temp_shared_ptr->doSomething();
} else {
    // 对象已经被释放了
}

重要准则与陷阱(Dos and Don'ts)

  • DO : 优先使用 std::make_shared。它更快、更安全(异常安全)、内存利用率更高。

  • DO : 默认使用 std::unique_ptr,只在确需共享所有权时才使用 std::shared_ptr共享所有权是有成本的(控制块、原子操作)。

  • DO : 在可能存在循环引用的地方,使用 std::weak_ptr 来"打破"循环。

  • DON'T : 不要从裸指针创建多个独立的 shared_ptr

    cpp 复制代码
    MyClass* raw_ptr = new MyClass();
    std::shared_ptr<MyClass> sp1(raw_ptr);
    std::shared_ptr<MyClass> sp2(raw_ptr); // 灾难!两个独立的控制块会双重删除 raw_ptr。

    如果你必须从裸指针构造,请直接在一行代码中完成:

    cpp 复制代码
    std::shared_ptr<MyClass> sp1(new MyClass()); // OK,但不如 make_shared
  • DON'T : 避免传递 shared_ptr 本身作为函数参数,除非函数意图共享所有权(即需要拷贝一份) 。如果函数只是需要使用对象,传递裸指针 (ptr.get()) 或引用即可。不必要的拷贝会增加原子操作的开销。

    • 要共享所有权void func(std::shared_ptr<MyClass> sp) (值传递)
    • 只使用不拥有void func(MyClass* ptr)void func(MyClass& ref)
  • DON'T : 不要假设 shared_ptr 能保证托管对象是线程安全的。引用计数是线程安全的,但对象本身的 doSomething() 方法是否需要加锁,取决于对象自身的实现。

总结对比

特性 std::shared_ptr std::unique_ptr
所有权模型 共享所有权 独占所有权
拷贝语义 支持(引用计数+1) 禁止
移动语义 支持(所有权转移,计数不变) 支持(所有权转移)
开销 较大(控制块、原子操作) 极小(几乎为零)
核心机制 引用计数 独占性(移动语义)
首选创建方式 std::make_shared std::make_unique
典型用途 共享资源、缓存、观察者模式、复杂关系图 独占资源、工厂模式、实现 PImpl 惯用法、函数内部资源管理

核心思想std::shared_ptr 提供了了一种强大而方便的共享资源生命周期管理方式,但"能力越大,责任越大"。你必须清醒地意识到其性能开销和循环引用的陷阱,并学会用 std::weak_ptr 与之配合。在绝大多数情况下,std::unique_ptr 应该是你的默认选择,std::shared_ptr 则是在共享所有权不可避免时的终极解决方案。


C++底层机制推荐阅读
【C++基础知识】深入剖析C和C++在内存分配上的区别
【底层机制】【C++】vector 为什么等到满了才扩容而不是提前扩容?
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
【底层机制】剖析 brk 和 sbrk的底层原理
【底层机制】为什么栈的内存分配比堆快?
【底层机制】右值引用是什么?为什么要引入右值引用?
【底层机制】auto 关键字的底层实现机制
【底层机制】std::unordered_map 扩容机制
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
【底层机制】【编译器优化】RVO--返回值优化
【基础知识】仿函数与匿名函数对比
【底层机制】【C++】std::move 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】emplace_back 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】【编译器优化】循环优化--为什么引入?怎么实现的?流程啥样?
【底层机制】std::string 解决的痛点?是什么?怎么实现的?怎么正确用?
【底层机制】std::unique_ptr 解决的痛点?是什么?如何实现?怎么正确使用?


关注公众号,获取更多底层机制/ 算法通俗讲解干货!

相关推荐
Java中文社群1 小时前
有点意思!Java8后最有用新特性排行榜!
java·后端·面试
moisture2 小时前
CUDA常规知识点
后端·面试
zcychong2 小时前
ArrayMap、SparseArray和HashMap有什么区别?该如何选择?
android·面试
小高0072 小时前
🌐ES6 这 8 个隐藏外挂,知道 3 个算我输!
前端·javascript·面试
甜瓜看代码3 小时前
Android事件分发机制
面试
李重楼4 小时前
前端性能优化之 HTTP/2 多路复用
前端·面试
lecepin5 小时前
AI Coding 资讯 2025-09-17
前端·javascript·面试
闰五月7 小时前
JavaScript作用域与作用域链详解
前端·面试
顾林海7 小时前
Android编译插桩之AspectJ:让代码像特工一样悄悄干活
android·面试·性能优化