:从shared_ptr理解引用计数与共享所有权)
一、为什么有了unique_ptr还需要shared_ptr上一篇手写的unique_ptr强调一个资源 ↓ 只能有一个所有者例如MyUniquePtrint p1(new int(10));此时p1 ↓ 10不能再直接MyUniquePtrint p2 p1;因为unique_ptr禁止拷贝。如果真的要把资源交给p2MyUniquePtrint p2(std::move(p1));执行的是所有权转移最终p1 → nullptr p2 → 10但是实际开发中有时候一块资源确实需要被多个对象共同使用。例如对象A ↓ Resource ↑ 对象B对象 A 和对象 B 都需要访问同一个资源。如果仍然使用独占所有权就会比较麻烦。于是就有了shared_ptr它允许多个智能指针 ↓ 共同拥有同一块资源例如std::shared_ptrint p1(new int(10)); std::shared_ptrint p2 p1;此时p1 ──┐ ├──→ 10 p2 ──┘但这又带来了一个新的问题到底应该由谁delete如果p1析构的时候直接delete ptr_;那么p2就会变成悬空指针。所以shared_ptr必须知道当前还有多少个shared_ptr 正在使用这块资源这就是引用计数 Reference Count二、引用计数到底是怎么工作的假设刚创建MySharedPtrint p1(new int(10));此时只有p1管理资源。所以引用计数 1结构可以理解成p1 ↓ ptr_ ─────────→ 10 count_ ───────→ 1现在执行MySharedPtrint p2 p1;因为多了一个共享所有者引用计数 1 → 2此时p1 ──┐ ├──→ 10 p2 ──┘ 引用计数 2如果再执行MySharedPtrint p3 p2;那么引用计数 2 → 3现在p1 ──┐ p2 ──┼──→ 10 p3 ──┘ 引用计数 3接下来假设p3离开作用域那么引用计数 3 → 2但是不能释放资源因为还有p1 p2正在使用。接着p2离开作用域引用计数2 → 1还是不能释放。最后p1离开作用域引用计数1 → 0此时已经没有任何所有者所以delete ptr_;真正释放资源。因此shared_ptr的核心逻辑可以总结成创建shared_ptr ↓ count 1 ↓ 发生拷贝 ↓ count ↓ 对象析构 ↓ count-- ↓ count 0 ? ↙ ↘ 是 否 ↓ ↓ 释放资源 不释放所以手写shared_ptr最关键的就是所有共享对象 必须共同看到同一个引用计数不能每个shared_ptr自己保存size_t count_;因为这样p1.count_ 1 p2.count_ 1二者互相不知道对方的存在。因此简化实现中可以保存size_t *count_;让多个MySharedPtr共同指向同一个引用计数结构变成p1.ptr_ ────────┐ ├──→ 10 p2.ptr_ ────────┘ p1.count_ ──────┐ ├──→ 2 p2.count_ ──────┘这就是引用计数能够共享的原因。三、拷贝构造、赋值和析构怎么写先定义一个基本结构template typename T class MySharedPtr { private: T *ptr_; // 真正管理的对象 size_t *count_; // 共享引用计数 public: explicit MySharedPtr(T *ptr nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) { } };例如MySharedPtrint p1(new int(10));此时ptr_ ↓ 10 count_ ↓ 11. 拷贝构造shared_ptr和unique_ptr最大的区别之一就是shared_ptr允许拷贝所以需要实现MySharedPtr(const MySharedPtr other) : ptr_(other.ptr_), count_(other.count_) { if (count_) (*count_); }例如MySharedPtrint p1(new int(10)); MySharedPtrint p2(p1);首先ptr_ other.ptr_;让p1 p2指向同一个对象。接着count_ other.count_;让二者共享同一个引用计数。最后(*count_);引用计数1 → 2最终p1 ──┐ ├──→ 10 p2 ──┘ count 22. 析构函数析构的时候不能直接delete ptr_;而是应该先减少引用计数因此可以把释放逻辑单独封装成void release() { if (!count_) return; --(*count_); if (*count_ 0) { delete ptr_; delete count_; } }析构函数直接~MySharedPtr() { release(); }假设count 3一个对象析构3 → 2不会释放资源。直到1 → 0才执行delete ptr_; delete count_;为什么除了delete ptr_;还需要delete count_;因为我们的引用计数本身也是new size_t(1)动态申请出来的。所以引用计数没人使用以后也需要释放。3. 拷贝赋值现在考虑MySharedPtrint p1(new int(10)); MySharedPtrint p2(new int(20)); p2 p1;赋值之前p1 → 10 count 1 p2 → 20 count 1执行p2 p1;首先不能直接p2.ptr_ p1.ptr_;因为这样20原来那块资源就没人管理了会造成内存泄漏所以应该先释放p2原来拥有的资源release();然后再接管ptr_ other.ptr_; count_ other.count_;最后(*count_);完整实现MySharedPtr operator(const MySharedPtr other) { if (this ! other) { release(); ptr_ other.ptr_; count_ other.count_; if (count_) (*count_); } return *this; }最终p1 ──┐ ├──→ 10 p2 ──┘ count 2而原来20对应的引用计数已经降为0所以被释放。这里的if (this ! other)是为了避免p1 p1;这种自赋值情况。否则可能先把自己的引用计数减掉甚至释放资源然后又去访问已经失效的数据。四、移动语义和完整MySharedPtr实现虽然shared_ptr支持拷贝但它同样支持移动例如MySharedPtrint p1(new int(10)); MySharedPtrint p2(std::move(p1));移动和拷贝最大的区别在于拷贝 ↓ 增加一个所有者 ↓ 引用计数 1而移动 ↓ 只是把当前所有权交给另一个对象 ↓ 所有者数量没有增加所以移动构造MySharedPtr(MySharedPtr other) noexcept : ptr_(other.ptr_), count_(other.count_) { other.ptr_ nullptr; other.count_ nullptr; }注意这里引用计数不用1因为p1已经不再是所有者了。原来p1 → 10 count 1移动以后p1 → nullptr p2 → 10 count 1所有者数量仍然1移动赋值同样如此MySharedPtr operator(MySharedPtr other) noexcept { if (this ! other) { release(); ptr_ other.ptr_; count_ other.count_; other.ptr_ nullptr; other.count_ nullptr; } return *this; }这里同样先release();处理自己原来管理的资源。然后接管other的资源。为了像普通指针一样使用还可以继续实现T operator*() const { return *ptr_; } T *operator-() const { return ptr_; } T *get() const { return ptr_; } size_t use_count() const { return count_ ? *count_ : 0; } explicit operator bool() const { return ptr_ ! nullptr; }完整代码如下#include cstddef #include iostream #include utility template typename T class MySharedPtr { private: T *ptr_; size_t *count_; // 当前对象不再持有资源 void release() { if (!count_) return; // 当前所有者减少一个 --(*count_); // 已经没有任何shared_ptr管理资源 if (*count_ 0) { delete ptr_; delete count_; } ptr_ nullptr; count_ nullptr; } public: // 普通构造 explicit MySharedPtr(T *ptr nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) { } // 析构 ~MySharedPtr() { release(); } // 拷贝构造增加一个共享所有者 MySharedPtr(const MySharedPtr other) : ptr_(other.ptr_), count_(other.count_) { if (count_) (*count_); } // 拷贝赋值 MySharedPtr operator(const MySharedPtr other) { if (this ! other) { release(); ptr_ other.ptr_; count_ other.count_; if (count_) (*count_); } return *this; } // 移动构造转移所有权不增加引用计数 MySharedPtr(MySharedPtr other) noexcept : ptr_(other.ptr_), count_(other.count_) { other.ptr_ nullptr; other.count_ nullptr; } // 移动赋值 MySharedPtr operator(MySharedPtr other) noexcept { if (this ! other) { release(); ptr_ other.ptr_; count_ other.count_; other.ptr_ nullptr; other.count_ nullptr; } return *this; } // 支持*p T operator*() const { return *ptr_; } // 支持p-member T *operator-() const { return ptr_; } // 获取原始指针 T *get() const { return ptr_; } // 获取当前引用计数 size_t use_count() const { return count_ ? *count_ : 0; } // 支持if(p) explicit operator bool() const { return ptr_ ! nullptr; } }; class Student { public: void show() { std::cout Student::show() std::endl; } }; int main() { MySharedPtrStudent p1(new Student); std::cout count p1.use_count() std::endl; { MySharedPtrStudent p2 p1; std::cout count p1.use_count() std::endl; p2-show(); } std::cout count p1.use_count() std::endl; return 0; }运行过程可以理解成创建p1 ↓ count 1 ↓ p2 p1 ↓ count 2 ↓ p2离开作用域 ↓ count 1 ↓ p1离开作用域 ↓ count 0 ↓ delete资源因此整个shared_ptr最核心的逻辑其实就是ptr_ 共享引用计数count_每发生一次拷贝count每失去一个所有者count--直到count 0真正释放资源。五、面试中shared_ptr最容易追问什么首先很容易被问shared_ptr和unique_ptr最大区别是什么可以回答unique_ptr表示独占所有权不允许拷贝只能通过移动语义转移所有权shared_ptr表示共享所有权可以有多个智能指针共同管理同一块资源通过引用计数决定什么时候真正释放资源。可以简单记成unique_ptr 一个资源 ↓ 一个所有者 shared_ptr 一个资源 ↓ 多个所有者 ↓ 引用计数管理生命周期第二个常见问题是为什么引用计数不能直接写成普通成员变量例如size_t count_;因为发生拷贝以后p1.count_ 1 p2.count_ 1两个对象拥有的是两个独立计数。而我们真正需要的是p1 ──┐ ├──→ 同一个count p2 ──┘所以简化实现使用size_t *count_;标准库中的shared_ptr则使用更加完整的Control Block 控制块控制块中通常不只有shared引用计数还会管理weak引用计数 删除器 分配器等信息。所以真正的std::shared_ptr可以大致理解成shared_ptr │ ├── 指向实际对象 │ └── 指向Control Block │ ├── strong count ├── weak count └── deleter等信息本文为了突出面试中的引用计数原理只使用size_t *count_;进行了简化。第三个非常常见的问题是shared_ptr的引用计数线程安全吗这里要分开说。标准库的不同shared_ptr对象如果共享同一个控制块p1 ─┐ ├→ Control Block p2 ─┘对控制块中引用计数的增减具有线程安全保证。但是shared_ptr引用计数线程安全不等于它指向的对象线程安全例如std::shared_ptrint p std::make_sharedint(0);多个线程同时(*p);仍然可能产生数据竞争。所以应该区分控制块引用计数 ↓ 有相应线程安全保证 被管理的T对象 ↓ 是否线程安全取决于T本身和访问方式而我们本文手写的size_t *count_;只是普通整数并没有做线程同步因此这个MySharedPtr本身不是线程安全版本如果想进一步处理可以考虑std::atomicsize_t维护引用计数。第四个重点问题就是shared_ptr有什么经典问题答案就是循环引用例如两个对象A ↓ shared_ptrB B ↓ shared_ptrA形成A ─────────→ B ↑ │ └────────────┘假设A的引用计数 1 B的引用计数 1即使外部的shared_ptr已经全部销毁A还被B引用 B还被A引用因此A count ! 0 B count ! 0最终两个对象都无法释放。也就是循环引用 ↓ 引用计数无法归零 ↓ 内存泄漏而解决这个问题的关键就是第三种智能指针weak_ptrweak_ptr可以观察shared_ptr管理的资源但是它不会增加强引用计数这样就可以打破shared_ptr之间的循环引用如果从面试角度总结这一篇可以直接这样回答shared_ptr用于实现共享所有权多个shared_ptr可以共同管理同一块资源。它内部需要共享一个引用计数拷贝构造和拷贝赋值时引用计数加一对象析构时引用计数减一当强引用计数变为 0 时才真正释放资源。移动操作只是转移已有所有权因此不会增加引用计数。标准库实现中引用计数通常位于独立的控制块中而shared_ptr最大的经典问题是循环引用需要使用weak_ptr解决。所以unique_ptr ↓ 独占所有权 shared_ptr ↓ 共享所有权 ↓ 引用计数 weak_ptr ↓ 弱引用 ↓ 解决循环引用这三种智能指针的关系也就逐渐串起来了。0voice · GitHub