18. 智能指针的性能开销:它到底慢在哪里?
很多人觉得智能指针就是「带自动释放的普通指针」,用起来方便,性能应该也差不多。
嗯,这个想法其实有点危险。
我早年在一个高帧率渲染引擎里吃过这个亏——满屏的 shared_ptr 传来传去,帧率直接掉了 15%。当时我还纳闷,明明逻辑没变,怎么就卡了?后来一分析,全是智能指针的「隐形开销」在作祟。
今天咱们就掰开揉碎,看看智能指针到底慢在哪里。
18.1 智能指针 vs 原始指针:一个直观对比
先看一个最简单的例子:
// 原始指针
void raw_pointer_demo() {
int* p = new int(42);
// 使用 p
delete p;
}
// unique_ptr
void unique_ptr_demo() {
auto p = std::make_unique<int>(42);
// 使用 p
// 自动释放
}
// shared_ptr
void shared_ptr_demo() {
auto p = std::make_shared<int>(42);
// 使用 p
// 引用计数归零时释放
}
从功能上看,三者都能完成任务。但从性能角度看,差距就出来了。
核心结论:
unique_ptr在大多数情况下零开销,和原始指针性能一致。shared_ptr有显著开销,主要来自引用计数的原子操作和控制块的内存分配。weak_ptr的开销和shared_ptr类似,因为它需要访问同一个控制块。
18.2 开销到底来自哪里?
我习惯把智能指针的开销分成三类:内存开销、时间开销、控制流开销。
18.2.1 内存开销
unique_ptr 的大小通常和原始指针一样(除非你用了自定义删除器且删除器有状态)。但 shared_ptr 就不一样了。
一个 shared_ptr 内部包含两个指针:
- 一个指向管理的对象
- 一个指向控制块(control block)
控制块里存了什么?
- 引用计数(通常是
atomic类型) - 弱引用计数
- 删除器(如果用了自定义删除器)
- 分配器(如果用了自定义分配器)
所以一个 shared_ptr 的大小是原始指针的 2 倍(16 字节 vs 8 字节,64 位系统下)。控制块本身还要额外分配内存。
我的经验: 如果你用 make_shared,控制块和对象本身会分配在同一块内存里,能省一次分配开销。但代价是——只要还有 weak_ptr 活着,整个内存块就不能释放,即使对象已经析构了。
18.2.2 时间开销
时间开销主要来自引用计数的操作。每次拷贝、赋值、析构 shared_ptr,都要对引用计数做原子操作。
原子操作本身不慢,但架不住次数多。我举个例子:
void process(std::shared_ptr<Data> sp) {
// 进入函数:引用计数 +1(原子操作)
// 函数结束:引用计数 -1(原子操作)
}
void batch_process() {
auto sp = std::make_shared<Data>();
for (int i = 0; i < 1000000; ++i) {
process(sp); // 每次调用都涉及两次原子操作
}
}
这个循环里,process 被调用 100 万次,每次两次原子操作,总共 200 万次。原子操作虽然比普通操作慢不了太多,但 200 万次累积下来,延迟就很可观了。
注意: 原子操作会阻止编译器优化和 CPU 指令重排。在高并发场景下,频繁的原子操作还可能引发缓存行乒乓(cache line bouncing),性能下降更明显。
18.2.3 控制流开销
这个容易被忽略。智能指针的析构函数里,需要判断引用计数是否归零,归零了还要调用删除器。这些分支判断和函数调用,虽然单次开销很小,但在热路径上会累积。
我曾经在一个网络库中看到,shared_ptr 的析构函数占到了总 CPU 时间的 8%。当时我们只是用它来管理连接对象,没想到开销这么大。
18.3 一张图看懂智能指针的性能差异
下面这张图总结了三种智能指针在不同维度上的开销对比:
18.4 如何避免不必要的性能损失?
知道了问题在哪,解决方案就清晰了。我总结了几个实战经验:
18.4.1 能用 unique_ptr 就别用 shared_ptr
这是最直接的优化。很多场景其实不需要共享所有权,只是习惯性地用了 shared_ptr。
// 不好的做法:不需要共享却用了 shared_ptr
std::shared_ptr<Config> load_config() {
return std::make_shared<Config>("config.json");
}
// 好的做法:用 unique_ptr
std::unique_ptr<Config> load_config() {
return std::make_unique<Config>("config.json");
}
18.4.2 传引用,别传值
如果函数不需要拥有对象的所有权,传 const 引用或原始指针就行。
// 不好的做法:每次调用都增加引用计数
void process(std::shared_ptr<Data> sp);
// 好的做法:传引用
void process(const std::shared_ptr<Data>& sp);
一个小细节: 传引用时,如果函数内部需要把 shared_ptr 存起来(比如放入容器),那还是得拷贝一次。但至少你控制了拷贝发生的时机,而不是每次调用都无谓地增减计数。
18.4.3 用 make_shared 代替 new
这个我反复强调过:
// 两次内存分配
std::shared_ptr<Data> sp1(new Data());
// 一次内存分配(推荐)
auto sp2 = std::make_shared<Data>();
make_shared 把对象和控制块分配在同一块内存里,不仅省了一次 malloc,还提高了缓存局部性。
18.4.4 避免在热路径上拷贝 shared_ptr
我在项目中遇到过这样一个场景:一个高频调用的回调函数里,每次都会拷贝 shared_ptr。后来改成传引用,性能提升了 12%。
// 热路径上的错误写法
void on_event(std::shared_ptr<Session> session) {
// 每次事件触发都拷贝一次
session->send(data);
}
// 优化后
void on_event(const std::shared_ptr<Session>& session) {
session->send(data);
}
18.5 什么时候该用 shared_ptr?
说了这么多开销,不是让你别用 shared_ptr。该用的时候还得用。
我个人习惯是:
- 对象生命周期明确、所有权唯一 →
unique_ptr - 对象需要被多个模块共享,且生命周期不确定 →
shared_ptr - 需要观察对象是否还活着,但不延长生命周期 →
weak_ptr
说白了,性能开销是客观存在的,但大多数业务场景下这点开销根本不算什么。只有在高频调用、内存敏感、实时性要求高的场景下,才需要认真考虑这些细节。
一句话总结: 智能指针的性能开销,核心就是 shared_ptr 的原子操作和控制块内存。用 unique_ptr 替代 shared_ptr、传引用代替传值、用 make_shared 代替 new,这三招能解决 90% 的性能问题。
公众号:蓝海资料掘金营,微信deep3321