文章

智能指针

智能指针

[TOC]

1 什么是智能指针

C++ 没有垃圾回收机制,需要程序员自己释放和分配内存,否则就会造成内存泄漏。

智能指针是指向动态对象的指针,当其应该被释放时,智能指针可以确保自动释放内存,不需要手动释放,避免内存泄漏问题,更加容易也更加安全地使用动态内存。

智能指针的本质是类模板,当智能指针所指向的对象使用完后,对象会自动调用析构函数去释放指针所指向的空间。这一机制背后的核心思想是 RAII(Resource Acquisition Is Initialization,资源获取即初始化):将资源的生命周期绑定到对象的生命周期上,构造函数获取资源,析构函数释放资源。这样做的一个重要收益是异常安全:即使函数因异常提前退出,栈展开时析构函数也会被自动调用,资源不会泄漏,而这正是裸指针手动管理最容易被忽略的薄弱环节。

以下是智能指针基本框架,所有智能指针类模板都包含一个对象指针、构造函数、析构函数:1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>  

using namespace std;
template <typename T>
class SmartPtr {
public:
	SmartPtr(T* p) :ptr(p) {}  //构造函数  
	~SmartPtr() 
	{ //析构函数  
		if (ptr != nullptr) 
		{ 
			cout << "smartprt: delete" << endl;
			delete ptr;
			ptr = nullptr;
		}
	}
private:
	T* ptr;   //指针对象  
};
int main(int argc, char* argv[]) 
{
	SmartPtr<int> ptr_int(new int(1));  //指向int类型的智能指针  
	SmartPtr<string> ptr_string(new string("abc"));   //指向string类型的智能指针  
	return 0;
}

2 智能指针的类型

现行可用的智能指针包含三种:unique_ptrshared_ptrweak_ptr,以下依次介绍了这三种智能指针的大概原理和基本用法。

还有一种 auto_ptr,他是 C++98 的智能指针,C++11 已抛弃,故本文中不做介绍。

我在 VS2022 中新建了一个控制台程序,可以直接调用上述指针。但是若提示报错的话,就需要 #include <memory>

2.1 unique_ptr

注意 unique_ptr 是独占对象的所有权的,它不允许其他的智能指针共享其内部的指针。就是在某个时候一定只有一个 unique_ptr 指向一个特定的对象,当 unique_ptr 被销毁的时候它所指向的对象也会被销毁2

unique_ptr 直接禁用了拷贝构造函数和拷贝赋值运算符1。可以通过它的构造函数初始化一个独占智能指针对象,但是不允许通过赋值将一个 unique_ptr 赋值给另一个 unique_ptr3

能使用 unique_ptr 时就不要使用 share_ptr 指针(后者需要保证线程安全,所以在赋值或销毁时 overhead 开销更高)4

(1)构造方式

1
2
3
4
5
6
7
 std::unique_ptr<Entity> e1 = new Entity();        //不合法,构造函数是 explicit 的,禁止隐式转换  
 std::unique_ptr<Entity> e1(new Entity());         //OK   
 std::unique_ptr<Entity> e1 = std::make_unique<Entity>();  //首选   
 auto e1 = std::make_unique<Entity>();             //首选(std::make_unique 是 C++14 引入的)
 std::unique_ptr<Entity> e2 = e1;                  //不合法,指针是不能复制的   
 std::unique_ptr<Entity> e2 = std::move(e1);       //可移动,所有权转移    
 func(std::move(e1));                              //这样函数传参:所有权转移 

(2)其他成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
 //通过函数返回的方式初始化    
 unique_ptr<int> SmartPtr = func();
 //解除对原始内存的管理    
 SmartPtr.reset();
 //重新指定智能指针管理的原始内存    
 SmartPtr.reset(new int(2));
 //get()方法可以获取智能指针管理的原始地址    
 cout << "ptr addr = " << SmartPtr.get() << endl;
 cout << "ptr content = " << *SmartPtr.get() << endl;
 //放弃对指针的控制权返回指针,并将自身置为空。  
 //共享指针释放,内存不释放:  
 SmartPtr.release();
 SmartPtr = nullptr;

(3)使用方法

可通过智能指针直接调用类内的成员函数。也可以通过 get() 函数取得原始资源的指针,再通过该指针进行调用。

在参考文章5中有用法示例的代码。

2.2 shared_ptr

shared_ptr 实现了共享拥有的概念,利用 “引用计数” 来控制堆上对象的生命周期。允许多个 shared_ptr 指向同一块资源,并且保证共享资源只会被释放一次,所以程序不会崩溃1

原理:在初始化的时候引用计数设为 1,每当被拷贝或者赋值的时候引用计数 +1,析构的时候引用计数 -1,直到引用计数被减到 0,那么就可以 delete 掉对象的指针了。

每个 shared_ptr 都有两个指针,一个原始指针,一个计数区域的指针(SharedPtrControlBlock)5

(1)提供的函数

构造 shared_ptr 的方法:

1
2
3
4
5
6
7
8
9
 std::shared_ptr<Entity> e1(new Entity());                 //OK    
 std::shared_ptr<Entity> e1 = std::make_shared<Entity>();  //首选
 auto e1 = std::make_shared<Entity>();                     //首选    
 std::shared_ptr<Entity> e2 = e1;                          //可复制,计数+1    
 std::shared_ptr<Entity> e2 = std::move(e1);               //可移动,计数不变   
 e2.reset();                   //释放托管对象的所有权(若有),计数-1  
 e2.reset(new Entity());       //用新的指针代替原先托管的对象  
 func(std::move(e1));          //这样函数传参:计数不变    
 func(e1);                     //这样函数传参:计数+1 

创建新的 shared_ptr 对象的最佳方法是使用 std :: make_shared,因为 std::make_shared 一次性为 int 对象和用于引用计数的数据都分配了内存,最为高效,而 new 操作符只是为 int 分配了内存6

详细解释下为什么使用make_shared 的创建方式会更加的高效了,在我们是使用 new 的方法去初始化一个 shared_ptr 的时候,我们需要先在堆上申请分配一块内存,用来存储对象,然后用这个变量调用 share_ptr 的构造函数,再次分配一次内存,而使用 make_shared 就只需要分配一次内存就可以了2

关于智能指针调用 reset 的初始化方式,这个函数在智能指针没有值的时候调用是用来初始化的,当这个智能指针有值的时候,调用 reset 函数就会引起原有对象智能指针的引用计数 -12

shared_ptr 的其他成员函数:

1
2
3
4
5
6
 use_count() //返回引用计数的个数  
 unique()    //返回是否是独占所有权(use_count是否为1)  
 swap()      //交换两个shared_ptr对象(即交换所拥有的对象,引用计数也随之交换)  
 reset()     //放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少 
 get()       //返回存储的指针。
             //存储的指针指向shared_ptr对象解引用的对象,一般与其拥有的指针相同

(2)使用方法

1
2
3
4
5
6
 std::shared_ptr<entity> e = std::make_shared<entity>();
 //使用方法一,取得原始资源进行使用:  
 entity * t = e.get();
 t->func();   //func()是entity类内的成员函数  
 //使用方法二,可通过智能指针直接调用类内的成员函数  
 e->func();   //func()是entity类内的成员函数 

一定要谨慎的使用 get() 函数,当我们用一个裸指针上面的 (entity*) 来保存地址的时候,我们没有办法掌握ptr会在什么时候释放这块内存地址,这样我们在使用的过程中就会产生不可预知的错误。如果我们获取了这个裸指针,一不小心调用了 delete,就会导致同一块内存地址析构了两次。如果我们用一个 shared_ptr 来保存这个地址,那么我们就相当于又有了一个从 1 开始计数的 shared_ptr 与 ptr 都指向这块内存,最终的结果就是调用两次析构2

在参考文章5中有用法示例的代码。

(3)其他

使用 make_shared 的优势和劣势,见参考文章5

2.3 weak_ptr

weak_ptr 不共享指针,不能操作资源,它的构造不会增加引用计数,析构也不会减少引用计数,它的主要作用是监测 shared_ptr 所管理的资源是否存在1

C++11 标准虽然将 weak_ptr 定位为智能指针的一种,但该类型指针通常不单独使用(没有实际用处),只能和 shared_ptr 类型指针搭配使用。甚至可以说,weak_ptr 是为了辅助 shared_ptr 的存在5,它不管理 shared_ptr 内部的指针,借助 weak_ptr 类型指针,我们可以获取 shared_ptr 指针的一些状态信息,比如有多少指向相同的 shared_ptr 指针、shared_ptr 指针指向的堆内存是否已经被释放等等7

需要注意的是,当 weak_ptr 类型指针的指向和某一 shared_ptr 指针相同时,weak_ptr 指针并不会使所指堆内存的引用计数加 1;同样,当 weak_ptr 指针被释放时,之前所指堆内存的引用计数也不会因此而减 1。也就是说,weak_ptr 类型指针并不会影响所指堆内存空间的引用计数7

除此之外,weak_ptr<T> 模板类中没有重载 *-> 运算符,因为他不共享指针,不能操作资源,只能访问所指的堆内存,而无法修改它7,所以它的构造不会增加引用计数,析构也不会减少引用计数,它的主要作用就是作为一个旁观者监视 shared_ptr 中管理的资源是否存在3

利用 weak_ptr 可以解决 shared_ptr 的一些问题:

  • 返回管理 this 的 shared_ptr1
  • 解决循环引用问题,避免内存泄漏1

(1)函数

构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 //创建一个空weak_ptr指针:  
 std::weak_ptr<int> wp1;
 
 //凭借已有weak_ptr指针,创建一个新的weak_ptr指针:  
 std::weak_ptr<int> wp2(wp1);
 //若 wp1 为空指针,则 wp2 也为空指针;  
 //反之,如果 wp1 指向某一 shared_ptr 指针拥有的堆内存,  
 //则 wp2 也指向该块存储空间(可以访问,但无所有权)。  
 
 //weak_ptr 指针更常用于指向某一 shared_ptr 指针拥有的堆内存,  
 //因为在构建 weak_ptr 指针对象时,  
 //可以利用已有的 shared_ptr 指针为其初始化。例如:  
 std::shared_ptr<int> sp(new int);
 std::weak_ptr<int> wp3(sp);
 //由此,wp3 指针和 sp 指针有相同的指针。  
 //再次强调,weak_ptr 类型指针不会导致堆内存空间的引用计数增加或减少。

其他常用的成员方法及各自功能见下面的表格7

成员方法 功能
operator=() 重载 = 赋值运算符, weak_ptr 指针可以直接被 weak_ptr 或者 shared_ptr 类型指针赋值。
swap(x) 其中 x 表示一个同类型的 weak_ptr 类型指针,该函数可以互换 2 个同类型 weak_ptr 指针的内容。
reset() 将当前weak_ptr指针置为空指针(清空对象,使其不监测任何资源)。
use_count() 查看指向和当前 weak_ptr 指针相同的 shared_ptr 指针的数量。
expired() 判断当前 weak_ptr 指针为否过期,判断观测的资源是否已经被释放。
lock() 获取管理所监测资源的 shared_ptr 对象。如果当前 weak_ptr 已经过期,则该函数会返回一个空的 shared_ptr 指针;反之,该函数返回一个和当前 weak_ptr 指向相同的 shared_ptr 指针。
operator=() 重载 = 赋值运算符,是的 weak_ptr 指针可以直接被 weak_ptr 或者 shared_ptr 类型指针赋值。

(2)使用方法

在参考文章5中有用法示例的代码。

2.4 auto_ptr

auto_ptr 是管理权转移的思想,即原对象拷贝给新对象,原对象就会被设置成 nullptr。此时就只有新对象指向资源空间。如果此时再去调用原对象,那整个程序会崩溃,所以现在很多公司禁用 auto_ptr1。不仅如此,在 C++11 中,该智能指针已被弃用。

注意:不要使用 std::auto_ptr5

2.5 小结

类型 描述
unique_ptr 独占所指向的对象。同一时间只有一个智能指针能指向该对象,禁止指针的拷贝。
shared_ptr 共享指针,强引用。允许多个指针指向同一个对象,使用计数机制记录被共享指针数,对象与资源在最后一个引用被销毁时释放。
weak_ptr 弱引用,不参与引用计数,不能直接操作资源。纯粹作为观察者监视 shared_ptr 所管理的对象是否存活,本身不参与内存管理。
auto_ptr 【已弃用】采用所有权模式,可以剥夺所有权,即当对象拷贝或者赋值后,前面的对象就悬空了。

(1)从指针与对象的对应关系进行分类4

一种是可以使用多个智能指针管理同一块内存区域,每增加一个智能指针,就会增加 1 次引用计数。shared_ptr 属于这种。

另一类是不能使用多个智能指针管理同一块内存区域,通俗来说,当智能指针 2 来管理这一块内存时,原先管理这一块内存的智能指针 1 只能释放对这一块指针的所有权。auto_ptrunique_ptr 属于这种。注意 weak_ptr 不属于以上任何一类,因为它根本不参与内存管理,仅作为观察者存在。

(2)从是否引用计数的角度进行分类8

  • 不带引用计数的智能指针:

    auto_ptr:不推荐使用,且 C++11 标准中被抛弃。

    scoped_ptr:不支持拷贝构造,和赋值重载,实现到私有权限,无法访问,后期 g++ 10 没有该对象了。

    unique_ptr:推荐使用

  • 带引用计数:

    多个智能指针可以管理同一个资源,每个对象资源匹配一个引用计数,给一个资源做赋值或者拷贝构造的时候,引用计数加 1。一个资源出了它的作用域之后,引用计数减 1,如果引用计数 count == 0,资源就释放了。

    shared_ptr:强智能指针,可以改变资源的引用计数。

    weak_ptr:弱智能指针,不会改变资源的引用计数。

    定义对象的时候,用强智能指针;引用对象的地方使用弱指针,注意:弱智能指针是无法调用对象的成员的,它只是一个观察的作用,可以在使用的时候升级为强指针8

    std::shared_ptr<A> ps = _ptra.lock();

3 删除器

智能指针初始化的时候可以指定删除动作,这个删除操作对应的函数是删除器。删除器本质是一个回调函数,我们只需进行实现,其调用是由智能指针完成。

在 C++11 中,当智能指针指向数组时,需要指定删除器,因为默认删除器不支持数组对象(delete 无法正确释放 new[] 分配的内存)。自定义删除器可以是函数指针、仿函数、lambda、包装器1

3.1 shared_ptr 的删除器

shared_ptr 的删除器只需要在构造时传入即可,不需要将删除器类型写入模板参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方式一:lambda 表达式(最常用)
std::shared_ptr<int> sp1(new int[10], [](int* p) {
    delete[] p;
});

// 方式二:使用标准库提供的 std::default_delete
std::shared_ptr<int> sp2(new int[10], std::default_delete<int[]>());

// 方式三:管理 FILE* 等非 new 分配的资源
std::shared_ptr<FILE> sp3(fopen("test.txt", "r"), [](FILE* fp) {
    if (fp) fclose(fp);
});

// 方式四:普通函数
void array_deleter(int* p) { delete[] p; }
std::shared_ptr<int> sp4(new int[10], array_deleter);

当我们用 shared_ptr 管理数组的时候,一定要指定删除器2

3.2 unique_ptr 的删除器

unique_ptr 指定删除器的方式与 shared_ptr 不同,删除器类型需要作为模板参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
// 方式一:使用 std::default_delete(无需额外指定)
std::unique_ptr<int[]> up1(new int[10]);  // unique_ptr 对数组有特化版本

// 方式二:自定义删除器作为模板参数(lambda 需用 decltype)
auto deleter = [](FILE* fp) { if (fp) fclose(fp); };
std::unique_ptr<FILE, decltype(deleter)> up2(fopen("test.txt", "r"), deleter);

// 方式三:使用函数指针作为删除器
std::unique_ptr<FILE, void(*)(FILE*)> up3(
    fopen("test.txt", "r"),
    [](FILE* fp) { if (fp) fclose(fp); }
);

unique_ptr 指定删除器的时候,需要确定删除器的类型2,这正是它与 shared_ptr 删除器最大的使用差异:shared_ptr 的类型擦除机制使得删除器类型不必出现在指针类型中,使用起来更灵活。

4 std::enable_shared_from_this

std::enable_shared_from_this 是一个模板类,它在标准库(STL)中提供,用于 让类对象能安全地生成自身的 std::shared_ptr 实例9

当你希望 类的实例能够从内部成员函数中获取指向自身的 std::shared_ptr 时,这个特性非常有用。为了这样做,该类必须继承自 std::enable_shared_from_this

下面是一个简单的例子,展示了如何用 std::enable_shared_from_this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <memory>
#include <iostream>

class MyClass : public std::enable_shared_from_this<MyClass>
{
public:
    std::shared_ptr<MyClass> get_shared() {
        return shared_from_this();
    }

    void do_something() {
        // 在这里,你可以安全地使用 shared_from_this
        std::shared_ptr<MyClass> sharedPtr = get_shared();
        // ...
    }

    ~MyClass() {
        std::cout << "MyClass destroyed" << std::endl;
    }
};

int main() {
    std::shared_ptr<MyClass> myInstance = std::make_shared<MyClass>();
    myInstance->do_something();
    return 0;
}

在这个例子中,MyClass 继承自 std::enable_shared_from_this<MyClass>。这允许 MyClass 的实例在成员函数do_something 中安全地调用 get_shared 方法, get_shared 会返回一个新的 std::shared_ptr<MyClass>,指向调用方法的对象实例。这是安全的,因为 myInstance 已经是通过 std::make_shared 创建的 std::shared_ptr

请注意,为了能够安全地使用 shared_from_this,对象必须已经被 std::shared_ptr 管理。如果对象不是由 std::shared_ptr 创建的,调用 shared_from_this 将会抛出一个 std::bad_weak_ptr 异常。

5 深入理解 shared_ptr 的底层机制

前面的章节把三种智能指针怎么用讲完了,但有几个问题一直没展开:shared_ptr 为什么比 unique_ptr 慢?make_shared 到底高效在哪里?引用计数是怎么做到线程安全的?这也就是这章的内容。

5.1 一个 shared_ptr 其实有两个指针

直觉上 shared_ptr 内部就是一个 T* 指针而已。实际上它里面通常有两个指针,一个指向你管理的对象,一个指向 “控制块”(Control Block)。

控制块是 shared_ptr 的精髓,它是一块在堆上独立分配的内存,存了四样东西:

  • 强引用计数:当前有多少个 shared_ptr 指向这个对象
  • 弱引用计数:当前有多少个 weak_ptr 在观察这个对象
  • 删除器:析构时怎么回收资源
  • 分配器:控制块自己的内存分配器

所有指向同一对象的 shared_ptr 共享同一个控制块,这就是 “共享” 的真正含义:不是共享对象本身,而是共享对对象生命周期的控制权。

因为要多存一个指向控制块的指针,shared_ptr 在 64 位系统上通常是 16 字节(两个 8 字节指针),而 unique_ptr 只有 8 字节。这 8 字节的差距平时感觉不到,但如果一个 vector 里存了百万个 shared_ptr,那就是 8MB 的纯内存开销,差距还是很明显的。

5.2 make_shared 为什么高效(以及它唯一的坑)

2.2 节提过一句 make_sharednew 高效,这里把原因说透。

new 构造 shared_ptr 时,会发生两次堆分配

  1. new T :在堆上给 T 对象分配内存
  2. shared_ptr 构造函数内部:在堆上给控制块再分配一块内存

两块内存不连续、两次系统调用、析构时两次释放。

make_shared<T>() 时,只发生一次堆分配,把控制块和 T 对象挤在一整块连续内存里:

1
2
3
4
5
6
new shared_ptr<T> 的方式:
  堆区 A:  [T 对象]
  堆区 B:  [控制块]         ← 两块内存,不连续

make_shared<T> 的方式:
  堆区:    [控制块 | T 对象]  ← 一整块连续内存

一次分配不仅少一次系统调用,而且控制块和数据在连续内存里对 CPU 缓存更友好,这两个原因加起来,就是标准推荐 make_shared 的全部理由。

但 make_shared 有一个坑:

因为控制块和 T 对象共享同一块内存,当所有 shared_ptr 都析构、强引用计数归零时:

  • T 的析构函数正常执行
  • 但如果有 weak_ptr 还活着,整块内存不能释放,因为控制块还不能销毁,而控制块和 T 对象的内存是连在一起分配的

这也就意味着,假设你有一个 500MB 的大对象,某个 weak_ptr 一直没释放。对象的析构函数跑了、逻辑上已经死了,但那 500MB 内存就是收不回来。

如果用 new 构造就不会有这个问题,控制块和 T 对象是分开分配的,shared_ptr 全析构后 T 对象的内存立即释放,weak_ptr 只吊着一个几十字节的小控制块。

1
2
3
4
5
6
7
new 方式:
  shared_ptr 全析构 → T 对象内存立即释放(500MB 回来了)
  weak_ptr 还活着  → 控制块继续存活(几十字节),无伤大雅

make_shared 方式:
  shared_ptr 全析构 → T 析构函数跑了,但内存还在(500MB 收不回来!)
  weak_ptr 还活着  → 整块内存(控制块 + T 对象)都不能释放

结论:默认用 make_shared。但如果你的对象内存很大,同时存在 weak_ptr 长期挂着不释放的情况,换用 new 构造,让对象内存可以提前回收。 这个 trade-off 要在做架构设计时心里有数。

5.3 引用计数的原子操作不是免费的

shared_ptr 的引用计数必须线程安全,多个线程同时拷贝同一个 shared_ptr 时,计数加减不能乱。标准库用原子操作(std::atomic)来实现这个保证。

原子操作的问题是:普通整数加减几个 CPU 周期就完了,原子操作在多核 CPU 上会引起缓存行(Cache Line)在核间同步,开销高出一个数量级。更关键的是,即使你只在单线程里用 shared_ptr,编译器也不会把原子操作优化成普通操作。每次拷贝和析构,原子指令照跑不误。

那什么时候该在乎这个开销?

  • 偶尔创建几个 shared_ptr 传来传去:无所谓,感知不到
  • 热点循环里频繁拷贝 shared_ptr :把函数参数改成 const shared_ptr<T>&,省掉无谓的计数加减
  • 函数参数不想参与所有权 :别传 shared_ptr<T>,传 T*const T&

归纳成一句话:shared_ptr 管生命周期,裸指针/引用管访问。 两者各司其职,别让 shared_ptr 染指只用访问不需要所有权的场景。

6 小结

当你需要一个独占资源所有权(访问权+生命控制权)的指针,且不允许任何外界访问,使用 std::unique_ptr;

当需要一个共享资源所有权(访问权+生命控制权)的指针,使用 std::shared_ptr;

当需要一个能访问资源,但不控制其生命周期的指针,请使用 std::weak_ptr

推荐用法5:一个 shared_ptr 和 n 个 weak_ptr 搭配使用 而不是 n 个 shared_ptr,因为一般模型中,最好总是被一个指针控制生命周期,然后可以被 n 个指针控制访问。这样从逻辑上看,大部分模型的生命在直观上总是受某一样东西直接控制而不是多样东西共同控制。从程序上看,也能够完全避免生命周期互相控制引发的循环引用问题。

7 一些智能指针相关的问题

7.1 智能指针怎么解决交叉引用造成的内存泄漏

  • 循环引用的本质:多个对象通过shared_ptr互相引用,形成引用环,导致引用计数无法降到 0,析构函数不执行,引发内存泄漏;
  • 核心解决方法:将循环引用链中至少一方的shared_ptr替换为weak_ptr(弱引用不增加计数,打破引用环);
  • weak_ptr 使用要点:需通过lock()获取shared_ptr才能访问对象,且lock()会检查对象是否存活,避免野指针。

简单来说:shared_ptr的循环引用是 “计数降不到 0”,weak_ptr是 “不参与计数,打破循环”。

什么叫交叉引用

两个或多个对象 通过shared_ptr互相持有对方的引用,形成一个 “引用环” 时,就会发生循环引用。此时每个对象的引用计数都至少为 1(因为互相引用),导致它们的引用计数永远无法降到 0,析构函数不会被调用,最终造成内存泄漏

代码示例:下面的代码模拟了两个Node对象互相持有shared_ptr,最终导致析构函数不执行、内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <memory>

class Node {
public:
    std::shared_ptr<Node> peer; // 用shared_ptr引用另一个Node
    
    Node() {
        std::cout << "Node 构造函数执行" << std::endl;
    }
    
    ~Node() {
        std::cout << "Node 析构函数执行" << std::endl;
    }
};

int main() {
    // 创建两个Node对象,互相引用
    std::shared_ptr<Node> node1 = std::make_shared<Node>();
    std::shared_ptr<Node> node2 = std::make_shared<Node>();
    
    // 循环引用:node1持有node2的引用,node2持有node1的引用
    node1->peer = node2;
    node2->peer = node1;
    
    // 此时:
    // node1的引用计数 = 2(main中的node1 + node2->peer)
    // node2的引用计数 = 2(main中的node2 + node1->peer)
    
    // main函数结束时,node1和node2被销毁,引用计数各减1 → 变为1
    // 但因为互相引用,引用计数无法降到0,析构函数不会执行!
    return 0;
}

如何避免

核心解决方案是:将循环引用链中的一方(或多方)的shared_ptr替换为weak_ptr

weak_ptr 的关键特性:

  • weak_ptr是 “弱引用”,指向shared_ptr管理的对象,但不会增加引用计数
  • weak_ptr不能直接访问对象,需要通过lock()方法获取shared_ptr(若对象已销毁,lock()返回空的shared_ptr);
  • 正因为不增加引用计数,能打破循环引用的 “环”,让引用计数正常降到 0。

修复后的代码示例:只需将其中一个shared_ptr改为weak_ptr,就能打破循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>

class Node {
public:
    std::weak_ptr<Node> peer; // 把一方改为weak_ptr,不增加引用计数
    
    Node() {
        std::cout << "Node 构造函数执行" << std::endl;
    }
    
    ~Node() {
        std::cout << "Node 析构函数执行" << std::endl;
    }
};

int main() {
    std::shared_ptr<Node> node1 = std::make_shared<Node>();
    std::shared_ptr<Node> node2 = std::make_shared<Node>();
    
    // 循环引用被打破:
    // node1->peer是weak_ptr,指向node2但不增加其引用计数;
    // node2->peer(如果还是shared_ptr)指向node1,增加其引用计数,但这里我们改了一方就够
    node1->peer = node2; // weak_ptr接收shared_ptr,引用计数不变
    node2->peer = node1; // 同理
    
    // 此时:
    // node1的引用计数 = 1(仅main中的node1)
    // node2的引用计数 = 1(仅main中的node2)
    
    // main函数结束时,node1和node2被销毁,引用计数各减1 → 变为0
    // 析构函数正常执行,内存释放!
    return 0;
}

运行结果

1
2
3
4
Node 构造函数执行
Node 构造函数执行
Node 析构函数执行
Node 析构函数执行

可以看到,析构函数正常执行,内存泄漏问题解决。

访问 weak_ptr 指向的对象

如果需要通过weak_ptr访问对象,必须先调用lock()获取shared_ptr(确保对象未被销毁):

1
2
3
4
5
6
7
8
9
10
// 补充:在Node类中添加访问peer的方法
void access_peer() {
    // lock()返回shared_ptr:若对象存在,引用计数+1;若已销毁,返回空
    std::shared_ptr<Node> p = peer.lock();
    if (p) {
        std::cout << "成功访问peer对象" << std::endl;
    } else {
        std::cout << "peer对象已销毁" << std::endl;
    }
}

其他辅助避免方式

  • 设计层面:尽量避免对象之间的双向引用,比如用单向引用代替双向引用;
  • 手动打破循环:在合适的时机(比如对象不再需要互相引用时),将其中一方的shared_ptr置为nullptr,主动打破循环(但不如weak_ptr优雅)。

7.2 不要混合使用智能指针和裸指针

在同一个项目中坚持只使用智能指针,不使用裸指针,否则会出现忘记 delete 裸指针的情况。另外需要注意的事项还会很多,很复杂10

7.3 不要用 get() 函数的返回值初始化或 reset 另外的智能指针4

get() 返回的是智能指针所管理对象的裸指针。如果用它去初始化另一个智能指针,两个智能指针会各自独立地认为自己拥有该对象的所有权,分别维护各自的引用计数,最终导致同一块内存被释放两次(double free)。

1
2
3
4
5
6
int* raw = new int(42);
std::shared_ptr<int> sp1(raw);
// 危险:用 sp1.get() 初始化 sp2,sp2 会独立创建一个新的引用计数(从 1 开始)
std::shared_ptr<int> sp2(sp1.get());
// sp2 超出作用域 → 引用计数归零 → delete raw
// sp1 超出作用域 → 引用计数归零 → delete raw(double free,未定义行为!)

正确做法是通过已存在的 shared_ptr 进行拷贝或赋值,这样共享同一个引用计数:

1
std::shared_ptr<int> sp2 = sp1;  // 安全:共享引用计数

7.4 不要使用同一个原始指针构造多个 shared_ptr6

创建多个 shared_ptr 的正确方法是使用一个已存在的 shared_ptr 进行拷贝,而不是使用同一个原始指针反复构造。

1
2
3
int* num = new int(10);
std::shared_ptr<int> p1(num);
std::shared_ptr<int> p2(num);  // 危险!p2 不知道 p1 的存在

假如使用原始指针 num 创建了 p1,又同样方法创建了 p2,p1 和 p2 各自独立维护引用计数(都为 1)。当 p1 超出作用域时引用计数归零,调用 delete 释放 num 内存,此时 num 成了悬空指针;当 p2 超出作用域再次 delete 时就可能导致未定义行为6

正确做法:

1
2
std::shared_ptr<int> p1 = std::make_shared<int>(10);
std::shared_ptr<int> p2 = p1;  // 安全:共享引用计数

7.5 不要用栈中的指针构造 shared_ptr 对象6

shared_ptr 默认的构造函数中使用的是 delete 来删除关联的指针,所以构造的时候也必须使用 new 出来的堆空间的指针。

shared_ptr 对象超出作用域调用析构函数 delete 栈上对象的指针时会出错。

7.6 shared_ptr 和 unique_ptr 到底怎么选

std::shared_ptrstd::unique_ptr 都有各自的使用场景,不能简单说谁更好。前面聊了各自的适用场景,这里补一组量化对比,让你选型时心里有具体数据而不是模糊感觉。

空间开销对比:

  unique_ptr shared_ptr
内部结构 一个裸指针 一个裸指针 + 一个控制块指针
64 位系统尺寸 8 字节 16 字节
独立控制块 有(堆分配,额外 ~24 字节)
额外内存分配 0 1 次(make_shared 除外)

时间开销对比:

操作 unique_ptr shared_ptr
构造 保存裸指针,O(1) 分配控制块(或复用),涉及堆分配
拷贝 禁止 原子操作 +1 引用计数
析构 判非空 + delete 原子操作 -1 计数 + 判归零 + delete
解引用 */-> 等同于裸指针 等同于裸指针
移动 交换裸指针 交换两个指针,不涉及原子操作

可以看到 shared_ptr 的真正额外开销集中在构造、拷贝、析构这三个操作上,解引用访问对象是没有额外开销的。所以性能热点在于你怎么传递 shared_ptr,而不在于你通过它访问了几次对象。

选型决策流程:

  1. 先问自己:这个资源真的需要共享所有权吗?
    • 不需要 → 用 unique_ptr,到此为止
    • 需要 → 进入第 2 步
  2. 再问:shared_ptr 的额外开销在我的场景里是否敏感?
    • 不敏感(绝大多数情况)→ 用 shared_ptr,make_shared 构造
    • 敏感(如热点循环内大量拷贝)→ 重新审视设计,看是否能用 const shared_ptr<T>& 传参或换成 unique_ptr
  3. 一个实战中特别好用的模式:unique_ptr 持有资源,裸指针让外部观察。
1
2
3
4
5
class ConnectionManager {
    std::unique_ptr<Connection> conn_;  // 我持有,我负责释放
public:
    Connection* get() { return conn_.get(); }  // 别人只管用,别管释放
};

外面只拿裸指针,语义清晰:”这东西不是你的,别碰生命周期”。比 “还” 一个 shared_ptr 出去轻量得多。

大多数代码没必要用 shared_ptr。写的时候觉得”可能以后会共享”,实际上大部分对象的生命周期非常清晰,一个 owner 就够了。当你真的需要共享时,编译器的报错会提醒你,那时候再换也不迟。

7.7 智能指针是线程安全的吗?

智能指针并非完全线程安全,也非完全不安全,其线程安全性取决于具体类型和操作场景

在讨论线程安全前,先明确两个关键对象的区别:

  1. 智能指针对象本身:比如shared_ptr<int> sp 这个变量;
  2. 智能指针指向的底层对象:比如*sp对应的内存中的 int 值。

C++ 标准对智能指针的线程安全仅保障 部分场景,且不同智能指针的行为差异很大:

1. unique_ptr 的线程安全

unique_ptr独占式智能指针(不允许拷贝,仅支持移动),其线程安全规则:

  • ✅ 若多个线程拥有各自独立unique_ptr(指向同一对象或不同对象),且仅操作自己的unique_ptr,则安全;
  • ❌ 若多个线程同时操作同一个unique_ptr对象(比如同时调用std::movereset、赋值),则不安全(无任何线程安全保护);
  • ❌ 无论如何,unique_ptr不会保护其指向的底层对象,多个线程通过unique_ptr修改*ptr,必须手动加锁。

2. shared_ptr 的线程安全(重点)

shared_ptr共享式智能指针(通过引用计数管理生命周期),C++11及以上标准明确了其线程安全规则:

  • 引用计数的操作是线程安全的:多个线程同时拷贝/析构同一个shared_ptr对象(导致引用计数增减),是原子操作,无需额外加锁;
  • 智能指针对象本身的读写/修改非线程安全:多个线程同时对同一个shared_ptr变量执行赋值、resetswap、移动等操作(比如sp = nullptrsp = make_shared<int>(10)),会导致数据竞争,必须加锁;
  • 指向的底层对象非线程安全:多个线程通过shared_ptr读写*sp,必须自己加锁保护(智能指针不负责)。

代码示例:shared_ptr 的线程安全场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <memory>
#include <thread>
#include <mutex>

std::shared_ptr<int> sp = std::make_shared<int>(0);
std::mutex mtx;

// 场景1:多个线程拷贝同一个sp(引用计数操作,线程安全)
void copy_sp() {
    for (int i = 0; i < 1000000; ++i) {
        std::shared_ptr<int> local_sp = sp; // 仅拷贝,引用计数原子增减,安全
    }
}

// 场景2:多个线程修改同一个sp对象(非线程安全,需加锁)
void modify_sp() {
    for (int i = 0; i < 1000000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // 必须加锁!
        sp = std::make_shared<int>(i); // 修改sp本身,不加锁会数据竞争
    }
}

// 场景3:多个线程修改底层对象(非线程安全,需加锁)
void modify_underlying_obj() {
    for (int i = 0; i < 1000000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // 必须加锁!
        *sp += 1; // 修改底层int值,智能指针不保护
    }
}

int main() {
    // 测试场景1:3个线程拷贝sp
    std::thread t1(copy_sp);
    std::thread t2(copy_sp);
    std::thread t3(copy_sp);
    t1.join(); t2.join(); t3.join();

    // 测试场景2:2个线程修改sp
    std::thread t4(modify_sp);
    std::thread t5(modify_sp);
    t4.join(); t5.join();

    // 测试场景3:2个线程修改底层对象
    std::thread t6(modify_underlying_obj);
    std::thread t7(modify_underlying_obj);
    t6.join(); t7.join();

    std::cout << *sp << std::endl;
    return 0;
}

3. weak_ptr 的线程安全

weak_ptr依附于shared_ptr存在,其线程安全规则与shared_ptr一致:

  • ✅ 对weak_ptrlock()、析构等操作(仅影响引用计数)是线程安全的;
  • ❌ 对weak_ptr对象本身的赋值、reset等修改操作非线程安全;
  • ❌ 不会保护指向的底层对象。

总结

  1. 仅引用计数操作安全shared_ptr/weak_ptr的引用计数增减是原子的,无需加锁;
  2. 智能指针对象本身不安全:任何对同一个智能指针变量的修改(赋值、reset、移动等),多线程下必须加锁;
  3. 底层对象永远不安全:无论哪种智能指针,多线程读写其指向的对象时,都需要手动加锁(如std::mutex)。

简单记:智能指针只解决“对象生命周期管理”的线程安全(引用计数),不解决“对象数据访问”的线程安全。

7.8 什么时候用裸指针

前面一直在聊怎么管理对象:怎么创建、怎么释放、谁有所有权。但实际代码里还有大量场景不需要所有权的介入:你只是想访问一下对象,用完了就走,不关心对象的死活。

这种时候,裸指针和引用就是正确的工具。

1
2
3
4
5
6
7
void process(const std::unique_ptr<Widget>& wp) {
    wp->draw();  // 不参与所有权,只访问
}

void render(Widget* w) {
    if (w) w->render();  // 调用者保证 w 在 render 期间存活
}

这两个函数拿的都是”访问权”,不参与对象生命周期的任何决策。用智能指针的引用也好,用裸指针也好,核心语义一致:我不持有,我只是用一下。

一个常见误区是 “智能指针是好东西,裸指针是坏东西”,于是把项目里所有指针都换成 shared_ptr,函数参数清一色 shared_ptr<T>,这反而会把所有权概念搞模糊了。调用者看到 shared_ptr<T> 参数会困惑:”传进来会增加引用计数,这函数是不是要把我的对象存起来?”

所以实际建议是:

  • 持有资源的一方(容器、管理器、工厂)→ 用 unique_ptr 或 shared_ptr
  • 使用资源的一方(处理函数、回调、读数据)→ 用 T*const T&

一个判断标准:如果你的函数不需要也不应该影响对象的生命周期,参数就别用智能指针类型。这既是性能优化(省了原子操作),也是语义澄清(告诉调用者”我只读/写,不存”)。

参考文章

【占位】11121314151617181920212223

  1. CSDN. c++11智能指针[DB/OL]. (2022-07-17).https://blog.csdn.net/weixin_43858819/article/details/125529689 ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7 ↩︎8

  2. CSDN. C++11_智能指针 [DB/OL]. (2022-04-23). https://blog.csdn.net/weixin_44387482/article/details/124356614 ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6

  3. CSDN. c++11之智能指针 [DB/OL]. (2022-09-14). https://blog.csdn.net/qq_56673429/article/details/124837626 ↩︎ ↩︎2

  4. ELEMENT-UI. 【C++11】Smart Pointer智能指针[DB/OL]. (2023-01-08). https://www.ngui.cc/el/2678278.html?action=onClick ↩︎ ↩︎2 ↩︎3

  5. 博客园. 【C++11】4种智能指针[DB/OL]. (2022-10-04). https://www.cnblogs.com/bandaoyu/p/16752819.html ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7 ↩︎8

  6. CSDN. C++ 智能指针 shared_ptr 详解与示例 [DB/OL]. (2018-12-24). https://blog.csdn.net/shaosunrise/article/details/85228823 ↩︎ ↩︎2 ↩︎3 ↩︎4

  7. C语言中文网. C++11 weak_ptr智能指针 [DB/OL]. (2019-01-05). http://c.biancheng.net/view/7918.html ↩︎ ↩︎2 ↩︎3 ↩︎4

  8. CSDN. 深入理解掌握智能指针 [DB/OL]. (2022-02-12). https://blog.csdn.net/weixin_40533189/article/details/122857572 ↩︎ ↩︎2

  9. 来源:ChatGPT ↩︎

  10. CSDN. 智能指针和普通指针混用注意之一[DB/OL]. (2019-01-05). https://blog.csdn.net/ziliwangmoe/article/details/85840770 ↩︎

  11. CSDN. 智能指针shared_ptr的reset使用 [DB/OL]. (2023-02-07). https://blog.csdn.net/tianyexing2008/article/details/128919341 ↩︎

  12. 博客园. C++11智能指针——shared_ptr类成员函数详解[DB/OL]. (2021-07-19). https://www.cnblogs.com/JCpeng/p/15031742.html ↩︎

  13. CSDN. c++ unique_ptr共享指针release函数 [DB/OL]. (2023-02-09). https://blog.csdn.net/weixin_43061687/article/details/128862268 ↩︎

  14. CSDN. 深入掌握C++智能指针 [DB/OL]. (2019-03-20).https://blog.csdn.net/QIANGWEIYUAN/article/details/88562935?spm=1001.2014.3001.5502 ↩︎

  15. CSDN. C++动态内存与智能指针[DB/OL]. (2021-12-16). https://blog.csdn.net/weixin_43848885/article/details/121983343 ↩︎

  16. 从零实现智能指针:unique_ptr ↩︎

  17. C++14 make_unique ↩︎

  18. 【C++】 浅析 std::share_ptr 内部结构 ↩︎

  19. 探秘C++标准模板库中的三种智能指针 ↩︎

  20. C++类循环依赖破解:前向声明与智能指针的妙用 ↩︎

  21. C++中的RAII机制及其智能指针的应用 ↩︎

  22. 简单易学,三分钟搞定智能指针 ↩︎

  23. C++智能指针的基本实现 ↩︎

本文由作者按照 CC BY 4.0 进行授权