shared_ptr底层剖析
# 29.shared_ptr底层剖析
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 控制块精确布局与设计
- 4. 引用计数的原子操作实现
- 5. make_shared 单次分配原理
- 6. weak_ptr 的原理与用途
- 7. enable_shared_from_this 内幕
- 8. 设计模式与误用
- 9. shared_ptr 性能全景
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 图节点循环引用的内存泄漏
某社交网络分析引擎用有向图存用户关系——每个节点持有相邻节点的 shared_ptr。这是最典型的 C++ 内存泄漏场景——编译器不报错、静态分析工具常漏掉、valgrind 测试不运行就不会发现:
// ====== 事故代码 V1:循环引用 ======
struct Node {
std::string name;
std::vector<std::shared_ptr<Node>> neighbors;
~Node() { std::cout << "~Node " << name << '\n'; }
};
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->neighbors.push_back(b);
b->neighbors.push_back(a); // ← 循环引用!
// a.use_count() = 2, b.use_count() = 2
a.reset(); // a 的 use_count → 1(b 还持有一个引用)
b.reset(); // b 的 use_count → 1(a 的 neighbors[0] 还持有一个引用)
// 两个 Node 都没有被析构——内存泄露!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
困惑的核心:为什么 shared_ptr 不能自动检测循环引用?Java 的 GC 就能啊?
论证:shared_ptr 是纯引用计数——没有全局对象图扫描。它看到的只有 use_count 这个数字。当 a 引用 b、b 引用 a,两个 count 都是 2(外部 1 + 对方 1)。外部引用释放后,两个 count 各降到 1——但无法再降。编译器无法知道「这 1 个引用是谁给的」——它只看到数字。要做循环检测,需要遍历整个对象图(像 GC 那样),但这就需要:
- 一个全局的「所有 shared_ptr 的列表」——运行时维护开销
- 一个「标记-清除」的 GC 线程——不符合 C++ 的「零开销抽象」
C++ 的选择:不付这笔开销。用 weak_ptr 手动打断循环——程序员知道哪里是环,编译器不需要知道。
剖析引用链的内存细节:
初始状态(外部两个 shared_ptr):
stack: sp_a -> [Node A: name="A", neighbors=[→B]] use_count=1
stack: sp_b -> [Node B: name="B", neighbors=[→A]] use_count=1
push_back 后:
[Node A: use_count=2] ← sp_a (1) + B.neighbors[0] (1)
[Node B: use_count=2] ← sp_b (1) + A.neighbors[0] (1)
sp_a.reset() 后:
[Node A: use_count=1] ← 只剩 B.neighbors[0] 持有
[Node B: use_count=2] ← sp_b (1) + A.neighbors[0] (1)
sp_b.reset() 后:
[Node A: use_count=1] ← B.neighbors[0] 持有(B 还活着!因为 A 引用 B)
[Node B: use_count=1] ← A.neighbors[0] 持有(A 还活着!因为 B 引用 A)
→ 死锁在两个 use_count=1 之间
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
汇编层面看不见任何线索——use_count 就是一个堆上的 atomic<int>。程序正常运行,内存分析器(如 valgrind)只能在进程退出时报告「还有内存没释放」,但说不清具体在哪个对象上形成了环。
# 1.2 enable_shared_from_this 的双控制块惨案
同一个代码库的 Session 类,作者想从成员函数里获取自身的 shared_ptr:
// ====== 事故代码 V2:双控制块 ======
struct Session : std::enable_shared_from_this<Session> {
void on_event() {
auto self = shared_from_this(); // 获取自己的 shared_ptr
async_post(self); // 投递到异步队列
}
};
// ❌ 错误用法:裸 new 直接构造 shared_ptr
Session* raw = new Session();
std::shared_ptr<Session> sp1(raw); // ① 创建控制块 A
auto sp2 = raw->shared_from_this(); // ② 创建控制块 B!
// 两个控制块管理同一个对象 → use_count 各自为 1 → 第一个到 0 的析构对象
// → 第二个析构时 delete 已释放的内存 → double free
2
3
4
5
6
7
8
9
10
11
12
13
14
shared_from_this 返回一个新的 shared_ptr——但它读取的是 enable_shared_from_this 基类里的 weak_ptr。如果这个 weak_ptr 还没被初始化(因为对象不是通过 make_shared / shared_ptr 构造函数创建的),它为空——但即使不为空,如果它是从另一个控制块来的——两个控制块一起管理同一个对象 → 灾难。
# 1.3 七个待解疑问
① shared_ptr 的控制块到底存在哪? 包含哪些字段? libstdc++ 源码长什么样? → 第 3 章
② 引用计数的 ++ 和 -- 是原子的吗? LOCK 前缀在缓存一致性协议层怎么工作? → 第 4 章
③ make_shared 的一次分配具体怎么实现? 源码级流程是什么? → 第 5 章
④ weak_ptr 怎么做到「不增加 strong_count 但能安全提升为 shared_ptr」? → 第 4.4/第 6 章
⑤ enable_shared_from_this 怎么工作? 双控制块怎么产生、怎么预防? → 第 7 章
⑥ shared_ptr 的性能开销到底多大? 和 GC 比怎么样? 有什么误用模式? → 第 8.4/第 9 章
⑦ 循环引用怎么检测? 工程中怎么避免? weak_ptr 应该用在哪? → 第 6 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 控制块的五个字段
┌────────────────────────────────────────────────────┐
│ shared_ptr 控制块 │
├──────────────┬─────────────────────────────────────┤
│ strong_count │ 强引用计数——对象存活引用数 │
│ weak_count │ 弱引用计数——weak_ptr 数量+1(strong) │
│ deleter │ 删除器——如何销毁对象 │
│ allocator │ 分配器——如何释放控制块自身 │
│ object_ptr │ 指向被管理对象的指针 │
└──────────────┴─────────────────────────────────────┘
2
3
4
5
6
7
8
9
注意:控制块是堆上分配的——一个 shared_ptr 实例只有两个指针(16 字节):一个指向对象、一个指向控制块。
shared_ptr 实例 控制块 (堆)
┌──────────────────┐ ┌──────────────────┐
│ ptr → Widget │ │ strong_count: 3 │
│ ctrl → [控制块] │─────►│ weak_count: 1 │
└──────────────────┘ │ deleter │
sizeof = 16 │ Widget* │
└──────────────────┘
2
3
4
5
6
7
# 2.2 为何这么切
疑惑:为什么 shared_ptr 需要一个独立分配的控制块——不能把引用计数嵌在对象里吗?
论证:
- 强类型安全——
shared_ptr<void>可以管理任何类型的对象。如果引用计数嵌入对象,shared_ptr<void>就不知道该从哪个偏移读计数。 weak_ptr的独立生命周期——当 strong_count 归零、对象被析构后,weak_ptr 仍然可以存在。weak_ptr 需要检查 strong_count 是否为 0——这些信息不能跟着对象一起析构,必须在独立存活的「控制块」里。make_shared的一次分配优势——控制块可以和对象合并在一次内存分配里(第 5 章)——这正是独立控制块带来的灵活性。
结论:控制块的独立堆分配是共享所有权模型的必要代价——它解耦了对象生命周期和控制信息生命周期。
# 3. 控制块的精确布局与设计
# 3.1 libstdc++ 实际源码逐行拆解
下面是 libstdc++ 真正使用的控制块源码——注释版:
// __shared_count 的基类——引用计数的物理存储
class _Sp_counted_base {
_Atomic_word _M_use_count; // ① strong_count——原子 int
_Atomic_word _M_weak_count; // ② weak_count——原子 int
// 注意:非原子变量不够——多线程同时增减
// ③ 为什么用 int 而不是 long?标准规定 use_count() 返回 long,
// 但实现用 int 省 4 字节。理论上 2^31 个引用足够——现实中没有场景突破。
public:
// 构造函数——初始化双计数器
_Sp_counted_base() noexcept
: _M_use_count(1), // ④ 初始值 = 1——表示「有一个 shared_ptr 引用我」
_M_weak_count(1) {} // ⑤ 初始值 = 1——为这第一个 shared_ptr 保留的控制块存活权
// 增加强引用(shared_ptr 拷贝构造)
void _M_add_ref_copy() {
__gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); // atomic++
}
// 释放强引用——最关键的逻辑
void _M_release() noexcept {
// 原子减——如果归零,调用 dispose(析构对象)+ 弱引用释放
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) {
_M_dispose(); // ⑥ 先析构对象
_M_weak_release(); // ⑦ 再释放弱引用
}
}
// 弱引用释放
void _M_weak_release() noexcept {
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_weak_count, -1) == 1) {
_M_destroy(); // ⑧ weak_count 归零 → 删除控制块本身
}
}
virtual void _M_dispose() = 0; // ⑨ 纯虚——由模板子类实现 delete 对象
virtual void _M_destroy() = 0; // ⑩ 纯虚——由模板子类实现释放控制块内存
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
关键设计点逐条详解:
④⑤ 为什么初始值是 1 而不是 0?
shared_ptr<T> sp 创建时:
use_count = 1 → sp 持有一个强引用
weak_count = 1 → 为「sp 还活着」事实保留的控制块存活权
(即使没有任何 weak_ptr,控制块也需要在 sp 存活期间存活)
sp 析构 → use_count → 0 → dispose 对象 → weak_release → weak_count → 0 → 删除控制块
2
3
4
5
6
如果 weak_count 初始值 = 0:sp 析构时 weak_count 已是 0 → 直接删除控制块 → _M_dispose 还没调用!对象泄露。
⑥⑦ 为什么先 dispose 再 weak_release?
顺序不能反——必须先析构对象再释放控制块内存。因为如果先释放控制块,_M_dispose() 可能访问到已释放的控制块字段(如 deleter / allocator 的虚表)。
⑨⑩ 为什么用虚函数而不是模板?
这是 shared_ptr 「类型擦除」的心脏——_Sp_counted_base 是所有控制块的共同基类,无论 shared_ptr<int> 还是 shared_ptr<std::string> 的控制块都继承自它。_M_dispose / _M_destroy 的虚函数调用是运行时分发——这 8 字节(vptr)是共享所有权必须付的代价。
// 实际存储类型——模板子类:
template <typename Tp, typename Alloc>
class _Sp_counted_ptr : public _Sp_counted_base {
Tp* _M_ptr; // 指向被管理对象
Alloc _M_alloc; // 分配器(用于释放控制块)
// _M_dispose() override { delete _M_ptr; }
// _M_destroy() override { this->~_Sp_counted_ptr(); alloc.deallocate(...); }
};
2
3
4
5
6
7
8
所以 shared_ptr 的总 sizeof 为何是 16:
shared_ptr 实例 (16 字节) 控制块 (堆上,~48 字节)
┌──────────────────┐ ┌──────────────────────┐
│ ptr → object │ │ vptr → vtable (8B) │ ← 虚函数分发
│ ctrl → [block] │───► │ use_count (4B) │
└──────────────────┘ │ weak_count (4B) │
│ padding (0-4B)│
│ T* _M_ptr (8B) │
│ allocator (8B+) │
└──────────────────────┘
2
3
4
5
6
7
8
9
一个 shared_ptr 实例 = 2 个指针(16 字节)。控制块 = 独立堆分配 + 虚表 + 原子计数器——这是共享所有权无法避免的基础设施成本。
# 3.2 strong_count 与 weak_count 的完整推演
两个计数器的语义完全不同:
| 计数器 | 含义 | 何时++ | 何时-- | 归零时的动作 |
|---|---|---|---|---|
strong_count | 对象的「强引用」数 | shared_ptr 拷贝构造 | shared_ptr 析构/重置 | 析构对象 |
weak_count | 弱引用 + 1(为强引用预留) | weak_ptr 拷贝构造;首个 shared_ptr 构造 | weak_ptr 析构/重置 | 释放控制块 |
关键:weak_count 比 weak_ptr 的数量多 1——这是为「至少有一个 shared_ptr 存活时」保留的控制块存活权。当所有 shared_ptr 析构(strong_count=0)后,只要还有 weak_ptr,控制块依然存活(weak_count>0)。
# 3.3 内存布局——make_shared vs 普通构造
① 普通构造:两次分配
shared_ptr<T> sp(new T());
┌──────────────┐ ┌──────────┐
│ sp: 两个指针 │ │ T 对象 │ ← 分配 1:对象
│ ptr ─────────┼────►│ │
│ ctrl ────────┼──┐ └──────────┘
└──────────────┘ │
│ ┌──────────────┐
└─►│ 控制块 │ ← 分配 2:控制块
│ strong_count │
│ weak_count │
└──────────────┘
② make_shared:一次分配
auto sp = std::make_shared<T>();
┌──────────────┐
│ sp: 两个指针 │ ┌──────────────┐
│ ptr ─────────┼────►│ 控制块 │ ← 一次分配:控制块 + T 连在一起
│ ctrl ────────┼──┐ │ strong_count │
└──────────────┘ │ │ weak_count │
│ ├──────────────┤
└─►│ T 对象 │
└──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 3.4 控制块的析构时机
auto sp = std::make_shared<Widget>();
std::weak_ptr<Widget> wp = sp;
sp.reset(); // strong_count → 0
// 此时:Widget 被析构 ✅
// 控制块仍然存活(weak_count = 1——wp 持有)
wp.reset(); // weak_count → 0
// 此时:控制块被释放 ✅
2
3
4
5
6
7
8
9
# 4. 引用计数的原子操作实现
# 4.1 原子增与原子减汇编推演
疑惑:shared_ptr 的拷贝和析构真的需要 LOCK 前缀吗?单线程程序为什么也付出这开销?
论证:编译器无法在编译期判断「这个 shared_ptr 是否会跨线程共享」。标准规定 shared_ptr 的引用计数必须线程安全——即使你的程序是单线程,编译器也要生成原子指令。
// shared_ptr 拷贝构造
auto sp2 = sp1;
2
GCC 13.2 -O2 (x86-64):
; sp2 = sp1 —— 原子增 strong_count
mov rax, [rsi+8] ; rax = 控制块指针(shared_ptr 偏移 +8 处)
lock add DWORD PTR [rax], 1 ; atomic strong_count++
; LOCK 前缀:锁缓存行 → 当前 core 独占
; → 其他 core 的 store buffer 被刷新
; → 所有 core 看到一致的强一致性序
2
3
4
5
6
// shared_ptr 析构
sp2.reset();
2
; ~sp2 —— 原子减 strong_count + 检查归零
mov rax, [rdi+8] ; rax = 控制块指针
lock sub DWORD PTR [rax], 1 ; atomic strong_count--
je .Lrelease ; 如果归零 → 跳转到释放逻辑
ret
.Lrelease:
; dispose() → 析构对象
; weak_release():
; lock sub [ctrl+4], 1 ; atomic weak_count--
; je .Ldestroy ; 如果 weak_count 也为 0 → 删除控制块
2
3
4
5
6
7
8
9
10
为什么需要 LOCK 前缀——从缓存一致性协议角度看:
Core 0 Core 1
auto sp2 = std::move(sp1) sp1.reset()
│ │
├─ lock add [ctrl], 1 ├─ lock sub [ctrl], 1
│ ① 广播 RFO (Read For Ownership) │ ① 同样广播 RFO
│ ② 独占缓存行 │ ② 等待 Core 0 释放缓存行
│ ③ 修改计数 │ ③ 获取缓存行所有权
│ ④ 写回 │ ④ 修改计数
如果没有 LOCK 前缀:
Core 0 读到 count=2 → 加 1 → count=3
Core 1 同时读到 count=2 → 减 1 → count=1
最终 count = 1(丢失了 Core 0 的 +1!)
→ use_count 和实际引用数不一致 → 对象早释或从不释放
2
3
4
5
6
7
8
9
10
11
12
13
14
单线程的 LOCK 开销:在单核上,LOCK 前缀不会触发缓存行争抢——延迟约 1 个周期。编译器也有优化——如果它能证明 shared_ptr 确实不会被多线程访问(如函数内部的局部变量),可能在 -O3 下消掉 LOCK(但标准不要求)。
# 4.2 为什么不用互斥锁
| 方案 | 单次操作延迟 (无竞争) | 单次操作延迟 (4 核竞争) | 内存占用 |
|---|---|---|---|
std::atomic<int> (lock add) | ~5 ns | ~45-120 ns | 4 字节 |
std::mutex (futex) | ~50 ns | ~2000 ns | 40 字节 |
std::shared_mutex | ~80 ns | ~5000 ns | 56 字节 |
原子变量在无竞争时只需 LOCK ADD——延迟 ≈ 一个 store 指令 + 缓存一致性协议的一次 RFO 请求。互斥锁需要:
lock()→ 如果无竞争走 fast path(user-space 自旋)→ ~50 ns- 如果有竞争 → 进入内核的 futex wait → 上下文切换(~1000-2000 ns)
差距 10×~400× —— shared_ptr 选原子变量不是偏好,是正确选择。
# 4.3 高竞争场景的性能退化
当多个线程同时修改同一个 shared_ptr 的引用计数时,LOCK ADD 触发缓存行在多核之间跳动(cache line bouncing):
| 线程数 | 单次 copy 时间 | 退化原因 |
|---|---|---|
| 1 | 15 ns | 单核——无竞争 |
| 4 | 45 ns | 4 核轮流占缓存行 |
| 16 | 320 ns | 缓存行在不同 socket 间跳(NUMA 效应) |
减轻措施:
- 用
std::atomic<std::shared_ptr<T>>(C++20)代替多个线程直接操作同一个shared_ptr - 或每个线程持有一份自己的
shared_ptr(自然减少共享计数器的竞争)
# 4.4 weak_ptr::lock 的原子快照
std::shared_ptr<Widget> sp = wp.lock();
汇编本质:
; wp.lock() — 原子操作序列
retry:
mov eax, [ctrl + strong_count_offset] ; 读 strong_count
test eax, eax
jz return_null ; 如果 = 0 → 返回空
lock cmpxchg [ctrl + strong_count_offset], eax+1 ; CAS 原子增
jne retry ; 如果 CAS 失败 → 重试
; 成功——构造 shared_ptr 返回
2
3
4
5
6
7
8
关键:lock cmpxchg——如果 strong_count 在 读 和 增 之间被另一个线程改成 0,CAS 失败、重试。这保证了 weak_ptr::lock() 的原子性:永远不会返回一个「strong_count 已经归零的」shared_ptr。
# 4.4 weak_ptr::lock 的 CAS 循环完整拆解
疑惑:weak_ptr::lock() 为什么需要 CAS?不能直接读 strong_count 然后 ++ 吗?
论证:如果分离成读和增两步:
// ❌ 非原子版本——有竞态窗口
std::shared_ptr<T> bad_lock() {
if (ctrl->strong_count == 0) return nullptr; // ① 读:count = 1
// ⚠️ 窗口:另一个线程在 ① 和 ② 之间释放了最后一个 shared_ptr
// → strong_count 变成 0
ctrl->strong_count++; // ② 增:count = 1
// 成功获取——但对象刚刚已经被析构了!UB!
}
2
3
4
5
6
7
8
CAS 的本质:原子地「比较 + 交换」——把读和增合并为一个不可分割的操作:
std::shared_ptr<Widget> sp = wp.lock();
汇编本质(完整版):
; wp.lock() — 完整 CAS 循环
mov rax, [rdi+8] ; rax = 控制块指针
.Lretry:
mov edx, [rax] ; edx = strong_count (读)
test edx, edx ; 检查是否为 0
jz .Lreturn_null ; 如果 = 0 → 对象已死 → 返回空
lea ecx, [rdx+1] ; ecx = edx + 1 (期望的新值)
lock cmpxchg [rax], ecx ; CAS: 如果 [rax] == edx → [rax] = ecx
; 否则 → edx = [rax] (被其他线程改了)
jne .Lretry ; CAS 失败 → edx 变了 → 重新读
; 成功——strong_count 原子地从 N 增到 N+1
; 构造 shared_ptr 并返回
.Lreturn_null:
xor eax, eax ; 返回 nullptr
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lock cmpxchg 三步语义:
lock cmpxchg [mem], reg:
① 原子地比较 [mem] == eax
② 如果相等:ZF=1, [mem] = reg
③ 如果不等:ZF=0, eax = [mem](被别的 core 改了!)
这和 weak_ptr::lock 的需求完美匹配:
读到 strong_count = N (> 0)
→ CAS 尝试把 strong_count 改成 N+1
→ 如果在这之间被其他线程改成 0 → CAS 失败,ZF=0 → eax 被刷新 → 走到 test eax,eax → jz retry
→ retry 时读到 0 → 返回 nullptr ✅
2
3
4
5
6
7
8
9
10
三种竞争状态的全路径推演:
| 线程 A (lock) | 线程 B (reset) | 结果 |
|---|---|---|
| 读 strong_count=1 | — | — |
| CAS (1→2) | — | ✅ lock 成功,count=2 |
| 读 strong_count=1 | reset: strong_count→0 | A 的 CAS 失败([mem]=0≠eax=1)→ retry → 读到 0 → 返回 null ✅ |
| — | reset: strong_count→0 | A 的 CAS 失败 → 返回 null ✅ |
结论:CAS 保证「只要 lock 成功返回非空 shared_ptr,strong_count 至少为 1」——对象一定活着。任何竞态条件下都不会出现「返回了一个指向已析构对象的 shared_ptr」的情况。
# 5. make_shared 的单次分配原理
# 5.0 为什么需要一次分配
疑惑:make_shared 一次分配对象+控制块有什么好处?不就是少一次 operator new 调用吗?
论证:
- 减少
operator new调用——每次调用new都涉及:① 查找 free list ② 可能的 brk/mmap 系统调用 ③ 更新分配器内部结构。少一次 = ~20ns 节省。 - 缓存局部性——两次分配 = 对象和控制块分散在堆上两块区域。一次分配 = 两块紧挨着——CPU 加载控制块时顺带把对象也带进了 cache line。
- 减少内存碎片——两次独立的小分配 = 两个独立的碎片位。一次分配 = 一块连续内存。
- 减少
shared_ptr的数量跟踪——普通构造的控制块跟踪的是独立于对象的T* object_ptr;make_shared 版两者合一。
// 普通构造——libstdc++ 内部
template <typename T, typename... Args>
shared_ptr<T> make_shared(Args&&... args) {
// ① 计算总大小 = 控制块 + 对象
using ctrl_type = _Sp_counted_ptr_inplace<T, allocator<T>>;
size_t total = sizeof(ctrl_type) + sizeof(T) + alignof(T);
// ② 一次分配
void* mem = operator new(total);
// ③ placement new——控制块在低地址,对象在高地址
auto* ctrl = ::new(mem) ctrl_type(alloc, std::forward<Args>(args)...);
// ctrl_type 的构造函数在「控制块之后」的内存上 placement new T(args...)
// ④ 返回 shared_ptr——指向对象和控制块
return shared_ptr<T>(ctrl);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 5.1 两次分配 vs 一次分配的全景对比
// 两次分配
std::shared_ptr<Widget> sp(new Widget(42));
// ① operator new(sizeof(Widget)) → 分配对象
// ② operator new(sizeof(control_block)) → 分配控制块
// 一次分配
auto sp = std::make_shared<Widget>(42);
// ① operator new(sizeof(control_block) + sizeof(Widget))
// → 一次分配对象+控制块合一
2
3
4
5
6
7
8
9
性能对比(GCC 13.2 -O2, 100 万次构造析构):
| 方式 | 构造时间 | 析构时间 | 内存碎片 |
|---|---|---|---|
| 两次分配 | 38 ns | 28 ns | 两块分散 |
| make_shared | 24 ns | 20 ns | 一块连续 |
# 5.2 Weaked Pointer 对单次分配的限制
make_shared 的一个后果:只要有任何 weak_ptr 存活,整块内存(含对象和控制块)都不能释放:
auto sp = std::make_shared<LargeWidget>(); // 一次分配 1MB(对象大)
std::weak_ptr<LargeWidget> wp = sp;
sp.reset(); // strong_count=0 → LargeWidget 的析构函数被调用 ✅
// 但 1MB 内存不能释放——因为 weak_ptr 还活着
// → 控制块和对象的「尸体」占据同一块内存,必须等 weak_ptr 也释放
wp.reset(); // weak_count=0 → 整块 1MB 内存被释放 ✅
2
3
4
5
6
7
8
如果对象本身很大(如 100MB 的图像缓存),make_shared 会导致:对象析构后 100MB 内存在 weak_ptr 存活期间一直被占着。 这时候用普通构造(两次分配)更合适——对象的 100MB 可以立刻释放,只有控制块(几十字节)被 weak_ptr 保持。
# 5.3 弱引用保持控制块存活
这是 weak_count 比实际的 weak_ptr 数量多 1 的原因:
shared_ptr 存在期间:
weak_count = (weak_ptr 数量) + 1
└── 这 1 个是「对象还活着」的标记
strong_count 归零后,这个 +1 被移除
weak_count = (weak_ptr 数量)
weak_count 归零 → 控制块析构(连同 make_shared 的整块内存)
2
3
4
5
6
7
# 6. weak_ptr 的原理与用途
# 6.1 不增加 strong_count 的设计
std::shared_ptr<Widget> sp = std::make_shared<Widget>();
std::weak_ptr<Widget> wp = sp; // sp.use_count() 仍然是 1——wp 不增加 strong
2
weak_ptr 构造——汇编:
; wp = sp — 只增 weak_count
mov rax, [rsi+8] ; sp 的控制块指针
lock add DWORD PTR [rax+4], 1 ; atomic weak_count++(偏移 4 是 weak_count)
2
3
# 6.2 expired() 与 lock() 的原子语义
if (!wp.expired()) {
auto sp = wp.lock(); // ⚠️ 检查 expired() 和 lock() 之间可能被改!
// sp 可能为空!如果另一个线程在 expired() 和 lock() 之间释放了最后一个 shared_ptr
}
// ✅ 正确用法:直接用 lock()
if (auto sp = wp.lock()) {
// sp 一定有效——lock 原子地检查 + 增加计数
}
2
3
4
5
6
7
8
# 6.3 Observer 模式与缓存场景
class Cache {
std::weak_ptr<Texture> texture_cache_;
public:
std::shared_ptr<Texture> get_texture(const std::string& path) {
if (auto tex = texture_cache_.lock()) return tex; // 缓存命中
auto tex = std::make_shared<Texture>(path);
texture_cache_ = tex; // weak_ptr 不阻止释放
return tex;
}
};
// 当没有其他 shared_ptr 引用 Texture 时——自动释放,weak_ptr 自然过期
2
3
4
5
6
7
8
9
10
11
# 6.4 use_count 的调试价值与陷阱
auto sp = std::make_shared<int>(42);
std::cout << sp.use_count(); // 1 —— 仅用于调试!不可用于业务逻辑!
// 另一个线程可能在你 use_count() 返回 1 后立即释放 sp
// 然后你又继续用 sp → UB
// ✅ 正确的写法:不依赖于 use_count 的值做控制流决策
2
3
4
5
6
# 7. enable_shared_from_this 内幕
# 7.1 weak_ptr 藏在基类里
template <typename T>
class enable_shared_from_this {
mutable std::weak_ptr<T> weak_this_; // ← 核心就这一个成员
protected:
std::shared_ptr<T> shared_from_this() {
return std::shared_ptr<T>(weak_this_);
}
std::shared_ptr<const T> shared_from_this() const {
return std::shared_ptr<const T>(weak_this_);
}
};
2
3
4
5
6
7
8
9
10
11
初始化时机:weak_this_ 不是在 enable_shared_from_this 的构造函数里初始化的——是在第一个 shared_ptr 构造时由 shared_ptr 的构造函数特殊设置。
# 7.2 shared_from_this 的初始化时机
std::shared_ptr<Session> sp(new Session());
// ① new Session() → Session 构造(包括 enable_shared_from_this 的构造)
// 此时 weak_this_ 为空
// ② shared_ptr<Session> 的构造函数检测到 Session 继承自 enable_shared_from_this
// → 设置 weak_this_ = *this(即用自己来初始化基类的 weak_ptr)
// ③ 此后任何 Session 的成员函数调用 shared_from_this() → 安全返回 shared_ptr
2
3
4
5
6
# 7.3 双控制块的预防
// ❌ 两个 shared_ptr 各自从同一个裸指针构造 → 两个独立控制块
Session* raw = new Session();
std::shared_ptr<Session> sp1(raw); // 创建控制块 A + 初始化 weak_this_
std::shared_ptr<Session> sp2(raw); // 创建控制块 B!没有初始化 weak_this_!
// sp2 完全不知道 sp1 的存在——两个控制块各自计数 → double free
// ✅ 只用一个 shared_ptr 作为起点
auto sp = std::make_shared<Session>(); // 一种方式
// 或
std::shared_ptr<Session> sp(new Session()); // 只出现一次裸 new
2
3
4
5
6
7
8
9
10
预防原则:任何继承 enable_shared_from_this 的类,绝对不允许裸 new 后交给两个 shared_ptr。永远用 make_shared 或唯一一个 shared_ptr(new T) 构造。
# 8. 与 unique_ptr 共用的设计模式
# 8.1 从 unique 到 shared 的语义转换
// 工厂返回 unique_ptr——调用方决定是否共享
std::unique_ptr<Widget> create_widget();
// 调用方需要共享所有权时:
auto sp = std::shared_ptr<Widget>(std::move(create_widget()));
// 或者:
auto sp = std::shared_ptr<Widget>(create_widget()); // unique_ptr 可以隐式 move 进 shared_ptr
2
3
4
5
6
7
# 8.2 Pimpl + shared_ptr
// widget.h
class Widget {
struct Impl;
std::shared_ptr<Impl> pimpl_; // shared_ptr 允许 Widget 拷贝时共享 Impl
public:
Widget();
void do_something();
};
2
3
4
5
6
7
8
与 unique_ptr 的 Pimpl 对比:unique_ptr<Impl> 要求显式声明析构函数(在 .cpp 文件中完整定义 ~Widget() 以访问 Impl 的完整性)——shared_ptr 不需要,因为它的 deleter 是类型擦除的。
# 8.3 异步回调生命周期
void async_operation(std::shared_ptr<Session> session) {
std::thread([session = std::move(session)] {
session->do_work(); // session 保证在回调期间存活
}).detach();
}
// 完全不担心 session 的内存在回调执行前被释放
2
3
4
5
6
# 8.4 shared_ptr 的常见误用模式
误用①:把 this 指针直接放进 shared_ptr
struct Widget {
std::shared_ptr<Widget> get_shared() {
return std::shared_ptr<Widget>(this); // ❌ 灾难——新建控制块!
}
};
auto sp = std::make_shared<Widget>();
auto sp2 = sp->get_shared();
// sp.use_count() = 1, sp2.use_count() = 1 → 两个独立控制块 → double free!
// 正确:用 enable_shared_from_this (第 7 章)
2
3
4
5
6
7
8
9
10
误用②:在多线程中共享非 const shared_ptr 而不加锁
// shared_ptr 的引用计数是线程安全的——但 shared_ptr 对象本身不是!
std::shared_ptr<Widget> g_sp; // 全局 shared_ptr
// 线程 A
g_sp = std::make_shared<Widget>(); // ⚠️ 和下面同时执行
// 线程 B
auto local = g_sp; // ⚠️ 和上面同时执行
// 问题:g_sp 的 ptr_ 和 ctrl_ 不是原子更新的——可能读到 «ptr_ 新, ctrl_ 旧»
// → 旧控制块 + 新对象指针 → 不知道计数是什么 → UB
// ✅ 修复:用 atomic<shared_ptr<T>>(C++20) 或 mutex 保护
2
3
4
5
6
7
8
9
10
11
误用③:shared_ptr 循环引用中的一半用 weak_ptr
struct Parent { std::shared_ptr<Child> child; };
struct Child { std::shared_ptr<Parent> parent; }; // ❌ 循环——都用 shared
// ✅ 最小修正——把反向引用改成 weak_ptr
struct Child { std::weak_ptr<Parent> parent; };
// 通用规则:
// parent → child : unique_ptr 或 shared_ptr(独占/共享)
// child → parent : 裸指针 或 weak_ptr(不拥有所有权)
2
3
4
5
6
7
8
9
误用④:依赖 use_count 做业务逻辑
if (sp.use_count() == 1) {
// ⚠️ 另一个线程可能在「==1检查」和「使用 sp」之间释放了引用
sp->modify(); // 可能 UB
}
// use_count 是诊断/调试工具——不是同步原语
2
3
4
5
# 8.5 shared_ptr 的 aliasing constructor
一个容易被忽略但非常强大的特性——shared_ptr 的 aliasing constructor:
struct Object { int data; };
struct Holder {
Object obj;
std::shared_ptr<Object> get_shared() {
// aliasing constructor——共享 Holder 的控制块,但指向 obj
return std::shared_ptr<Object>(shared_from_this(), &obj);
}
};
// 只要 Holder 还活着(通过 shared_ptr),obj 的引用就有效——不需要单独管理 obj 的生命周期
2
3
4
5
6
7
8
9
# 9. shared_ptr 的性能全景
# 9.1 构造、拷贝、析构的开销对比
| 操作 | 裸指针 | unique_ptr | shared_ptr |
|---|---|---|---|
| 默认构造 | 0 ns | 0 ns | 0 ns |
| 从 new 构造 | 0 ns | 0 ns | ~30 ns(分配控制块) |
| 拷贝 | 0 ns | 编译错误 | ~15 ns(原子增) |
| 移动 | 0 ns | ~1 ns(交换 ptr_) | ~3 ns(交换 ptr_+ctrl_) |
| 解引用 | 基准 | 相同 | 相同 |
| 析构 | 0 ns | ~5 ns(delete) | ~28 ns(原子减+条件释放) |
# 9.2 与 GC 语言的引用对比
| 维度 | C++ shared_ptr | Java/C# GC |
|---|---|---|
| 何时回收 | 最后一个引用离开 | GC 线程未来某时 |
| 回收确定性 | ✅ 确定性 | ❌ 非确定性 |
| 循环引用 | ❌ 手工用 weak_ptr | ✅ 自动检测 |
| 每字节开销 | 16 字节(两个指针) | 0 字节(GC 有全局开销) |
| 每次拷贝开销 | ~15 ns(lock add) | 0 ns(无引用计数) |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章七个疑问,逐条作答并补充新增发现:
| # | 疑问 | 答案 |
|---|---|---|
| ① | 控制块结构? | 第 3 章:vptr + strong_count + weak_count + object_ptr + allocator + deleter |
| ② | 原子操作的汇编? | 第 4 章:lock add / lock sub / lock cmpxchg + CAS retry |
| ③ | make_shared 的一次分配? | 第 5 章:源码级实现——控制块+对象连续分配;weak_ptr 存活时整块不释放 |
| ④ | weak_ptr 如何安全 lock? | 第 4.4:lock() 的 CAS 原子增——三种竞态全路径推演 |
| ⑤ | enable_shared_from_this? | 第 7 章:基类藏 weak_ptr,shared_ptr 构造时初始化;双控制块=double free |
| ⑥ | shared_ptr 性能? | 第 9 章:拷贝 ~15ns、析构 ~28ns、移动 ~3ns、解引用零额外开销 |
| ⑦ | 循环引用怎么办? | 第 6.3:owner→owner 用 shared_ptr,owner→observed 用 weak_ptr |
新增——共享所有权三法则:
| 法则 | 内容 | 违反的后果 |
|---|---|---|
| ① | 一个对象只能有一个控制块 | 裸 new→多个 shared_ptr→double free |
| ② | 循环引用 = 泄露 | 任何环至少一端用 weak_ptr |
| ③ | shared_ptr 本身不是线程安全的 | 多线程改同一 shared_ptr 须用 atomic/mutex |
案例①修复(循环引用):
struct Node {
std::string name;
std::vector<std::weak_ptr<Node>> neighbors; // ← weak_ptr 不增加 strong_count
};
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->neighbors.push_back(b);
b->neighbors.push_back(a);
// a.use_count() = 1, b.use_count() = 1 ✅ 没有循环引用
2
3
4
5
6
7
8
9
10
验证修复的方法:在破坏循环引用的 weak_ptr 端打印 expired()。当外部引用释放后,expired() == true 确认环已断开。
案例②修复(双控制块):永远用 make_shared 创建继承 enable_shared_from_this 的对象;如果不允许(如需要自定义 deleter),确保裸指针只进入一个 shared_ptr 构造函数。
# 10.2 一次拷贝的完整生平
auto sp2 = sp1; // shared_ptr 拷贝
═══════ 语义层 ═══════
sp1 已经持有一个 Widget——use_count = N
sp1 的成员:
ptr_ → Widget 对象
ctrl_ → 控制块(堆上)
sp2 被默认构造:
ptr_ = nullptr
ctrl_ = nullptr
拷贝构造 sp2(sp1):
① 读 sp1.ptr_ 和 sp1.ctrl_
② ctrl_->use_count 原子 +1(lock add)
③ sp2.ptr_ = sp1.ptr_
④ sp2.ctrl_ = sp1.ctrl_
═══════ 汇编层 ═══════
mov rax, [rsi] ; sp2.ptr_ = sp1.ptr_
mov [rdi], rax
mov rax, [rsi+8] ; rax = sp1.ctrl_
mov [rdi+8], rax ; sp2.ctrl_ = sp1.ctrl_
lock add DWORD [rax], 1 ; ctrl->use_count++ (原子)
总指令:5 条
总时间:~15 ns(4 条 mov + 1 条 lock add)
额外开销 vs unique_ptr:+2 条 mov(多一个 ctrl_ 拷贝)+ 1 条 lock add
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 10.3 设计哲学回扣
哲学 1:共享是最后的选择——默认独占
shared_ptr 有原子操作开销、控制块开销、循环引用风险、虚函数调用开销——每一项都是独占所有权不需要的。能用 unique_ptr 解决的,绝不用 shared_ptr。共享所有权是成本最高的所有权模型——只在真正需要时用。 80% 的智能指针用法应该是 unique_ptr。
哲学 2:弱引用打破循环——不拥有、只观察
weak_ptr 不是性能工具——是正确性工具。它的设计目标:在不增加强引用计数的前提下,安全地观察一个可能已经析构的对象。 CAS 是实现这条语义的唯一正确方式——任何更简单的方案(读+增两步)都有竞态窗口。
哲学 3:控制块是所有权声明的物理化
为什么 shared_ptr 需要 16 字节(两个指针)?因为共享所有权的物理体现就是:一个指针指向我、一个指针指向「我们多少人在看同一个对象」。所有权的模型决定了内存成本的底线——没有「零开销的共享所有权」这种东西。 要么付原子操作的 CPU 成本,要么付 GC 线程的全局开销。
哲学 4:一次分配 = 缓存友好 + 减少碎片 + 类型安全
make_shared 的一次分配不仅省了 operator new → 更重要的是对象和控制块在同一个 cache line 里。CPU 加载控制块时顺带加载了对象——对高频访问场景是隐形加速。
哲学 5:引用计数 = 确定性回收 + 分布式成本
shared_ptr 的引用计数回收是确定性的(对象在最后一个引用离开时立即析构)——这和 Java/C# GC 的「未知的未来某刻」完全不同。代价是每次拷贝都要原子操作。确定性 vs 吞吐量——C++ 选择了确定性。
# 10.4 速查表合集
三种智能指针速查:
| 维度 | unique_ptr | shared_ptr | weak_ptr |
|---|---|---|---|
| 所有权 | 独占 | 共享(计数) | 不拥有 |
| sizeof | 8 字节 | 16 字节 | 16 字节 |
| 拷贝 | 禁止 | 原子增计数 | 原子增弱计数 |
| 移动 | 交换 ptr_ | 交换 ptr_+ctrl_ | 同 shared_ptr |
| 析构逻辑 | delete 对象 | 原子减→归零→delete | 原子减弱计数 |
weak_ptr 使用法则:
循环引用的标准解法:
owner → owner : shared_ptr
owner → observer : weak_ptr
parent → child : unique_ptr
child → parent : 裸指针 或 weak_ptr
2
3
4
5
shared_ptr 误用速查:
| 误用 | 后果 | 修复 |
|---|---|---|
| 同一个裸指针给两个 shared_ptr | double free | make_shared |
| 多线程改同一个 shared_ptr 不加锁 | UB | atomic<shared_ptr> / mutex |
| use_count 做业务逻辑 | race condition | 锁/lock() |
| enable_shared_from_this + 裸 new | 双控制块 | make_shared |
下一篇:共享所有权的引用计数说清了。下一篇进入 30.weak_ptr 与 this 增强——
enable_shared_from_this的 CRTP 实现细节、weak_from_this的引入(C++17)、二级指针失效检测、Observer 场景的最佳实践。