std::shared_ptr
是现代C++资源管理工具箱中另一件至关重要的工具,但其设计哲学和适用场景与 std::unique_ptr
截然不同。
1. 解决了什么痛点? (The Problem)
std::unique_ptr
完美解决了独占所有权的问题,但现实世界并非总是独占的。很多场景需要多个实体共享访问同一份资源,且资源的生命周期需要持续到最后一个使用者结束使用为止。
在 std::shared_ptr
出现之前,实现这种"共享所有权"是极其棘手且容易出错的:
- 手动引用计数 (Manual Reference Counting) :开发者需要手动维护一个计数器,在每次有新的指针指向资源时递增,在每次使用结束时递减,并在计数器归零时
delete
资源。这个过程繁琐且极易出错(漏增、漏减)。 - 所有权不明确:和裸指针一样,很难知道到底有多少个共享者,以及谁应该是最后一个负责释放的人。
- 异常不安全:手动管理引用计数在异常发生时很难保证正确性。
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
复杂,因为它需要管理两个实体:
- 指向的对象(Managed Object)。
- 控制块(Control Block),其中包含:
- 引用计数器(Use Count) :记录有多少个
shared_ptr
正拥有该对象。 - 弱引用计数器(Weak Count) :记录有多少个
weak_ptr
在观察该对象(稍后解释)。 - 删除器(Deleter):通常是类型擦除(type-erased)的,用于定制销毁逻辑。
- 分配器(Allocator):用于分配控制块本身,通常不需要关心。
- 引用计数器(Use Count) :记录有多少个
其内存布局通常如下图所示:
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
。cppMyClass* raw_ptr = new MyClass(); std::shared_ptr<MyClass> sp1(raw_ptr); std::shared_ptr<MyClass> sp2(raw_ptr); // 灾难!两个独立的控制块会双重删除 raw_ptr。
如果你必须从裸指针构造,请直接在一行代码中完成:
cppstd::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 解决的痛点?是什么?如何实现?怎么正确使用?
关注公众号,获取更多底层机制/ 算法通俗讲解干货!