shared_ptr 完全指南:引用计数、控制块与开销

std::unique_ptr 把「独占所有权」钉死在类型上,但现实里经常需要多个对象同时引用同一份资源 :缓存、父子节点、观察者列表......这时 std::shared_ptr(共享所有权指针,shared pointer)登场。它用引用计数(reference count)记录「现在有几个人在用」,最后一个离开时才释放。这篇把控制块里到底存了什么、它比裸指针贵在哪、以及什么时候不该用它一次讲清。

1. 引子:unique_ptr 不够用的那一刻

设想一个节点同时被两个容器引用。用 unique_ptr 做不到------它禁止拷贝。于是有人退回裸指针,又掉回「谁负责释放」的坑;或者写 std::shared_ptr<Node>(new Node) 硬扛,却不清楚这一下多花了什么。共享所有权正是 shared_ptr 要解决的问题:

官方文档:std::shared_ptr --- cppreference

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,一个指向控制块。

官方文档:std::shared_ptr 的「实现说明」一节 --- cppreference

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------对象指针和控制块指针确实是两个独立的值,缺一不可。

官方文档:别名构造函数(aliasing constructor)--- cppreference

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) 我就做独占操作」是错的,存在竞态)。在单线程里它也要小心:编译器可能在拷贝构造参数时零成本地调整计数,让结果与直觉不符。它正确的用途只有调试和教学:

官方文档:use_count 只是近似值 --- cppreference

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. 延伸阅读

11. 一句话总结

shared_ptr 用堆上的控制块(强/弱计数 + 删除器 + 分配器)实现共享所有权,代价是 16 字节体积、一次额外的控制块分配、以及原子计数的拷贝开销;解引用本身不多一次间接,但判存活和拷贝都要穿过控制块;use_count() 只用于调试、绝不能进逻辑,没有共享需求时老老实实用 unique_ptr 更划算。

相关推荐
xailorliu3 小时前
SLIC_超像素算法
c++·opencv·目标检测·机器学习·计算机视觉
苏打豆3 小时前
CRISP源码阅读——基于ROS2力矩反馈控制器与速度加速度零空间
c++·线性代数·算法·矩阵·机器人
小小龙学IT4 小时前
snap7 开源西门子 S7 通信协议库深度解析
c++·开源
小溪学编程5 小时前
从 C++ 的规范变迁看语言发展
jvm·c++·面试
ShineWinsu6 小时前
对于Redis:string类型的解析
java·c++·redis·分布式·缓存·面试·string
YYYing.7 小时前
【设计模式系列 (七) 】桥接模式
c++·后端·设计模式·桥接模式
Zelman8 小时前
网络协议性能调优手册
网络协议·性能优化
高山有多高8 小时前
【Linux笔记】Linux自定义Shell
linux·c++