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<T> sp1 shared_ptr<T> sp2 T 对象(数据) 控制块 引用计数: 2 弱引用计数: 0 删除器 分配器 (make_shared 时 对象和控制块在一起)

控制块是 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:一次内存分配。对象和控制块放在同一块连续内存中。

我画个对比图,更直观:

直接 new:两次分配 T 对象(堆上) 控制块(堆上) 分配 1 分配 2 make_shared:一次分配 T 对象 控制块 | 一次分配,连续内存

一次分配的好处很明显:

  1. 减少 malloc 调用:内存分配是昂贵的操作,少一次就快一次。
  2. 更好的缓存局部性:对象和控制块在连续内存中,CPU 缓存命中率更高。
  3. 减少内存碎片:一次分配一块大内存,比两次分配小块内存更友好。

我的建议:能用 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