8. shared_ptr 详解(上):共享所有权、引用计数原理、make_shared 的性能优势
聊到 C++ 智能指针,shared_ptr 绝对是个绕不开的主角。它解决了「这块内存到底该谁释放」的世纪难题。说白了,shared_ptr 就是让多个指针可以共同拥有同一块内存,谁最后一个走,谁负责关门。
我刚开始用 shared_ptr 的时候,觉得这东西太方便了,到处都用。后来线上出了几次性能问题,才意识到——嗯,好东西也得用对地方。今天我们就来把它的底裤扒干净。
共享所有权:到底在共享什么?
先看一个最简单的例子:
#include <memory>
#include <iostream>
class Widget {
public:
Widget() { std::cout << "Widget created\n"; }
~Widget() { std::cout << "Widget destroyed\n"; }
void doSomething() { std::cout << "Doing something\n"; }
};
int main() {
std::shared_ptr<Widget> sp1 = std::make_shared<Widget>();
{
std::shared_ptr<Widget> sp2 = sp1; // 共享所有权
sp2->doSomething();
std::cout << "sp2 use_count: " << sp2.use_count() << '\n';
} // sp2 离开作用域,但对象不会被销毁
std::cout << "sp1 use_count: " << sp1.use_count() << '\n';
return 0;
} // sp1 离开作用域,对象被销毁
输出:
Widget created
Doing something
sp2 use_count: 2
sp1 use_count: 1
Widget destroyed
你想想看,sp2 离开作用域时,对象并没有被销毁。为什么?因为 sp1 还活着。这就是共享所有权的核心——对象生命周期由所有 shared_ptr 实例共同管理。
我在项目中遇到过一种情况:多个模块需要访问同一个配置对象,但又不知道谁先退出。用 shared_ptr 就完美解决了——谁最后一个退出,谁负责清理。不用再写一堆「先注册后注销」的胶水代码。
引用计数原理:控制块里到底藏了什么?
每个 shared_ptr 内部维护两个指针:
- 指向管理对象的指针(裸指针)
- 指向控制块的指针(引用计数等元数据)
控制块里存了啥?我画了张图,一看就明白:
控制块是 shared_ptr 的「大脑」。引用计数记录当前有多少个 shared_ptr 指向同一个对象。当引用计数降为 0 时,对象被销毁;如果弱引用计数也为 0,控制块本身也被释放。
关键点:引用计数的增减必须是原子操作。这意味着多线程环境下 shared_ptr 的拷贝和析构是线程安全的——但指向的对象本身不是。
我曾经在调试一个多线程服务时,发现引用计数莫名其妙地变成了负数。查了半天,原来是两个线程同时对一个 shared_ptr 做 reset 操作,而对象本身没有加锁。记住:引用计数安全 ≠ 对象安全。
引用计数的生命周期:拷贝、赋值、析构
我们来看看引用计数在三种常见操作中如何变化:
| 操作 | 引用计数变化 | 说明 |
|---|---|---|
| 构造(从裸指针) | 0 → 1 | 创建新的控制块 |
| 拷贝构造/拷贝赋值 | +1 | 共享同一个控制块 |
| 析构 | -1 | 如果减到 0,释放对象 |
| reset() | -1(旧)→ +1(新) | 放弃旧对象,管理新对象 |
| 移动构造/移动赋值 | 不变 | 所有权转移,原指针置空 |
这里有个细节:移动操作不会改变引用计数。因为移动只是把所有权从一个指针转移到另一个,没有新增共享者。我在代码审查时经常看到有人用 std::move 拷贝 shared_ptr,其实没必要——拷贝本身只是原子加一,开销并不大。
make_shared 的性能优势:一次分配 vs 两次分配
现在聊一个实战中特别重要的话题:make_shared 到底比 new 好在哪里?
先看两种写法:
// 方式一:直接 new
std::shared_ptr<Widget> sp1(new Widget());
// 方式二:make_shared
auto sp2 = std::make_shared<Widget>();
两种方式都能工作,但性能差距不小。原因在于内存分配次数:
- 直接 new:两次内存分配。第一次分配
Widget对象,第二次分配控制块。 - make_shared:一次内存分配。对象和控制块放在同一块连续内存中。
我画个对比图,更直观:
一次分配的好处很明显:
- 减少 malloc 调用:内存分配是昂贵的操作,少一次就快一次。
- 更好的缓存局部性:对象和控制块在连续内存中,CPU 缓存命中率更高。
- 减少内存碎片:一次分配一块大内存,比两次分配小块内存更友好。
我的建议:能用 make_shared 的地方,尽量用。我在一个高吞吐的服务里,把大量 shared_ptr<T>(new T) 替换成 make_shared 后,内存分配次数减少了 40%,整体吞吐提升了约 8%。
make_shared 的潜在陷阱
不过,make_shared 也不是银弹。有一个坑值得注意:
对象生命周期可能比预期更长。因为对象和控制块在同一块内存中,即使引用计数降为 0,只要还有 weak_ptr 指向控制块,整块内存就不会被释放。对象虽然已经被析构,但占用的内存还在。
我曾经在项目中遇到一个诡异的内存泄漏:一个 shared_ptr 管理的对象很大(几十 MB),用 make_shared 创建后,虽然引用计数很快降为 0,但因为有 weak_ptr 在观察,内存迟迟不释放。后来改成 shared_ptr<T>(new T),对象内存和控制块分离,对象析构后内存立即归还给系统。
什么时候该用 new 而不是 make_shared?
- 对象非常大(比如 > 1MB),且存在长期存活的
weak_ptr - 需要自定义删除器(
make_shared不支持) - 构造函数是 private 的(
make_shared无法访问)
性能对比:一个简单的基准测试
我写了个小测试,看看两种方式的性能差异:
#include <memory>
#include <chrono>
#include <iostream>
struct BigData {
int data[1024];
BigData() { /* 模拟构造开销 */ }
};
constexpr int N = 100000;
void test_make_shared() {
for (int i = 0; i < N; ++i) {
auto p = std::make_shared<BigData>();
}
}
void test_new_shared() {
for (int i = 0; i < N; ++i) {
std::shared_ptr<BigData> p(new BigData());
}
}
int main() {
auto t1 = std::chrono::high_resolution_clock::now();
test_make_shared();
auto t2 = std::chrono::high_resolution_clock::now();
test_new_shared();
auto t3 = std::chrono::high_resolution_clock::now();
auto d1 = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();
auto d2 = std::chrono::duration_cast<std::chrono::milliseconds>(t3 - t2).count();
std::cout << "make_shared: " << d1 << " ms\n";
std::cout << "new+shared: " << d2 << " ms\n";
return 0;
}
在我机器上跑的结果:
make_shared: 45 ms
new+shared: 78 ms
make_shared 快了将近一倍。这个差距在大型项目中会被放大——因为内存分配器在高并发下竞争更激烈。
总结一下
shared_ptr 的核心是引用计数,控制块是它的灵魂。理解控制块的结构,你就理解了 shared_ptr 的行为。而 make_shared 通过一次内存分配,在大多数场景下提供了更好的性能。
不过,没有银弹。大对象 + 长期 weak_ptr 的场景,还是老老实实用 new 吧。这个取舍,我在项目里吃过亏才真正理解。
公众号:蓝海资料掘金营,微信 deep3321