C++ 手撕 shared_ptr:引用计数与控制块到底长什么样?

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 树?)

相关推荐
XiYang-DING1 小时前
P4009.和为K的子数组个数 & P5522.异或和为 k 的子数组个数
java·开发语言
学习论之费曼学习法1 小时前
无人值守AI内容矩阵实战(8):标题 A/B 实验的 6 个工程实现细节,隔日对比与自动判胜
人工智能·python·算法·矩阵·自动化·a/b测试·内容矩阵
Carl_奕然1 小时前
【机器视觉】最强算法之:3D高斯泼溅(3D Gaussian Splatting)
人工智能·python·算法·目标检测·计算机视觉·3d
SunkingYang1 小时前
C++14 vs C++17:新特性对比与用法指南
c++·新特性·用法·c++14·示例·c++17
jaysee-sjc1 小时前
【苍穹外卖】Day12:工作台数据看板与 Excel 报表导出
java·开发语言·数据库·spring boot·spring·微信·excel
w_zero_one1 小时前
算法--桶思想
java·开发语言·算法
飞猫的边缘AI2 小时前
MCU面试开放题
人工智能·mcu·面试·求职招聘·flash·ram·边缘ai
一个游离的指针2 小时前
ES6新特性:let、const、块级作用域
开发语言·javascript·es6
wangchunyu1142 小时前
JAVA 类加载机制与双亲委派模型
java·开发语言