之前在知乎上看到一篇讨论,大意是说当前存在对 std::shared_ptr 的滥用现象,很多时候其实 std::unique_ptr 才是更合适的选择。这引发了我的思考。今天,我们就结合实际开发中的几个典型案例,聊聊 std::shared_ptr 可能遇到的“坑”以及对应的解决思路。
历史背景
shared_ptr 并非从一开始就是 C++ 标准库的成员。它最早于 1999 年出现在 Boost 库中,甚至早于 Boost 自身的版本号体系。在那个年代,标准库中唯一的“智能指针”是 auto_ptr。然而,auto_ptr 因其诡异的拷贝语义和极易导致悬空指针的问题而“声名狼藉”,在实际项目中几乎无人敢用。最终,auto_ptr 在 C++11 中被弃用,并在 C++17 中被彻底移除。
直到 C++11,boost::shared_ptr 才被正式纳入标准库,成为 std::shared_ptr。在随后的十多年里,它几乎成为了 C++ 世界中使用最广泛的智能指针。
核心原理浅析
从极度简化的视角看,shared_ptr 内部维护着两块关键信息:一块是指向堆上托管对象的指针,另一块是指向控制块(control block)的指针。控制块中包含了引用计数等关键的元数据。
其生命周期管理遵循几条基本规则:
- 当
shared_ptr 被拷贝构造时,其引用计数加一。
- 当
shared_ptr 被赋值时,右侧操作数的引用计数加一,左侧操作数的引用计数减一。
- 当
shared_ptr 被析构时,引用计数减一。
- 当引用计数降为 0 时,托管的堆对象被销毁。
示例一:警惕不必要的拷贝
许多人误以为使用 shared_ptr 只是简单的引用计数加减,因此常常无脑使用拷贝传递。我们来看看下面这个对比示例:
#include <memory>
#include <chrono>
#include <iostream>
using shared_ptr_t = std::shared_ptr<int>;
void receiver_by_value(shared_ptr_t ptr){
volatile int x = *ptr;
(void)x;
}
void receiver_by_ref(const shared_ptr_t& ptr){
volatile int x = *ptr;
(void)x;
}
void test_by_value(uint64_t n){
auto ptr = std::make_shared<int>(100);
for (uint64_t i = 0; i < n; ++i) {
receiver_by_value(ptr);
}
}
void test_by_ref(uint64_t n){
auto ptr = std::make_shared<int>(100);
for (uint64_t i = 0; i < n; ++i) {
receiver_by_ref(ptr);
}
}
int main(){
uint64_t n = 1000000;
{
auto start = std::chrono::high_resolution_clock::now();
test_by_value(n);
auto end = std::chrono::high_resolution_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " us\n";
}
{
auto start = std::chrono::high_resolution_clock::now();
test_by_ref(n);
auto end = std::chrono::high_resolution_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " us\n";
}
}
输出结果可能类似于:
29460 us
2359 us
结论非常明显:在同一台机器上,按值传递的耗时在毫秒级,而按常量引用传递的耗时几乎可以忽略不计。 因此,一个重要的优化准则是:除非你明确需要共享所有权(例如跨线程传递),否则应当通过 const 引用传递 shared_ptr,以避免不必要的原子操作开销。
示例二:优先使用 std::make_shared
不少开发者会这样创建 shared_ptr:
auto ptr = std::shared_ptr<int>(new int(42));
这段代码看起来没问题,但实际上它触发了两次独立的堆内存分配:
- 一次用于
int 对象本身。
- 一次用于
shared_ptr 的控制块。
这不仅增加了分配成本,还降低了数据的缓存局部性。std::make_shared 正是为了解决这个问题而设计的:
auto ptr = std::make_shared<int>(42);
make_shared 会尝试将对象和控制块分配在连续的内存区域中,通常只需一次内存分配。我们可以通过一个简单的测试来感受差异:
#include <memory>
#include <vector>
#include <iostream>
void test_ctor(size_t n){
std::vector<std::shared_ptr<size_t>> v;
v.reserve(n);
for (size_t i = 0; i < n; ++i) {
v.emplace_back(std::shared_ptr<size_t>(new size_t(i)));
}
}
void test_make_shared(size_t n){
std::vector<std::shared_ptr<size_t>> v;
v.reserve(n);
for (size_t i = 0; i < n; ++i) {
v.emplace_back(std::make_shared<size_t>(i));
}
}
使用 valgrind 等工具测试上述代码(例如 n=100000),可以观察到,使用构造函数直接创建对象时内存分配次数约为 200003 次,而使用 std::make_shared 创建时,内存分配次数约为 100003 次,优势显著。
示例三:小心循环引用
循环引用是 shared_ptr 的经典陷阱。请看以下代码:
struct B;
struct A {
~A() { std::cout << "~A\n"; }
std::shared_ptr<B> b;
};
struct B {
~B() { std::cout << "~B\n"; }
std::shared_ptr<A> a;
};
void test(){
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b = b;
b->a = a;
}
这是一个典型的循环引用场景。当 test 函数结束时,局部变量 a 和 b 的引用计数各减为1(因为彼此持有对方的 shared_ptr),并未归零,因此它们的析构函数永远不会被调用,导致内存泄漏。这种问题在大型、复杂的对象关系图中往往隐藏极深。
解决循环引用的标准方法是引入 std::weak_ptr。weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个由 shared_ptr 管理的对象,但不会增加其引用计数。
struct B;
struct A {
~A() { std::cout << "~A\n"; }
std::shared_ptr<B> b;
};
struct B {
~B() { std::cout << "~B\n"; }
std::weak_ptr<A> a; // 将 shared_ptr 改为 weak_ptr
};
将 B 中指向 A 的指针改为 weak_ptr 后,循环被打破。当 test 函数结束时,a 的引用计数首先归零并被销毁,随后 b 的引用计数也归零。在需要访问 A 对象时,可以通过 weak_ptr::lock() 方法尝试获取一个临时的 shared_ptr。切记,必须检查 lock() 的返回值是否为空,这是唯一线程安全的做法。
示例四:make_shared 与 weak_ptr 的联用细节
make_shared 和 weak_ptr 单独使用都是极佳实践,但将它们结合使用时,有一个容易被忽略的细节。
如前所述,make_shared 将对象和控制块分配在同一块内存中。这意味着,只要还有 weak_ptr 指向该控制块,这块完整的内存(包含对象和控制块)就不能被释放,即使对象本身的析构函数已经被调用。这会带来以下影响:
- 对象的析构函数会被如期调用。
- 对象所占用的内存却依然被保留。
- 如果通过某些非法手段获取了原始指针,它可能“看起来”仍然指向有效内存,但对象状态已失效。
cppreference 中对这种现象的描述如下:
If any std::weak_ptr references the control block created by std::make_shared after the lifetime of all shared owners ended, the memory occupied by T persists until all weak owners get destroyed as well, which may be undesirable if sizeof(T) is large.
看一个具体例子:
std::weak_ptr<LargeType> weakPtr;
{
auto sharedPtr = make_shared<LargeType>();
weakPtr = sharedPtr;
// ...
} // sharedPtr 析构,LargeType对象析构函数被调用,但其内存未被释放
在上面的代码中,离开作用域后,LargeType 对象的内存并没有被立即释放,而是会持续到最后一个 weakPtr 被销毁。
而如果使用分开分配的方式:
std::weak_ptr<LargeType> weakPtr;
{
auto sharedPtr = std::shared_ptr<LargeType>(new LargeType); // 两次分配
weakPtr = sharedPtr;
// ...
} // sharedPtr 析构,LargeType对象立即被销毁且其内存被释放(控制块内存会等weakPtr销毁)
在这种情况下,由于对象内存和控制块内存是分开的,当所有 shared_ptr 销毁后,对象内存会立即被回收,只有控制块内存会等待 weak_ptr 释放。
因此,在设计需要大量、长期存在 weak_ptr 且托管对象体积 (sizeof(T)) 非常大的场景时,需要权衡是否使用 make_shared。这是内存管理中一个高级但重要的考量点。
总结
std::shared_ptr 是一个强大的工具,但“能力越大,责任越大”。盲目滥用会导致性能损耗和难以察觉的内存问题。记住几个关键点:非必要不拷贝、优先使用 make_shared、用 weak_ptr 打破循环引用、了解内存延迟释放的细节。在实际的 C++ 项目开发中,结合 RAII 等理念审慎地使用智能指针,才能构建出既安全又高效的程序。希望这些来自实践的经验和思考,能帮助你在云栈社区及其他技术交流中更好地驾驭 C++ 的内存管理艺术。