【C++】智能指针的使用及其原理--详解

一.为什么我们会需要智能指针

因为需要帮 C++ 程序员管理堆上 new 出来的内存,自动完成释放,用来解决内存泄漏问题。

cpp 复制代码
#include <iostream>
#include <string>
#include <memory>
using namespace std;

// 动态分配内存,没有释放就return
void memoryLeak1()
{
	string *str = new string("动态分配内存!");
	return;
}

// 动态分配内存,虽然写了释放内存的代码,半路return直接跳过
int memoryLeak2()
{
	string *str = new string("内存泄露!");
	// ...

	// 发生某些异常,需要结束函数
	if (1)
    {
		return -1;
	}
	// 另外,try、catch抛出异常退出函数,同样会造成内存泄漏!

	delete str;	// 释放代码写了,但函数中途返回,这行根本跑不到
	return 1;
}

int main()
{
	memoryLeak1();
	memoryLeak2();

	return 0;
}
#include <iostream>
#include <string>
#include <memory>
using namespace std;

// 动态分配内存,没有释放就return
void memoryLeak1()
{
	string *str = new string("动态分配内存!");
	return;
}

// 动态分配内存,虽然写了释放内存的代码,半路return直接跳过
int memoryLeak2()
{
	string *str = new string("内存泄露!");
	// ...

	// 发生某些异常,需要结束函数
	if (1)
    {
		return -1;
	}
	// 另外,try、catch抛出异常退出函数,同样会造成内存泄漏!

	delete str;	// 释放代码写了,但函数中途返回,这行根本跑不到
	return 1;
}

int main()
{
	memoryLeak1();
	memoryLeak2();

	return 0;
}

具体分析如下:

(1)memoryLeak1 里面用 new 在堆上开辟 string 对象,str 指针本身存在栈上。函数执行 return 退出的时候,栈上的局部指针变量 str 直接销毁,但是堆上实际存字符串的那块内存没人去释放,就造成了内存泄漏

(2)memoryLeak2 虽然写好了 delete 用来释放内存。但 if 条件成立,直接执行 return -1 提前结束函数,后面的 delete str 根本跑不到,内存依旧泄漏。

(3)不只是 return 会跳过释放逻辑,如果 try 里面抛出异常,函数中途退出,同样会绕过 delete,引发内存泄漏。

(4)main 函数调用这两个函数,两处都会发生内存泄漏,堆内存会一直占用,直到整个程序运行结束才被系统回收。

如果把堆上分配出来的内存交给普通对象去托管,等这个对象生命周期结束,就调用它的析构函数把托管的内存释放掉,这个思路听起来很不错。而智能指针本质就是靠这套思路,实现内存自动释放。

  • C++ 里管理堆内存,主要就靠 new 和 delete 这一组运算符: new: 在堆上给对象开辟内存,返回对应的指针,还可以顺便把对象初始化。 **delete:**接收堆对象的指针,先销毁对象,再把这块内存还给系统。
  • 手动管理动态内存很容易出问题。最难的就是把控释放内存的时机。经常出现用完内存忘了释放,于是就产生内存泄漏。

二.内存泄漏

1.什么是内存泄漏,内存泄漏的危害

什么是内存泄漏 内存泄漏就是程序写完之后,因为代码疏忽或者逻辑 bug,用完的堆内存没有正常释放。 并不是物理内存凭空消失了,而是程序申请到一块内存之后,弄丢了这块内存的使用权,再也没办法把它还给系统,这块内存就被白白占用掉,造成内存浪费。

内存泄漏的危害 对于需要长时间跑的程序,内存泄漏的影响会被放大,像操作系统服务、后台常驻程序这类。 内存一点点不断堆积,程序占用内存会越涨越高,运行响应变得越来越慢,严重的时候直接卡死崩溃。

来看代码:

cpp 复制代码
void MemoryLeaks()
{
    // 1.内存申请了忘记释放
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = new int;
  
   // 2.异常安全问题
   int* p3 = new int[10];
  
   Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
  
   delete[] p3;
}

2.内存泄漏分类(了解)

在 C/C++ 开发里,主要关注两类内存相关泄漏问题:

堆内存泄漏(Heap leak) 堆内存是程序运行时,通过malloc、calloc、realloc或者new动态申请得来的内存。 用完之后,必须配套调用free或者delete进行释放。 如果代码逻辑有问题,申请的堆内存没有释放,后续程序又失去了对这块内存的访问能力,这块空间就再也得不到复用,就形成堆内存泄漏。

系统资源泄漏 程序向系统申请各类资源,比如套接字、文件描述符、管道等等,使用结束却没有调用对应接口释放。 这类不属于堆内存,但同样会持续消耗系统资源。积累多了会造成系统资源枯竭,整体性能下降,程序运行变得不稳定


3.如何检测内存泄漏(了解)

linux下内存泄漏检测:Linux下几款C++程序中的内存泄露检查工具_c++内存泄露工具分析-CSDN博客

在windows下使用第三方工具 :VS编程内存泄漏:VLD(Visual LeakDetector)内存泄露库_visual leak detector vs2020-CSDN博客


4.如何避免内存泄漏

  • ⼯程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下⼀条智能指针来管理才有保证。
  • 尽量使⽤智能指针来管理资源,如果⾃⼰场景⽐较特殊,采⽤RAII思想⾃⼰造个轮⼦管理。
  • 定期使⽤内存泄漏⼯具检测,尤其是每次项⽬快上线前,不过有些⼯具不够靠谱,或者是收费。

总结⼀下:内存泄漏⾮常常⻅,解决⽅案分为两种

1.事前预防型。如智能指针等。2.事后查错型。如泄漏检测⼯具。


三.智能指针的使用及原理

1.RAII

RAII,全称资源获取即初始化(Resource Acquisition Is Initialization),是借助对象生命周期管理各类程序资源的技术。像堆内存、文件句柄、网络连接、互斥锁这些资源都可以用这套方式管理。核心逻辑:对象构造的时候拿到资源,对象存活期间资源就可以正常使用,等到对象析构的时候再把资源释放掉。相当于把资源的管理工作交给对象来负责。

这么做带来两个好处:

  1. 不用我们手动写代码去释放资源
  2. 在对象的整个生命周期里,它持有的资源都是可用的

而智能指针,本质就是类模板。可以生成托管任意类型的指针对象智能指针对象生命周期结束时,会自动执行析构函数,把它指向的堆内存释放掉

看代码时间:下面是智能指针的基本框架,所有的智能指针类模板中都需要包含一个指针对象,构造函数和析构函数。

cpp 复制代码
template<class T>
class smartptr // 自定义的智能指针
{
public:
    smartptr (T* _ptr) // 构造函数
        : ptr(_ptr)
    {}
 
    ~smartptr () // 析构函数
    {
        if(ptr!=nullptr)
        {
            cout << "smartptr: delete" << endl;
            delete ptr;
            ptr = nullptr;
        }
    }
private:
    T* ptr; // 指针对象
};

2.智能指针的原理

前面写的简易资源管理类(smartptr ),还算不上真正的智能指针,它缺少指针该有的行为。普通指针可以解引用,也能用->访问对象的成员。

智能指针要做到和普通指针用法接近,就要在类里面重载*和->运算符。 重载之后,就可以像普通裸指针一样,用*解引用、用->访问托管对象里面的成员。

来看代码:

cpp 复制代码
template<class T>
class smartptr // 简易自定义智能指针
{
public:
    smartptr (T* _ptr) // 构造函数
        : ptr(_ptr)
    {}
 
    ~smartptr () // 析构函数
    {
        if(ptr!=nullptr)
        {
            cout << "smartptr: delete" << endl;
            delete ptr;
            ptr = nullptr;
        }
    }
 
    T& operator*() {return *ptr;} // 重载*运算符
 
    T* operator->() {return ptr;} // 重载->运算符
private:
    T* ptr; // 保存托管的裸指针
};

当我们的程序结束时,下面代码的 ptr1 和 ptr2 指针被销毁,那么对象 ptr1 和 ptr2 会自动调用析构函数去释放所指向的资源,这是智能指针的特点体现出来了

cpp 复制代码
int main()
{
    smartptr<int> ptr1(new int(1)); // 指向int类型的智能指针
    smartptr<string> ptr2(new string("string")); // 指向string类型的智能指针
    cout << *ptr1 << endl;
    cout << ptr2->c_str() << endl;
    return 0;
}

创建 ptr1 时,在堆上开辟了一个 int 空间,交给智能指针管理。ptr1 本身是栈对象,生命周期由系统自动管控。

ptr2 的原理一样,负责托管堆上的 string 对象。

打印 *ptr1 时,会触发我们重载的解引用运算符,取出堆中保存的整型数值并输出。

ptr2->c_str() 会调用重载的箭头运算符,拿到内部的裸指针,进而调用字符串的 c_str() 方法打印内容。

当 main 函数执行结束,栈上的 ptr1、ptr2 会自动销毁,触发析构函数,自动释放托管的堆内存,全程不需要我们手动写 delete,完美解决手动释放内存的麻烦。

运行时这两个代码加一起可以跑,我是为了分析拆开来看。

运行结果

接下来看,自定义的类中没有定义拷贝构造函数赋值重载函数,那么我们只能调用类中原生的拷贝构造函数和赋值重载函数。可是这样就会发生程序崩溃。前面类里面的函数不变,进行开始测试:

cpp 复制代码
 int main()
 {
     smartptr<int> ptr1(new int);
     smartptr<int> ptr2(ptr1); // 使用默认拷贝构造
     return 0;
 }

运行结果:

ptr2 和 ptr1 指向同一块堆内存。ptr2 生命周期结束,会调用析构把这块内存释放;等到 ptr1 再销毁的时候,又会执行一次析构,再次释放同一块内存。

程序退出的时候,ptr2 先销毁,内存已经被释放掉。之后 ptr1 销毁,还要再释放一遍已经释放过的内存。同一块内存重复释放,程序直接崩溃。只要一块内存已经释放完毕,再对它执行 delete,程序就会出错。

所以这个简易智能指针,不能直接用编译器自动生成的拷贝构造和赋值重载。如果要自己实现拷贝构造、赋值重载,就必须解决资源只会被释放一次这个核心问题。

简单总结一下智能指针的原理:

  1. RAII 特性。
  2. 重载 operator* 和 opertaor->,具有像指针一样的行为

接下来介绍三个智能指针如下,同时实现一下他们:

3.std::auto_ptr

https://cplusplus.com/reference/memory/auto_ptr/

C++98 标准库里面就提供过auto_ptr智能指针。为了解决拷贝时重复释放内存的问题,它用的是管理权转移的思路

简单说,把旧对象拷贝给新对象的时候,会把原来对象内部的指针置为nullptr,资源管理权直接交给新对象。最后就只剩新对象持有这块堆资源,旧对象不再指向任何内存,就不会出现重复 delete 的问题。

cpp 复制代码
int main()
{
    auto_ptr<int> ptr1(new int);
    auto_ptr<int> ptr2(ptr1); // ptr1置为nullptr
    return 0;
}

但 auto_ptr 有很明显的问题。调用拷贝构造或者赋值运算符完成管理权转移之后,如果再去使用原来那个旧对象,程序就会崩溃。 原因是转移完资源后,原来对象内部指针已经被置成nullptr,对空指针做解引用等操作就会出错。

这个缺陷风险很高,很多企业项目会直接禁用auto_ptr,C++11 之后也不再推荐使用它。


a.auto_ptr 的拷贝构造函数和赋值重载函数的实现

cpp 复制代码
// 管理权转移,转移完成之后源对象就不能再使用
auto_ptr(auto_ptr ap) // auto_ptr的拷贝构造函数
    : ptr(ap.ptr)
{
    ap.ptr = nullptr;
}

auto_ptr<T> operator=(auto_ptr<T>& ap) // auto_ptr的赋值重载函数
{
    if(ptr != ap.ptr)
    {
        ptr = ap.ptr;
        ap.ptr = nullptr;
    }
    return ptr;
}

**拷贝构造:**形参 ap 是传值方式,复制传入的对象。初始化列表把 ap 里面存的裸指针拿给新对象,再把源对象 ap 的指针置成 nullptr。资源就交到新对象手上,原来的对象不再管这块内存。 风险:拷贝完要是还去操作原来的对象,相当于操作空指针,代码直接报错。

**赋值重载 operator=:**先对比两个指针,避免自己给自己赋值。把 ap 持有的指针接管过来,再把源对象 ap 内部指针置空,完成资源移交。

**核心逻辑:**不管拷贝构造还是赋值,资源都会转给新对象,旧对象直接置空。确保同一块内存只会释放一次,解决重复 delete 导致崩溃的问题。

**致命缺点:**拷贝或者赋值之后,旧对象就为空了。要是代码没注意,继续拿旧对象做解引用、用 -> 访问成员,就会触发空指针错误,程序崩溃。所以很多企业项目直接不让用 auto_ptr。


b.auto_ptr 被 C++11 抛弃的主要原因

(1)复制或者赋值都会改变资源的所有权

cpp 复制代码
#include <iostream>
#include <memory>
#include <string>
using namespace std;

int main()
{
	// auto_ptr被C++11抛弃的主要原因:
	auto_ptr<string> p1(new string("I'm hjq!"));
	auto_ptr<string> p2(new string("I'm age 20."));

	cout << "p1:" << p1.get() << endl;
	cout << "p2:" << p2.get() << endl;

	// p2赋值给p1后,首先p1会先将自己原先托管的指针释放掉,然后接收托管p2所托管的指针
	// 然后p2所托管的指针置NULL,也就是p1托管了p2托管的指针,而p2放弃了托管
	p1 = p2;
	cout << "p1 = p2 赋值后:" << endl;
	cout << "p1:" << p1.get() << endl;
	cout << "p2:" << p2.get() << endl;

	return 0;
}

1.一开始p1、p2各自托管一块堆上的 string 内存,get()可以拿到内部裸指针地址,打印出两个不同的内存地址。

2.执行p1 = p2赋值操作:一开始p1、p2各自托管一块堆上的 string 内存,get()可以拿到内部裸指针地址,打印出两个不同的内存地址。

  • 执行p1 = p2赋值操作:
  • p1 先把自己原来持有的内存释放;
  • p1 接管 p2 的堆指针;
  • p2 内部指针被置空,彻底交出资源管理权。

3.赋值完成之后,p1 正常持有资源,p2 已经变成空智能指针。

但是坑就在这里:如果后续代码不小心再去使用 p2,比如解引用、调用成员方法,就等于操作空指针,程序直接崩溃。这就是 auto_ptr 不推荐使用的关键原因。

运行结果:

我们会发现,p2托管的指针给了p1托管,p2托管的指针NULL


(2). 在 STL 容器中使用 auto_ptr 存在着重大风险,因为容器内的元素必须支持可复制和可赋值

cpp 复制代码
#include <iostream>
#include <memory>
#include <string>
#include <vector>
using namespace std;

int main()
{
    vector<auto_ptr<string>> vec;
    auto_ptr<string> p3(new string("I'm p3"));
    auto_ptr<string> p4(new string("I'm p4"));
 
    // 必须使用std::move修饰成右值,才可以进行插入容器中
    vec.push_back(std::move(p3));
    vec.push_back(std::move(p4));
 
    cout << "vec.at(0):" <<  *vec.at(0) << endl;
    cout << "vec[1]:" <<  *vec[1] << endl;
 
    // 风险:
    vec[0] = vec[1]; // 如果进行赋值,就又回到了上面一个问题中
    cout << "vec.at(0):" << *vec.at(0) << endl;
    cout << "vec[1]:" << *vec.at(1) << endl;

    return 0;
}

创建 vector 容器,用来存放 auto_ptr。定义 p3、p4 两个 auto_ptr 对象,分别管理堆上的 string 对象。

push_back (std::move (p3)):std::move 把对象转成右值,完成管理权转移。资源交给容器内部的元素接管,这时外面的 p3、p4 内部指针已经变成空,后续代码不能再使用 p3、p4。

前面打印容器中的元素,资源还正常,可以正常解引用打印字符串。

执行 vec 0 = vec 1;,触发 auto_ptr 的赋值逻辑,发生管理权转移。vec 0 接管 vec 1 持有的内存,vec 1 内部指针直接被置为 nullptr。

后面再打印 * vec 1,等于对空指针解引用,程序就会崩溃。

这就是 auto_ptr 放进容器的致命问题。容器里面的元素一旦互相赋值,源元素就会被置空,后面访问这个源元素就会出错。所以 C++ 标准不建议把 auto_ptr 存到 vector 这类容器。

C++11 之后的 unique_ptr 同样不支持拷贝,只能通过 move 转移所有权。

错误的赋值操作会直接编译报错,不用等到运行阶段才出问题,从根源避免了这类隐患。

VS2022 的表现分两种情况:

  • Debug 模式:打印出vec1: 之后,程序停顿一小会儿直接异常退出,报错码0xc0000005内存访问冲突,程序崩溃终止,后面代码不会执行。
  • Release 优化模式:存在未定义行为,有可能看起来完整跑完,没有报错。这不是代码没问题,只是编译器优化带来的假象,换环境就会随机崩溃。

(3)不支持对象数组的内存管理

cpp 复制代码
auto_ptr<int[]> array(new int[5]); // 编译直接报错

auto_ptr内部析构函数用的是delete,而不是delete\[\]。数组内存必须用delete\[\]释放,所以它从设计上就管不了数组

上面这行代码,VS 编译直接报错:没有与参数列表匹配的构造函数。auto_ptr<int\[\]>根本就不支持这种写法。

这也是auto_ptr被淘汰的原因之一。C++11 之后改用unique_ptr,专门支持数组版本,unique_ptr<int\[\]>可以正常管理数组内存,析构时自动调用delete\[\]。

解决办法:C++11选择unique_ptr 正确管理数组

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

int main()
{
    unique_ptr<int[]> arr(new int[5]);
    arr[0] = 100;
    cout << arr[0] << endl;
    // 析构自动调用 delete[]
    return 0;
}

uto_ptr三个硬伤:

  • 拷贝 / 赋值后源对象被掏空,再用直接崩溃
  • 放进容器里,元素互相赋值会把对方置空
  • 不支持数组,连new int5都管不了

所以 C++11 直接用unique_ptr把它替换掉了。


(4)std::auto_ptr 的模拟实现

来看代码:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

// 模拟实现C++98的auto_ptr智能指针
template <class T>
class auto_ptr
{
public:
	// 构造:接管裸指针,保存资源地址
	auto_ptr(T* ptr)
		:_ptr(ptr)
	{}

	// 拷贝构造:管理权转移,不是拷贝,是把别人资源抢过来
	auto_ptr(auto_ptr<T> & sp)
		:_ptr(sp._ptr)
	{
        // 原对象置空,防止析构时二次释放同一块内存
		sp._ptr = nullptr;
	}

    // 赋值重载,同样做管理权转移
    auto_ptr<T>& operator=(auto_ptr<T>& sp)
    {
        // 检测是否自己给自己赋值,避免把自己资源直接置空
        if (this != &sp)
        {
            // 当前对象如果持有资源,先释放旧资源
            if (_ptr)
                delete _ptr;

            // 把传入对象的资源拿过来
            _ptr = sp._ptr;
            // 源对象指针置空,不让它后续析构释放这块内存
            sp._ptr = NULL;
        }
        return *this;
    }

	// 析构:释放保存的堆内存
	~auto_ptr()
    {
		if (_ptr)
        {
            cout << "delete:" << _ptr << endl;
			delete _ptr;    // 注意!这里只用普通delete,不支持数组new[]
			_ptr = nullptr;
		}
	}

	// 解引用重载,对外访问托管的对象
	T& operator*()
    {
		return *_ptr;
	}

	// ->重载,方便访问对象的成员
	T* operator->()
    {
		return _ptr;
	}
private:
	T* _ptr; // 保存管理的堆内存指针
};

核心就是管理权转移。拷贝或者赋值的时候,不会复制堆上的内存,而是直接把资源从旧对象移交到新对象,旧对象内部指针直接置空,防止同一块内存被多次 delete。

拷贝构造和赋值重载的参数都是左值引用,传不了临时对象。

析构函数内部调用的是普通 delete,所以管不了 new \[\] 开辟的数组,这也是 auto_ptr 的一个硬伤。

**有个很隐蔽的坑:**只要做了拷贝或者赋值,原先那个对象就变成空指针。如果后面还继续用它去调用 *、->,就会触发空指针访问,程序直接崩溃。

也没有做数组特化,写 auto_ptr<int \[\]> 这种语法直接编译报错。

反观 C++11 的 unique_ptr,直接把拷贝构造和赋值运算符给禁用掉,只允许 move 转移资源。错误的拷贝赋值在编译阶段就直接报错,不会把问题留到运行时才暴露。


4.std::unique_ptr

C++11 用更严谨的 unique_ptr 取代了 auto_ptr。

https://cplusplus.com/reference/memory/unique_ptr/

a.unique_ptr 的实现原理

unique_ptr 是 C++11 引入的智能指针,它采用独占式所有权管理动态资源。为了保证同一资源只能由一个 unique_ptr 管理,unique_ptr 禁用了拷贝构造函数和拷贝赋值运算符,因此不能进行拷贝操作。

cpp 复制代码
int main()
{
    std::unique_ptr<int> ptr1(new int);

    // 拷贝构造:不允许
    // std::unique_ptr<int> ptr2(ptr1);  // error

    std::unique_ptr<int> ptr3(new int);

    // 拷贝赋值:不允许
    // ptr3 = ptr1;  // error

    return 0;
}

但是,unique_ptr 支持移动构造和移动赋值,可以通过 std::move 转移资源的所有权:

cpp 复制代码
std::unique_ptr<int> ptr1(new int(10));
std::unique_ptr<int> ptr2(std::move(ptr1));

unique_ptr 支持移动构造和移动赋值,可以通过 std::move 转移资源的所有权:此时资源的所有权从 ptr1 转移给 ptr2,ptr1 不再拥有该资源。

一句话:unique_ptr = 不能拷贝,只能移动,独占资源。


b.unique_ptr 的拷贝函数和赋值重载函数

cpp 复制代码
template<class T>
class unique_ptr
{
public:
    //unique_ptr的拷贝构造函数和赋值重载构造给禁用掉
    unique_ptr(unique_ptr&) = delete;
    operator=(unique_ptr&) = delete;
private:
    T* ptr;
};

c.std::unique_ptr 的模拟实现

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

namespace hjq
{
    template <class T>
    class unique_ptr
    {
    public:
        // 构造,接管外部传进来的裸指针
        unique_ptr(T* ptr)
            :_ptr(ptr)
        {}

        // 析构,释放托管的堆内存
        ~unique_ptr()
        {
            if (_ptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
            }
        }
        
        // 重载解引用,像普通指针一样取值
        T& operator*()
        {
            return *_ptr;
        }
    
        // 重载->,方便访问托管对象的成员
        T* operator->()
        {
            return _ptr;
        }

        // 防拷贝:C++11 delete语法,直接删掉拷贝构造
        unique_ptr(const unique_ptr<T>& sp) = delete;
        // 删除拷贝赋值,禁止对象之间拷贝赋值
        unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;

    private:
        T* _ptr; // 保存托管的堆指针
    };
}

unique_ptr 模拟实现,放在自定义命名空间 hjq 里,和标准库做区分。

构造接收裸指针,将堆内存地址保存到成员_ptr,接管这块堆资源。

析构时先判断指针不为空,调用 delete 完成内存释放。

重载operator*和operator->,使用起来和普通裸指针语法一样。

**核心逻辑:**通过=delete直接删掉拷贝构造和拷贝赋值。只要写拷贝、赋值的代码,编译就直接报错,在编译阶段就禁止拷贝操作。

**对比 auto_ptr:**auto_ptr 允许拷贝,靠运行时做资源转移,旧对象会被置空,问题要等到程序跑起来才暴露。而 unique_ptr 直接禁止拷贝,错误在编译阶段就被揪出来。

这里记得要注意,这个简易版本只实现了独占、禁止拷贝,没有写移动构造和移动赋值,所以还不能用std::move转移所有权。真正标准库里面的 unique_ptr 是支持 move 移动语义的。


5.std::shared_ptr

https://cplusplus.com/reference/memory/shared_ptr/

shared_ptr 是 C++11 提供的智能指针。它支持多个 shared_ptr 同时管理同一块堆资源,内部依靠引用计数记录当前有多少个指针在共享这份资源。只有当最后一个管理该资源的 shared_ptr 销毁时,引用计数减到 0,才会真正释放堆内存,保证同一块内存只会释放一次,避免重复释放导致程序崩溃。


a.实现原理

shared_ptr 靠引用计数,实现多个对象共享同一份堆资源。可以这么理解:好比教室下班关门,谁是最后离开的人,谁负责锁门。

每个被 shared_ptr 管理的资源,内部都会配套维护一份引用计数,记录当前有多少智能指针在共用这块资源。

当某个 shared_ptr 对象销毁,调用析构函数,代表它不再使用这份资源,引用计数就减 1。

  • 如果计数减到 0,说明这是最后一个持有资源的对象,此时真正释放堆内存。
  • 如果计数不为 0,代表还有别的智能指针在使用资源,就不能释放,否则剩下的指针就会变成野指针。

std::shared_ptr::get() 可以拿到内部保存的裸指针,返回的就是智能指针托管的地址。头文件需要包含 <memory>。

**使用get()要格外小心:**一旦智能指针把资源释放掉,get 拿到的裸指针就变成无效指针。 get 一般只用来把裸指针传给普通函数,前提是外部代码不会去 delete 这个指针。千万不要拿 get 的返回值去初始化或者赋值给另一个智能指针,会造成两套独立的引用计数,最终引发重复释放。

来看代码:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

int main()
{
    
        shared_ptr<int> ptr6(new int(2));
        cout << ptr6.get() << endl;
        shared_ptr<int> ptr7 = ptr6;
        cout << ptr7.get() << endl;
        cout << ptr6.get() << endl;
        return 0;
    
}

我们看一下销毁过程:


b.shared_ptrd 的实现

赋值重载的三种情况

shared_ptr 赋值操作主要分三种场景处理,避免出错:

  1. 自赋值(ptr1 = ptr1) 自己给自己赋值,没有任何意义,直接跳过所有操作,防止误释放自身资源。
  2. 两个指针指向同一块空间(ptr2 = ptr1) 如果 ptr1 和 ptr2 本来就共享同一份资源,引用计数不用改动,什么都不需要处理。
  3. 两个指针指向不同空间(ptr2 = ptr1) 这是最常用、也是真正需要处理的情况: 先让 ptr2 释放自己原本托管的资源,引用计数减一,计数归零就彻底释放内存; 接着让 ptr2 接管 ptr1 的资源,同步引用计数; 最后让 ptr1 的资源引用计数加一,完成多指针共享资源。

product,本意是资源对象,不是变量名。

多线程角度: 因为_ptrcount指向的计数对象存放在堆上,所有线程都可以访问这块计数。多线程并发修改引用计数时,会出现product,本意是资源对象,根本不是变量名。线程安全问题,修改_ptrcount必须加锁,保证计数数据不会出错。

那operator*返回托管对象,为什么智能指针内部不用加锁保护?

operator*拿到的是业务资源本身,这块资源会存在读、写并发,这部分不属于智能指针的职责范围,并发安全需要调用方自己去保证。

来看代码:

cpp 复制代码
#include <iostream>
#include <mutex>
using namespace std;

template<class T>
class shared_ptr
{
public:
    //构造:接管裸指针,创建引用计数、互斥锁
    explicit shared_ptr(T* p = nullptr)
        : ptr(p)
    {
        if(ptr != nullptr)
        {
            ptrcount = new int(1);
            mt = new mutex;
        }
        else
        {
            ptrcount = nullptr;
            mt = nullptr;
        }
    }

    //增加引用计数
    void AddCount()
    {
        if(ptrcount == nullptr)
            return;
        mt->lock();
        (*ptrcount)++;
        mt->unlock();
    }

    //拷贝构造:新对象,共享sp的资源、计数、锁
    shared_ptr(shared_ptr<T>& sp)
    {
        ptr = sp.ptr;
        ptrcount = sp.ptrcount;
        mt = sp.mt;
        AddCount();
    }

    //析构:调用Release减少计数,计数为0释放全部资源
    ~shared_ptr()
    {
        Release();
    }

    //获取引用计数,返回int,不返回引用,禁止外部篡改
    int use_count()
    {
        if(ptrcount == nullptr)
            return 0;
        mt->lock();
        int cnt = *ptrcount;
        mt->unlock();
        return cnt;
    }

    //释放当前持有的资源
    void Release()
    {
        if(ptrcount == nullptr)
            return;

        bool deleteflag = false;
        mt->lock();
        if(--(*ptrcount) == 0)
        {
            //计数到0,释放资源、计数块
            delete ptr;
            delete ptrcount;
            ptr = nullptr;
            ptrcount = nullptr;
            deleteflag = true;
        }
        mt->unlock(); //原代码这里笔误 mt‑lock()

        //只有最后一个对象,才销毁mutex
        if(deleteflag)
        {
            delete mt;
            mt = nullptr;
        }
    }

    T& operator*() { return *ptr; }
    T* operator->() { return ptr; }

private:
    T* ptr;         //托管资源指针
    int* ptrcount;  //堆上的引用计数
    mutex* mt;      //保护引用计数的锁
};

构造函数接收外部 new 出来的裸指针,在堆上开辟引用计数,初始值置为 1,同时创建一把互斥锁。加上explicit关键字,防止裸指针隐式转换为 shared_ptr 对象。

AddCount () 负责引用计数加一。修改计数之前上锁,修改完成再解锁,避免多线程并发修改把计数改乱。

拷贝构造会生成一个全新的 shared_ptr 对象,直接拷贝源对象的资源指针、计数指针、锁指针。多个智能指针共用同一套计数和同一把锁,再把引用计数做加 1 处理。 要注意,拷贝构造出来的是新对象,本身没有携带旧资源,不能调用 Release,原来的代码在这里逻辑是错的。

Release () 是核心释放逻辑,析构函数会调用它。先上锁,再把引用计数减 1。 如果计数减到 0,代表当前是最后一个使用资源的对象,就释放托管资源与引用计数,把 deleteflag 标记为 true;锁解开之后,再去销毁 mutex 锁对象。 要是计数不等于 0,只做计数减一,不去释放资源。 计数判断的逻辑必须放在锁的保护范围内,mutex 千万不要在锁内部 delete,要解锁完成之后再释放。

use_count () 用来拿到当前引用计数。不能返回 int 引用,不然外部代码可以直接修改堆上的引用计数,会把智能指针搞坏。

operator * 和 operator-> 用来模拟普通指针的行为,让智能指针使用语法和裸指针保持一致。


c.std::shared_ptr 的线程安全问题

shared_ptr 是线程安全的吗?

引用计数的增减有加锁保护,是线程安全的,但它托管的资源本身并不保证线程安全。

我们可以写代码测试 shared_ptr 的线程安全,要分清两个关键点:

  1. 引用计数是所有 shared_ptr 共享的。如果两个线程同时对引用计数做 ++/--,这个操作本身不是原子的。比如计数原本是 1,两次并发 ++,结果有可能不是预期的 2,计数就乱掉。会造成内存泄漏,或者程序崩溃。所以引用计数的增减必须加锁保护,保证这部分操作线程安全。
  2. shared_ptr 托管的对象存放在堆上,如果多个线程同时读写这块对象,依旧会产生线程安全问题。

做实验的时候,把 AddRefCount、SubRefCount 里面的锁去掉,就能复现计数错乱的 bug。 线程安全问题属于偶现 bug,不一定每次运行都复现。把 main 里面循环次数 n 调大,复现概率会明显提高。

这里我们用自己手写的 SharedPtr 做演示,方便观察引用计数的线程问题。如果把手写版本替换成标准库的 std::shared_ptr 测试,得到的结论也是一样。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

struct Date
{
    int _year = 0;
    int _month = 0;
    int _day = 0;
};

void SharePtrFunc(hjq::shared_ptr<Date>& sp, size_t n, mutex& mtx)
{
    cout << sp.get() << endl;

    for (size_t i = 0; i < n; ++i)
    {
        // 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。
        hjq::shared_ptr<Date> copy(sp);

        // 这里智能指针访问管理的资源,不是线程安全的。所以我们看看这些值两个线程++了2n次,但是最终看到的结果,并一定是加了2n
        {
            unique_lock<mutex> lk(mtx);
            copy->_year++;
            copy->_month++;
            copy->_day++;
        }
    }
}

int main()
{
    hjq::shared_ptr<Date> p(new Date);
    cout << p.get() << endl;
    const size_t n = 100000;
    mutex mtx;
    thread t1(SharePtrFunc, std::ref(p), n, std::ref(mtx));
    thread t2(SharePtrFunc, std::ref(p), n, std::ref(mtx));

    t1.join();
    t2.join();

    cout << p->_year << endl;
    cout << p->_month << endl;
    cout << p->_day << endl;

    cout << p.use_count() << endl;

    return 0;
}

Date 结构体

测试用业务对象,存放_year、_month、_day,是 shared_ptr 托管的堆资源。

SharePtrFunc 线程函数

接收 shared_ptr 引用、循环次数、互斥锁,打印资源裸指针。 循环 n 次,每次拷贝构造局部copy: hjq::shared_ptr<Date> copy(sp); 拷贝构造引用计数 + 1;循环结束 copy 析构,引用计数‑1。

引用计数增减由 shared_ptr 内部锁保障线程安全。

局部域使用unique_lock对_year/_month/_day自增。 这是手动给业务对象加锁,不是 shared_ptr 自带锁。去掉锁就会发生数据竞争,运算结果小于2*n

shared_ptr 只负责引用计数,托管对象的读写并发,需要使用者自己保证。

main 主函数

创建 shared_ptr 管理 new 出来的 Date。 启动 t1、t2 两个线程,传入同一个智能指针。 n=100000,循环数值越大,并发问题越容易复现。 等待线程结束,打印年月日与引用计数use_count()。


d.std::shared_ptr 的循环引用

shared_ptr虽然使用方便,但存在循环引用的问题。

举一个双向链表的场景:链表节点内部_pre前驱、_next后继,如果都用shared_ptr来管理。 如果只用普通裸指针,又无法直接赋值给shared_ptr类型的成员。

cpp 复制代码
struct ListNode
{
    ListNode* _pre;
    ListNode* _next;
};

int main()
{
    shared_ptr<ListNode> node1;
    shared_ptr<ListNode> node2;
    node1->_next = node2;  //错误
    node2->_pre = node1;   //错误
    return 0;
}

报错原因:_next、_pre是裸指针ListNode*,不能直接接收shared_ptr<ListNode>对象,类型不匹配。

cpp 复制代码
struct ListNode
{
    shared_ptr<ListNode> _pre;
    shared_ptr<ListNode> _next;
};

int main()
{
    shared_ptr<ListNode> node1;
    shared_ptr<ListNode> node2;
    node1->_next = node2; //正确
    node2->_pre = node1;  //正确
    return 0;
}

把成员也定义成shared_ptr,类型匹配,可以直接赋值。 但是这里会产生循环引用内存泄漏问题:node1、node2 互相持有对方的 shared_ptr,双方引用计数都无法降到 0,节点不会释放


当其中两个节点互相引用的时候,就会出现循环引用的现象。如下所示:

cpp 复制代码
struct ListNode
{
    int _data;
    shared_ptr<ListNode> _pre;
    shared_ptr<ListNode> _next;
    ~ListNode() { cout << "~ListNode()" << endl; }
};
 
int main()
{
    shared_ptr<ListNode> node1(new ListNode);
    shared_ptr<ListNode> node2(new ListNode);
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
 
    node1->_next = node2;
    node2->_pre = node1;
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
 
    return 0;
}

use_count(): 返回智能指针对象的引用计数。

循环引用分析

1.node1、node2两个shared_ptr分别管理两个链表节点,每个节点引用计数初始为 1,不用手动delete。

2.node1->_next = node2,node2->_pre = node1互相指向对方,两个节点引用计数都变成 2。

3.函数执行结束,栈上node1、node2局部智能指针对象析构,两个节点引用计数各减 1,最后都剩余 1。

4.节点内部的_next、_pre依旧是有效的shared_ptr,还互相握着对方节点。

5.逻辑闭环:

  • node1要释放,得等node2内部_pre析构;而_pre是 node2 的成员,只有 node2 释放,_pre才会析构。
  • node2要释放,得等node1内部_next析构;而_next是 node1 的成员,只有 node1 释放,_next才会析构。

6.双方互相等待对方先释放,谁都释放不掉,发生内存泄漏,这就是循环引用。

解决方案:双向链表,把其中一个成员改成weak_ptr,不增加引用计数,打破循环

原理:node1->_next = node2; 和 node2->_prev = node1; 时,weak_ptr 的 _next 和 _pre 不会增加 node1 和 node2 的引用计数。

cpp 复制代码
struct ListNode
{
    int _data;
    weak_ptr<ListNode> _pre;
    weak_ptr<ListNode> _next;
    ~ListNode(){ cout << "~ListNode()" << endl; }
};
 
int main()
{
    shared_ptr<ListNode> node1(new ListNode);
    shared_ptr<ListNode> node2(new ListNode);
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
 
    node1->_next = node2;
    node2->_pre = node1;
 
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
 
    return 0;
}

如果不是 new 出来的对象,怎么用智能指针管理?

shared_ptr提供了删除器来处理这类场景,了解即可。

释放数组指针时,delete\[\]的方括号不能省略。方括号告诉编译器,当前指针指向的是对象数组首元素。 如果操作数组指针时漏掉\[\],程序编译不一定报错,运行时会直接出现未定义行为。

cpp 复制代码
int* ptr = new int[5];
delete[] ptr;
//delete ptr; //error

我们如果在动态内存中创建出一个数组,用一个 shared_ptr 对象去指向该数组,当 shared_ptr 使用完后,就会去调用析构函数,由于 shared_ptr 默认的删除方式是 delete ptr,后面没有带方括号,那么程序就会崩掉。

如果打开文件得到文件指针,交给shared_ptr管理。智能指针析构时不能直接用delete释放,需要调用close()关闭文件。

所以shared_ptr提供了支持自定义删除器的构造函数,用来指定资源的释放逻辑。

cpp 复制代码
template <class U, class D> shared_ptr (U* p, D del);

自定义删除器可以是:函数指针、仿函数、lambda、包装器。

【仿函数的删除器】

shared_ptr 中的析构函数会去调用 DelArry 仿函数去释放动态数组。

cpp 复制代码
#include <iostream>
#include <memory>

struct ListNode
{
    std::shared_ptr<ListNode> _pre;
    std::shared_ptr<ListNode> _next;
};

template<class T>
class DelArray
{
public:
    void operator()(T* array)
    {
        std::cout << "delete []" << std::endl;
        delete[] array;
    }
};

int main()
{
    // new[]开辟数组,传入仿函数对象作为自定义删除器
    std::shared_ptr<ListNode> ptr1(new ListNode[10], DelArray<ListNode>());
    return 0;
}

e.std::shared_ptr 和 std::weak_ptr 的模拟实现

cpp 复制代码
#include <iostream>
#include <mutex>
using namespace std;

namespace hjq
{
    template <class T>
    class shared_ptr
    {
    public:
        shared_ptr(T* ptr = nullptr)
            : _ptr(ptr)
            , _pRefCount(new int(1))
            , _pmtx(new mutex)
        {}

        //拷贝构造:共享资源、计数、锁,计数+1
        shared_ptr(const shared_ptr<T>& sp)
            : _ptr(sp._ptr)
            , _pRefCount(sp._pRefCount)
            , _pmtx(sp._pmtx)
        {
            AddRef();
        }

        void Release()
        {
            _pmtx->lock();
            bool flag = false;
            //引用计数减到0,才释放资源
            if (--(*_pRefCount) == 0 && _ptr != nullptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
                delete _pRefCount;
                flag = true;
            }
            _pmtx->unlock();
            //不能在lock内部销毁mutex,解锁后再删
            if (flag)
            {
                delete _pmtx;
            }
        }

        void AddRef()
        {
            _pmtx->lock();
            ++(*_pRefCount);
            _pmtx->unlock();
        }

        //拷贝赋值
        shared_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            if (this != &sp)
            {
                Release();
                _ptr = sp._ptr;
                _pRefCount = sp._pRefCount;
                _pmtx = sp._pmtx;
                AddRef();
            }
            return *this;
        }

        int use_count() const
        {
            return *_pRefCount;
        }

        ~shared_ptr()
        {
            Release();
        }

        T& operator*() const
        {
            return *_ptr;
        }

        T* operator->() const
        {
            return _ptr;
        }

        T* get() const
        {
            return _ptr;
        }

    private:
        T* _ptr;
        int* _pRefCount;
        mutex* _pmtx;
    };

    //简化版weak_ptr(这个实现残缺,仅演示外壳)
    template<class T>
    class weak_ptr
    {
    public:
        weak_ptr()
            : _ptr(nullptr)
        {}

        weak_ptr(const shared_ptr<T>& sp)
            :_ptr(sp.get())
        {}

        weak_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            _ptr = sp.get();
            return *this;
        }

        T& operator*() const
        {
            return *_ptr;
        }

        T* operator->() const
        {
            return _ptr;
        }

    private:
        T* _ptr;
    };
}

shared_ptr 成员

  • _ptr:保存要管理的堆内存裸指针
  • _pRefCount:堆上的引用计数,所有拷贝出来的对象共用这一份
  • _pmtx:互斥锁,专门保护引用计数,多线程同时修改计数的时候保证安全

构造函数 传入裸指针,引用计数直接初始化为 1,在堆上开出计数和锁。

注意:千万别拿同一个裸指针去多次构造 shared_ptr,会产生多套独立的控制块,最后会重复释放内存。

拷贝构造直接复制资源指针、计数指针、锁指针,不会重新开辟控制块;调用AddRef(),引用计数加 1。

AddRef先上锁,引用计数自增,再解锁,用来保证多线程环境下计数不出错。

Release(核心释放逻辑) 析构、拷贝赋值都会走这个函数。

  • 上锁,引用计数减 1
  • 如果计数减到 0,说明这是最后一个持有资源的对象,释放管理的内存,再释放引用计数
  • mutex 不能在加锁状态下直接 delete,用 flag 做标记,解锁之后再销毁锁对象
  • 计数不等于 0,只做计数减 1,不会释放任何资源

拷贝赋值先调用Release把自己原来持有的旧资源处理干净,再接管对方的资源,然后引用计数 + 1。

析构函数 直接调用Release,真正要不要释放内存交给 Release 去判断。

operator*、operator->、get():模仿普通指针的用法,让智能指针可以像裸指针一样访问资源。

weak_ptr(我写的这个) 这个简易实现只存了原始裸指针,不会增加引用计数。 但是没有关联控制块,缺少expired()、lock()接口,没办法判断资源是否已经被释放,只是一个外壳,不能真正用来解决循环引用问题。


四.C++11 与 boost 智能指针的发展关系

  1. C++98 最早出现了auto_ptr,这是第一个标准库智能指针,但拷贝行为有缺陷,实际使用很容易踩坑。
  2. boost 库后面实现了更实用的几款智能指针:scoped_ptr、shared_ptr、weak_ptr。
  3. 之后 TR1 技术报告把shared_ptr、weak_ptr引入进来,TR1 只是技术扩展,不属于正式 C++ 标准。
  4. 到 C++11 正式标准,把智能指针收进标准库:unique_ptr、shared_ptr、weak_ptr。unique_ptr大体对应 boost 的scoped_ptr,独占资源,不允许拷贝;shared_ptr、weak_ptr也参考 boost 的实现思路纳入标准。
相关推荐
Persistent的粽子!2 小时前
C++:类与对象(一)
开发语言·c++·经验分享·笔记
库玛西3 小时前
深入浅出Linux select网络模型:从底层位图原理到C++面向对象高级封装
linux·服务器·网络·c++·ubuntu
2402_882893863 小时前
深入浅出 unordered_map 与 unordered_set——从使用到底层差异
c++·哈希·unordered_map·unordered_set
今天要早睡_3 小时前
C++ 核心语法速过:命名空间、引用、函数重载与 nullptr 深度解析
android·java·c++
汉克老师4 小时前
CSP-J 初赛(以满分为目标):第二十七课 《图的遍历——BFS广度优先搜索——像“水波纹”一样,一层一层地搜索》
c++·csp-j·小学生·学c++编程
闻缺陷则喜何志丹4 小时前
【动态规划】P3609 [USACO17JAN] Hoof, Paper, Scissor G
c++·算法·动态规划·洛谷
汉克老师4 小时前
CSP-J 初赛(以满分为目标):第二十六课 《图的遍历——DFS深度优先搜索——从“树的先序遍历”走进真正的图世界》
c++·csp-j·小学生·学c++编程
程序猿编码4 小时前
基于GGML的C++17轻量化语音推理引擎:说话人识别与语音分析技术全解析
开发语言·c++·pytorch·深度学习·神经网络·大模型
影视飓风TIM5 小时前
C++哈希表深度剖析:冲突处理、rehash、迭代器与unordered容器封装
c++·哈希算法·散列表