一、为什么有了unique_ptr还需要shared_ptr
上一篇手写的 unique_ptr 强调:
一个资源
↓
只能有一个所有者
例如:
MyUniquePtr<int> p1(new int(10));
此时:
p1
↓
10
不能再直接:
MyUniquePtr<int> p2 = p1;
因为 unique_ptr 禁止拷贝。
如果真的要把资源交给 p2:
MyUniquePtr<int> p2(std::move(p1));
执行的是:
所有权转移
最终:
p1 → nullptr
p2 → 10
但是实际开发中,有时候一块资源确实需要被多个对象共同使用。
例如:
对象A
↓
Resource
↑
对象B
对象 A 和对象 B 都需要访问同一个资源。
如果仍然使用独占所有权,就会比较麻烦。
于是就有了:
shared_ptr
它允许:
多个智能指针
↓
共同拥有同一块资源
例如:
std::shared_ptr<int> p1(new int(10));
std::shared_ptr<int> p2 = p1;
此时:
p1 ──┐
├──→ 10
p2 ──┘
但这又带来了一个新的问题:
到底应该由谁delete?
如果:
p1
析构的时候直接:
delete ptr_;
那么:
p2
就会变成悬空指针。
所以 shared_ptr 必须知道:
当前还有多少个shared_ptr
正在使用这块资源
这就是:
引用计数
Reference Count
二、引用计数到底是怎么工作的
假设刚创建:
MySharedPtr<int> p1(new int(10));
此时只有:
p1
管理资源。
所以:
引用计数 = 1
结构可以理解成:
p1
↓
ptr_ ─────────→ 10
count_ ───────→ 1
现在执行:
MySharedPtr<int> p2 = p1;
因为多了一个共享所有者:
引用计数
1 → 2
此时:
p1 ──┐
├──→ 10
p2 ──┘
引用计数 = 2
如果再执行:
MySharedPtr<int> 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) {
}
};
例如:
MySharedPtr<int> p1(new int(10));
此时:
ptr_
↓
10
count_
↓
1
1. 拷贝构造
shared_ptr 和 unique_ptr 最大的区别之一就是:
shared_ptr允许拷贝
所以需要实现:
MySharedPtr(const MySharedPtr &other)
: ptr_(other.ptr_), count_(other.count_) {
if (count_) ++(*count_);
}
例如:
MySharedPtr<int> p1(new int(10));
MySharedPtr<int> p2(p1);
首先:
ptr_ = other.ptr_;
让:
p1
p2
指向同一个对象。
接着:
count_ = other.count_;
让二者共享同一个引用计数。
最后:
++(*count_);
引用计数:
1 → 2
最终:
p1 ──┐
├──→ 10
p2 ──┘
count = 2
2. 析构函数
析构的时候不能直接:
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. 拷贝赋值
现在考虑:
MySharedPtr<int> p1(new int(10));
MySharedPtr<int> 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 支持拷贝,但它同样支持:
移动
例如:
MySharedPtr<int> p1(new int(10));
MySharedPtr<int> 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() {
MySharedPtr<Student> p1(new Student);
std::cout << "count = " << p1.use_count() << std::endl;
{
MySharedPtr<Student> 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_ptr<int> p = std::make_shared<int>(0);
多个线程同时:
++(*p);
仍然可能产生数据竞争。
所以应该区分:
控制块引用计数
↓
有相应线程安全保证
被管理的T对象
↓
是否线程安全取决于T本身和访问方式
而我们本文手写的:
size_t *count_;
只是普通整数,并没有做线程同步,因此:
这个MySharedPtr本身不是线程安全版本
如果想进一步处理,可以考虑:
std::atomic<size_t>
维护引用计数。
第四个重点问题就是:
shared_ptr有什么经典问题?
答案就是:
循环引用
例如两个对象:
A
↓
shared_ptr<B>
B
↓
shared_ptr<A>
形成:
A ─────────→ B
↑ │
└────────────┘
假设:
A的引用计数 = 1
B的引用计数 = 1
即使外部的 shared_ptr 已经全部销毁:
A还被B引用
B还被A引用
因此:
A count != 0
B count != 0
最终两个对象都无法释放。
也就是:
循环引用
↓
引用计数无法归零
↓
内存泄漏
而解决这个问题的关键,就是第三种智能指针:
weak_ptr
weak_ptr 可以观察:
shared_ptr管理的资源
但是它:
不会增加强引用计数
这样就可以打破:
shared_ptr之间的循环引用
如果从面试角度总结这一篇,可以直接这样回答:
shared_ptr用于实现共享所有权,多个shared_ptr可以共同管理同一块资源。它内部需要共享一个引用计数,拷贝构造和拷贝赋值时引用计数加一,对象析构时引用计数减一,当强引用计数变为 0 时才真正释放资源。移动操作只是转移已有所有权,因此不会增加引用计数。标准库实现中引用计数通常位于独立的控制块中,而shared_ptr最大的经典问题是循环引用,需要使用weak_ptr解决。
所以:
unique_ptr
↓
独占所有权
shared_ptr
↓
共享所有权
↓
引用计数
weak_ptr
↓
弱引用
↓
解决循环引用
这三种智能指针的关系也就逐渐串起来了。