C++ 手撕 shared_ptr:引用计数与控制块到底长什么样?
摘要 :上一篇我们讲了
shared_ptr循环引用会导致内存泄漏,weak_ptr能救场。但面试官一旦追问:"shared_ptr内部到底是怎么实现的?控制块里存了什么?引用计数为什么要用原子变量?"很多人就卡壳了。本文用 100 行左右的代码,手写一个最小可用的shared_ptr,把控制块、原子计数和make_shared的内存布局一次性讲透。
一、为什么不能只用一个 int* 来计数?
先看一个"想当然"的错误实现:
cpp
template <typename T>
class BadSharedPtr {
T* _ptr;
int* _count; // 指向引用计数
public:
BadSharedPtr(T* p) : _ptr(p), _count(new int(1)) {}
~BadSharedPtr() {
if (--(*_count) == 0) {
delete _ptr;
delete _count;
}
}
// ...
};
这段代码看似合理:多个 BadSharedPtr 拷贝时共享同一个 _count,计数归零时释放对象和计数。
但它有一个致命缺陷 :_count 和 _ptr 是分开两次分配 的。如果 new int(1) 成功,但后续某个环节抛异常,_ptr 就泄漏了。更关键的是,标准库的 shared_ptr 需要同时支持弱引用计数 (weak_ptr 使用)、自定义删除器 、分配器 ------这些信息如果散落在智能指针对象内部,每个 shared_ptr 都要多存一份,浪费空间。
因此,标准库采用了 控制块(Control Block) 模式:把所有共享的元信息打包到一个独立的对象里,所有指向同一资源的 shared_ptr 共享同一个控制块。
二、控制块里到底有什么?
一个典型的控制块至少包含:
cpp
class ControlBlockBase {
public:
std::atomic<long> shared_count; // 强引用计数
std::atomic<long> weak_count; // 弱引用计数(weak_ptr 使用)
// 删除器、分配器等信息也在这里
virtual void destroy() = 0; // 销毁被管理对象
virtual void deallocate() = 0; // 释放控制块自身
};
💡 关键点 :
shared_count和weak_count必须是原子类型 (std::atomic)。因为多个线程可能同时拷贝/销毁指向同一对象的shared_ptr,如果计数是普通long,++和--都不是原子操作,会导致计数错乱,进而引发重复释放 或内存泄漏。
libstdc++ 的源码中,引用计数的增减最终都落到 __atomic_add_dispatch / __exchange_and_add_dispatch 这类原子操作上。我们手写时用 std::atomic<long> 就能达到同样的语义。
三、手写核心骨架
3.1 控制块的实现
cpp
template <typename T>
class ControlBlock {
public:
std::atomic<long> shared_count{1};
std::atomic<long> weak_count{1}; // 初始 1,代表控制块自身的"逻辑引用"
T* ptr;
ControlBlock(T* p) : ptr(p) {}
void add_shared() { shared_count.fetch_add(1, std::memory_order_relaxed); }
void release_shared() {
if (shared_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
destroy();
release_weak(); // 强引用归零,释放一个"逻辑弱引用"
}
}
void add_weak() { weak_count.fetch_add(1, std::memory_order_relaxed); }
void release_weak() {
if (weak_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete this; // 弱引用归零,控制块本身才销毁
}
}
void destroy() { delete ptr; } // 默认删除器
};
3.2 shared_ptr 本体
cpp
template <typename T>
class SharedPtr {
T* _ptr{nullptr};
ControlBlock<T>* _cb{nullptr};
void release() {
if (_cb) _cb->release_shared();
_ptr = nullptr;
_cb = nullptr;
}
public:
// 构造:接管裸指针,创建控制块
explicit SharedPtr(T* p = nullptr)
: _ptr(p), _cb(p ? new ControlBlock<T>(p) : nullptr) {}
// 拷贝:共享控制块,强引用 +1
SharedPtr(const SharedPtr& other)
: _ptr(other._ptr), _cb(other._cb) {
if (_cb) _cb->add_shared();
}
// 移动:直接偷走,不改变计数
SharedPtr(SharedPtr&& other) noexcept
: _ptr(other._ptr), _cb(other._cb) {
other._ptr = nullptr;
other._cb = nullptr;
}
// 赋值:先释放旧的,再共享新的
SharedPtr& operator=(const SharedPtr& other) {
if (this != &other) {
release();
_ptr = other._ptr;
_cb = other._cb;
if (_cb) _cb->add_shared();
}
return *this;
}
~SharedPtr() { release(); }
T* get() const { return _ptr; }
T& operator*() const { return *_ptr; }
T* operator->() const { return _ptr; }
long use_count() const { return _cb ? _cb->shared_count.load() : 0; }
explicit operator bool() const { return _ptr != nullptr; }
};
这段代码已经覆盖了 shared_ptr 最核心的生命周期语义。和标准库的差距主要在于:没有 weak_ptr 的完整支持、没有自定义删除器、没有 make_shared 优化。但控制块 + 原子引用计数这套骨架,和真实实现是一致的。
四、make_shared 到底优化了什么?
上面的 SharedPtr(T* p) 构造函数里,new ControlBlock<T>(p) 是一次独立的内存分配,而用户传入的 p 本身通常也是 new T 出来的。所以 SharedPtr<T> sp(new T) 至少涉及两次堆分配:一次给对象,一次给控制块。
make_shared 的优化是:把对象和控制块放进同一块连续内存里 ,只做一次分配。对象直接在控制块的数据成员中就地构造(placement new)。
new T + shared_ptr(T*) 的内存布局: make_shared<T>() 的内存布局:
[控制块] <-- 独立分配 [ 控制块 ]
[对象 T] <-- 独立分配 [ 对象 T ] <-- 同一块内存,连续
这样做的好处:
- 减少一次
new调用,提升分配效率; - 改善局部性:控制块和对象在同一缓存行附近,访问更快。
代价是:weak_count 归零之前,整块内存 (包括已经析构的对象所占的空间)都无法释放。如果你大量使用 weak_ptr 且对象较大,make_shared 反而可能让内存延迟回收。
五、顺便看 unique_ptr:为什么它没有控制块?
shared_ptr 需要控制块,是因为所有权是共享的 ,必须有一个独立于各个智能指针的"计数器"来协调释放时机。而 unique_ptr 是独占所有权 ,同一时刻只有一个 unique_ptr 拥有对象,不需要协调,因此零额外开销。
unique_ptr 的源码中,拷贝构造被显式删除:
cpp
unique_ptr(const unique_ptr&) = delete;
所有权只能通过移动 转移,转移时源指针置空,目的指针接管。删除器通过模板参数在编译期确定,没有虚函数、没有控制块、没有原子操作。此外,unique_ptr<T[]> 有偏特化 ,默认删除器使用 delete[] 而非 delete,避免对数组用错释放方式。
| 对比项 | shared_ptr |
unique_ptr |
|---|---|---|
| 所有权 | 共享 | 独占 |
| 控制块 | 有(堆上的独立对象) | 无 |
| 引用计数 | 原子变量 | 不需要 |
| 拷贝 | 允许(计数+1) | 禁止 (= delete) |
| 移动 | 允许(计数不变) | 允许(指针转移) |
| 额外开销 | 一个控制块 + 原子操作 | 几乎为零 |
📌 一句话 :
shared_ptr用控制块换来了共享所有权;unique_ptr用编译期约束换来了零开销。
六、面试直接背:3句话总结
问 :"请说说 shared_ptr 的内部实现。"
第一句 :
shared_ptr内部由指向对象的指针 和指向控制块的指针 组成,控制块中保存强引用计数、弱引用计数和删除器。第二句 :强引用计数用
std::atomic保证线程安全 ;强引用归零时销毁对象,弱引用归零时销毁控制块本身。第三句 :
make_shared将对象和控制块合并为一次内存分配,性能更好,但会延迟整块内存的释放。
七、面试高频 FAQ
Q1:shared_ptr 的引用计数是线程安全的吗?
计数本身是原子操作,是线程安全的。但被管理对象的读写不是线程安全的,多线程访问同一对象仍需额外同步。
Q2:shared_ptr<T> sp(new T) 和 make_shared<T>() 有什么区别?
前者至少两次分配,后者一次分配。但后者在存在 weak_ptr 时,对象内存会延迟到弱引用归零才释放。
Q3:为什么 unique_ptr 不能拷贝?
因为独占所有权语义要求同一时刻只有一个所有者。拷贝构造被 = delete 显式禁用,所有权只能通过移动转移。
Q4:weak_ptr 的计数存在哪里?
存在控制块的 weak_count 里。weak_ptr 本身只持有一个指向控制块的指针,不持有对象指针。
八、总结与挑战
| 核心点 | 结论 |
|---|---|
| 控制块 | 存强/弱计数、删除器,所有 shared_ptr 共享 |
| 原子计数 | shared_count 必须用 std::atomic |
| 释放时机 | 强计数归零销毁对象;弱计数归零销毁控制块 |
make_shared |
单次分配,局部性好,但延迟整块内存释放 |
unique_ptr |
无控制块,拷贝被 = delete,移动转移所有权 |
核心结论 :shared_ptr 的复杂度不在"智能",而在控制块的生命周期管理 。手写过一遍之后,循环引用、weak_ptr、make_shared 的代价这些面试题就不再是背诵,而是能推导出来的结论了。
** 评论区互动**:
看完手撕源码,你对 shared_ptr 的理解有变化吗?
扣1 :以前只知道循环引用,现在懂了控制块。
扣2 :终于搞懂 make_shared 为什么快了。
扣3 :我还是选择 unique_ptr,零开销才是王道。
评论区见!
🔥 系列爆款推荐 :
👉 [C++ shared_ptr 循环引用:一张图看懂内存泄漏,3句话面试直接背](#C++ shared_ptr 循环引用:一张图看懂内存泄漏,3句话面试直接背)
👉 [C++ vector 迭代器失效:一张图+3句话,面试直接背](#C++ vector 迭代器失效:一张图+3句话,面试直接背)
👉 [C++ 面试必杀技:map 底层为什么不用 AVL 树?](#C++ 面试必杀技:map 底层为什么不用 AVL 树?)