std::unique_ptr 把「独占所有权」钉死在类型上,但现实里经常需要多个对象同时引用同一份资源 :缓存、父子节点、观察者列表......这时 std::shared_ptr(共享所有权指针,shared pointer)登场。它用引用计数(reference count)记录「现在有几个人在用」,最后一个离开时才释放。这篇把控制块里到底存了什么、它比裸指针贵在哪、以及什么时候不该用它一次讲清。
1. 引子:unique_ptr 不够用的那一刻
设想一个节点同时被两个容器引用。用 unique_ptr 做不到------它禁止拷贝。于是有人退回裸指针,又掉回「谁负责释放」的坑;或者写 std::shared_ptr<Node>(new Node) 硬扛,却不清楚这一下多花了什么。共享所有权正是 shared_ptr 要解决的问题:
2. 控制块(control block)里存了什么
shared_ptr 之所以「智能」,是因为它背后有一个独立分配的控制块(control block) 。控制块不在 shared_ptr 对象内部,而是在堆上,所有指向同一对象的 shared_ptr 共享它:
text
栈(局部变量) shared_ptr 对象(16B) 堆
┌──────────┐ ┌───────────────────┐ ┌──────────────────────────┐
│ sp │ ----> │ 对象指针 ptr ───────────────> │ 被管理的对象 T │
└──────────┘ │ 控制块指针 ctrl ───────────> │ ┌──────────────────────┐ │
└───────────────────┘ │ │ 控制块 control block │ │
│ │ 强引用计数 shared │ │
│ │ 弱引用计数 weak │ │
│ │ 删除器 deleter │ │
│ │ 分配器 allocator │ │
│ └──────────────────────┘ │
└──────────────────────────┘
控制块里至少存四样东西:强引用计数(shared count) ------决定对象何时析构;弱引用计数(weak count) ------决定控制块本身何时释放;删除器(deleter) ------如何释放对象;分配器(allocator) ------如何释放整块内存。注意 shared_ptr 对象内部有两个 指针:一个指向对象 T,一个指向控制块。
3. 实测:16 字节、use_count、拷贝与 reset
use_count() 返回当前强引用计数;拷贝 shared_ptr 会把强引用计数原子地 +1;reset() 放弃自己的那份引用并减 1。下面实测三者,并顺带确认 shared_ptr 占 16 字节(两个指针):
cpp
// sp_basic.cpp --- 编译: g++ -std=c++17 -Wall -O2 sp_basic.cpp -o sp_basic
#include <iostream>
#include <memory>
int main() {
auto p = std::make_shared<int>(42);
std::cout << "value = " << *p << '\n';
std::cout << "use_count = " << p.use_count() << '\n';
std::cout << "sizeof(shared_ptr) = " << sizeof(p) << '\n';
std::cout << "sizeof(int*) = " << sizeof(int*) << '\n';
auto q = p; // 拷贝 = 共享所有权
std::cout << "after copy use_count = " << p.use_count() << '\n';
std::cout << "same object? " << (p.get() == q.get()) << '\n';
q.reset(); // q 放弃引用
std::cout << "after q.reset use_count = " << p.use_count() << '\n';
return 0;
}
text
value = 42
use_count = 1
sizeof(shared_ptr) = 16
sizeof(int*) = 8
after copy use_count = 2
same object? 1
after q.reset use_count = 1
同样是「指向一个 int」,shared_ptr 要 16 字节,裸指针只要 8 字节------多出来的那个指针就是控制块指针。
4. 它到底贵在哪:三笔开销
用 shared_ptr 不是零成本的,代价可以拆成三笔:
| 开销 | 具体表现 | 什么时候疼 |
|---|---|---|
| 体积 | shared_ptr 是 2 个指针(16B),unique_ptr 是 1 个(8B) |
大量指针存进容器、作为参数满天飞 |
| 分配次数 | 构造函数要为控制块再做一次堆分配(shared_ptr<T>(new T) 共 2 次) |
高频创建/销毁,分配器压力大 |
| 原子操作 | 每次拷贝/析构都对强计数做原子自增/自减 | 单线程高频拷贝,白付内存栅栏的代价 |
解引用本身并不额外多一次间接寻址 ------这一点常被说错。shared_ptr::operator* 读的是对象内部那个对象指针,和 unique_ptr 一样是一次解引用;真正需要穿过控制块的,是判存活、读计数、拷贝和析构这些操作。
官方文档:std::shared_ptr::operator* --- cppreference
想亲眼看汇编差异:Compiler Explorer(godbolt) 把两种指针的解引用放一起对比最直观。
5. 「两个指针分开」的铁证:别名构造
如果 shared_ptr 只有一个指针,下面这段就不可能工作:inner 只指向 Buffer 的数组字段(不是对象头部),却依然和 owner 共享同一个控制块、让对象保持存活。这就是别名构造函数(aliasing constructor):
cpp
// sp_alias.cpp --- 编译: g++ -std=c++17 -Wall -O2 sp_alias.cpp -o sp_alias
#include <iostream>
#include <memory>
struct Buffer {
int header = 7;
int payload[4] = {1, 2, 3, 4};
};
int main() {
auto owner = std::make_shared<Buffer>();
// 别名构造:inner 与 owner 共享同一个控制块,但对象指针指向 payload
std::shared_ptr<int> inner(owner, owner->payload);
std::cout << "owner.use_count = " << owner.use_count() << '\n';
std::cout << "inner.use_count = " << inner.use_count() << '\n';
std::cout << "inner points to payload? " << (inner.get() == owner->payload) << '\n';
std::cout << "*inner = " << *inner << '\n';
std::cout << "sizeof(inner) = " << sizeof(inner) << '\n';
return 0;
}
text
owner.use_count = 2
inner.use_count = 2
inner points to payload? 1
*inner = 1
sizeof(inner) = 16
inner 的计数跟着 owner 一起变(都是 2),但 *inner 读到的是 payload------对象指针和控制块指针确实是两个独立的值,缺一不可。
6. 删除器真的存在控制块里
控制块存删除器,意味着同一个类型 shared_ptr<Widget> 可以配不同删除器,而类型不变。下面用一个自定义删除器证明它确实被调用:
cpp
// sp_deleter.cpp --- 编译: g++ -std=c++17 -Wall -O2 sp_deleter.cpp -o sp_deleter
#include <iostream>
#include <memory>
struct Widget {
Widget() { std::cout << "Widget()\n"; }
~Widget() { std::cout << "~Widget()\n"; }
};
int main() {
{
// 这里为展示「删除器存于控制块」才手写裸 new;真实项目请用 make_shared
std::shared_ptr<Widget> w(new Widget, [](Widget* p) {
std::cout << "custom deleter runs\n";
delete p;
});
} // 离开作用域时,控制块里的删除器被调用
std::cout << "scope ended\n";
return 0;
}
text
Widget()
custom deleter runs
~Widget()
scope ended
输出的顺序印证了机制:对象析构由控制块中的自定义删除器触发,~Widget() 在 custom deleter runs 之后才打印。也再次提醒------能用 make_shared 就用 make_shared(见《make_unique / make_shared vs 裸 new》)。
7. use_count() 只能用于调试
use_count() 在多线程下只是近似值 ------你读到它的时候,别的线程可能正在拷贝或释放。所以它绝对不能写进业务逻辑(例如「if (p.use_count() == 1) 我就做独占操作」是错的,存在竞态)。在单线程里它也要小心:编译器可能在拷贝构造参数时零成本地调整计数,让结果与直觉不符。它正确的用途只有调试和教学:
8. 线程安全:控制块是原子的,对象不是
shared_ptr 的线程安全边界常被误解:多个线程各自持有一份 shared_ptr 副本是安全的 (计数操作原子),但同一个 shared_ptr 对象被多个线程同时读写 (比如一个拷贝、一个 reset)则不是,那是数据竞争;而指向的对象本身的读写安全,shared_ptr 完全不负责。
| 操作 | 是否线程安全 |
|---|---|
多个 shared_ptr 副本(各自独立)指向同一对象 |
安全(计数原子) |
同一个 shared_ptr 对象被多线程同时读+写 |
不安全,数据竞争 |
通过 *p 修改被管理对象 |
不安全,与 shared_ptr 无关 |
官方文档:std::atomic<std::shared_ptr>(C++20)--- cppreference:官方明确写了「控制块本身是线程安全的,多个
shared_ptr副本可以并发地reset/ 赋值」,也写了「同一个shared_ptr对象被并发读写就是数据竞争」。
9. 什么时候不该用 shared_ptr:与 unique_ptr 对比
没有共享需求时,用 shared_ptr 只是白付三笔开销。能用 unique_ptr 表达独占所有权就别用 shared_ptr:
| 维度 | unique_ptr<T> |
shared_ptr<T> |
|---|---|---|
| 所有权 | 独占,不可拷贝 | 共享,可拷贝 |
| 对象大小 | 1 个指针(8B) | 2 个指针(16B) |
| 拷贝代价 | 不可拷贝,只能 move(几乎免费) |
原子自增强引用计数(有开销) |
| 解引用间接 | 一次 | 一次(不经过控制块) |
| 判存活计数 | 无(独占即唯一) | 经控制块读强计数(额外间接) |
| 控制块分配 | 无 | 有(堆上一次额外分配) |
| 适用场景 | 明确的唯一拥有者 | 多处共享同一资源 |
还有一条必须记住的:shared_ptr 之间互相强引用会成环泄漏 ,这时要换 weak_ptr(见《weak_ptr 详解》)。下面把「独占用 move、共享用拷贝」放一起对比:
cpp
// sp_vs_unique.cpp --- 编译: g++ -std=c++17 -Wall -O2 sp_vs_unique.cpp -o sp_vs_unique
#include <iostream>
#include <memory>
int main() {
auto u1 = std::make_unique<int>(7);
auto u2 = std::move(u1); // unique:移动,u1 变空
std::cout << "u1 null? " << (u1 == nullptr) << '\n';
std::cout << "*u2 = " << *u2 << '\n';
auto s1 = std::make_shared<int>(7);
auto s2 = s1; // shared:拷贝,两者都有效
std::cout << "s1 valid? " << (s1 != nullptr) << '\n';
std::cout << "use_count = " << s1.use_count() << '\n';
return 0;
}
text
u1 null? 1
*u2 = 7
s1 valid? 1
use_count = 2
官方文档:C++ Core Guidelines「R: Resource management」一节(R.21:无共享需求就用 unique_ptr)
10. 延伸阅读
- std::shared_ptr --- cppreference:完整接口、控制块语义与线程安全保证,本篇所有论断的出处。
- std::make_shared --- cppreference:为什么优先用它(一次分配,见本系列第 28 篇)。
- std::weak_ptr --- cppreference:控制块里那枚弱计数的使用者,打破循环引用的钥匙。
- C++ Core Guidelines「R: Resource management」:R.20 要求所有权一律用智能指针表达,R.21 要求无共享需求就用
unique_ptr。
11. 一句话总结
shared_ptr 用堆上的控制块(强/弱计数 + 删除器 + 分配器)实现共享所有权,代价是 16 字节体积、一次额外的控制块分配、以及原子计数的拷贝开销;解引用本身不多一次间接,但判存活和拷贝都要穿过控制块;use_count() 只用于调试、绝不能进逻辑,没有共享需求时老老实实用 unique_ptr 更划算。