一:为什么要有智能指针
二:智能指针的使用以及原理
三:C++的智能指针
3.1:auto_ptr
3.2:unique_ptr
3.3:shared_ptr
3.3.1:基本设计思想与实现
3.3.2:线程安全问题
3.3.3:定制删除器
3.4:weak_ptr
3.4.1:shared_ptr的循环引用问题
3.4.2:weak_ptr解决循环引用问题
一:为什么要有智能指针
C++ 支持智能指针,是为了解决内存管理的问题。在 C++ 中,手动分配和释放内存是非常容易出错的,尤其是在面对复杂的程序结构和多线程的情况下。如果内存没有被正确地释放,就会导致内存泄漏,这将严重影响程序的性能和可靠性。下面给出一个例子,具体如下所示:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");//invalid_argument中的异常类型!
return a / b;
}
void Func()
{
// 1、如果p1这里new 抛异常会如何?-----没问题,没有要释放的空间!
// 2、如果p2这里new 抛异常会如何?-----有问题,如果p2抛异常,需要释放p1开辟的空间!
// 3、如果div调用这里又会抛异常会如何?----有问题,和上面一样,需要释放p1,p2开辟的空间!!!
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
delete p1;
delete p2;
}
int main()
{
try
{
Func();
}
catch (exception& e)//C++库中的异常类
{
cout << e.what() << endl;
}
return 0;
}
如果此时p1 new的时候抛异常,对程序有没有影响呢??
答没有影响,因为如果p1 new的时候抛异常(new 失败了),那么它将直接被main函数中的try/catch捕获,而此时没有空间开辟,不会造成任何问题!
如果是p2 new 的时候抛异常呢?对程序有没有影响呢??
答有影响,因为p2 在new 失败时,抛异常被main函数中的try/catch捕获到,那么直接跳到main函数的执行流中,此时func函数的栈帧将被跳过忽略,那么在func函数new 出来的p1空间将无法释放,会造成内存泄漏的问题!!!
那如果p1,p2都没有抛异常,而是调用的div函数抛异常呢。对程序有没有影响???
答有影响,如p2 new的时候抛异常一样,此时div函数抛异常时会被main函数中的try/catch捕获到,那么p1,p2 所对应的空间将无法释放,造成内存泄漏问题!!!
那什么是内存泄漏,内存泄漏会造成什么问题呢?如何解决上述问题中的内存泄漏呢???
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap leak):
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
系统资源泄漏:
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
我们上面的举的例子就是堆内存泄漏!!!此时,我们可以用异常的重新抛出了解决上述问题,使申请到的空间都合理的释放掉,不会造成内存泄漏问题,具体如下所示:
//解决方法:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
// 1、如果p1这里new 抛异常会如何?-----没问题,没有要释放的空间!
// 2、如果p2这里new 抛异常会如何?-----有问题,如果p2抛异常,需要释放p1开辟的空间,需要重新抛出异常!
// 3、如果div调用这里又会抛异常会如何?----有问题,和上面一样,需要释放p1,p2开辟的空间,重新抛出异常!
int* p1 = new int;
int* p2 = nullptr;
try//如果p2 new的时候失败抛异常,此时我们在func函数中捕捉异常,然后重新抛出!
{
p2 = new int;
}
catch (...)
{
delete p1;//但是在重新抛出之前,将p1 new 出来的空间释放掉!
throw;//再重新抛出
}
try如果是div函数抛异常,此时我们也在func函数中捕捉异常,然后重新抛出!
{
cout << div() << endl;
}
catch (...)
{
//但是在重新抛出之前,将p1,p2 new 出来的空间释放掉!
delete p1;
delete p2;
throw;//再重新抛出
}
//如果都没有抛异常,正常调用,释放掉空间!
delete p1;
delete p2;
}
int main()
{
try
{
Func();
}
catch (exception& e)//C++库中的异常类!
{
cout << e.what() << endl;
}
return 0;
}
我们可以在func函数中重新抛出,同时在重新抛出之前将该释放的空间释放掉,就不会造成内存泄漏的问题了!!!但是这样写实在让人难以忍受,如果func函数中调用了很多new 或者很多函数,我们总不能每一个都捕获异常再重新抛出吧,这样做太麻烦了,所以我们这时候就需要智能指针来解决这类问题!!!
///
二:智能指针的使用以及原理
那智能指针该怎么使用,能解决上述内存泄漏的问题,以及弥补重新抛出那种解决方法的不足呢???具体如下所示:
template<class T>
class smart_ptr//智能指针对象类
{
public:
smart_ptr(T*ptr)//构造时管理
:_ptr(ptr)//将传入的指针交给智能指针对象管理!
{
cout << "smart_ptr(T*ptr)" << endl;
}
~smart_ptr()//析构时释放
{
delete _ptr;
cout << "~smart_ptr()" << endl;
}
private:
T* _ptr;//指针
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
smart_ptr<int> sp1(new int);//将new 出来的空间交给智能指针管理
smart_ptr<int> sp2(new int);
cout << div() << endl;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
我们写一个智能指针类(构造时管理资源,析构时释放资源),此时,就算sp2 或者div函数抛异常了,也不用担心因为执行流乱跳而导致空间无法释放的问题了,只要func函数栈帧结束,sp1,sp2对象的生命周期也就结束了!而析构函数是默认在对象生命周期结束时调用的!
所以综上:
new 出来的空间将会被智能指针对象管理,
当构造智能指针对象时,smartptr类将传入的需要被管理的内存空间保存起来,
当调用其析构函数时,smartptr类的析构函数中会自动将管理的内存空间进行释放。
这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,又或是因为抛异常跳走了,只要smartptr对象的生命周期一结束,就会自动调用其对应的析构函数,进而完成内存资源的释放。
像这样构造函数中构造对象,析构函数中释放对象的这样思想我们称之为RAII!!!
智能指针的核心思想就是RAII,那什么是RAII呢?
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
1:不需要显式地释放资源。
2:采用这种方式,对象所需的资源在其生命期内始终保持有效!
注意:RAII是一种思想,智能指针只是这种思想的一种产物!就比如说,之前我们在线程库中学习的lock_guard和unique_lock,也是RAII的产物!
但是上述的smartptr类还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指向空间中的内容,
因此:我们还得需要将* 、->重载一下,才可让其像指针一样去使用!
template<class T>
class smart_ptr//智能指针对象类
{
public:
smart_ptr(T*ptr)
:_ptr(ptr)//将传入的指针交给智能指针对象管理!
{
cout << "smart_ptr(T*ptr)" << endl;
}
T& operator*()//重载解引用
{
return *_ptr;
}
T* operator->()//重载->
{
return _ptr;
}
~smart_ptr()//析构时释放智能指针所管理的指针
{
delete _ptr;
cout << "~smart_ptr()" << endl;
}
private:
T* _ptr;//指针
};
此时,我们就可以通过重载的operator*对智能指针对象进行修改等操作,而至于重载的->,则是在我们定义自定义类型(如日期类,smartptr<Date>)时使用的,这里就不做演示了!
所以智能指针:RAII思想+像指针一样使用(重载operator*,operator->)!!!
但是光有上面两个因素还不足以实现智能指针,实现智能指针还需要特别注意智能指针的拷贝问题!!!下面我们还是用上述实现的smartptr类,给出一个例子,来看看智能指针拷贝时会发生什么问题,具体如下所示:
我们可以看到,对于当前实现的smartptr智能指针类来说,如果我们用一个smartptr对象拷贝构造另一个smartptr对象,此时程序会直接崩溃!!!
为什么会崩溃???
答:因为规定:如果我们没有写拷贝构造,那么编译器会默认生成一个拷贝构造,同时对内置类型进行值拷贝,对自定义类型调用自定义类型的拷贝构造进行拷贝!
而此时我们实现的smartptr没有写拷贝构造,所以生成的默认拷贝构造进行值拷贝(浅拷贝),浅拷贝就导致了两个智能指针指向了同一块资源,那么析构时,对这块空间析构了两次(重复析构)!!!
那如何解决智能指针拷贝时的问题呢???难道要实现智能指针的深拷贝???
答:深拷贝不行!因为智能指针模拟的是原生指针的行为,而原生指针进行拷贝时,两个原生指针指向的就是同一块资源,就是浅拷贝!所以此时智能指针进行拷贝构造时,目的也是为了让两个智能指针管理同一块资源,也应该是浅拷贝,所以深拷贝不行,因为我们要的就是浅拷贝(指针拷贝就是浅拷贝)!!!
所以智能指针:RAII思想+像指针一样使用(重载operator*,operator->)+解决智能指针拷贝问题!!!
正是因为解决智能指针拷贝构造问题的方法不同,所以C++衍生出了很多种智能指针,下面我们来认识认识C++中的各种智能指针!!!
///
三:C++的智能指针
3.1:auto_ptr
C++98版本的库中就提供了auto_ptr的智能指针;auto_ptr解决拷贝问题的方式是:管理权转移的思想!!!(就是保证一份资源在任何情况下只有一个智能指针对其进行管理,拷贝构造时,将原本的智能指针删除,只让新的智能指针指向资源,那么析构时,就不会存在重复析构的问题了)!!!下面我们用C++库中的auto_ptr举个例子:
虽然我们通过管理权转移解决了智能指针的拷贝问题,但是管理权转移后,之前的智能指针将被悬空,同时管理权转移会使得资源所有权不够明显,如果不熟悉auto_ptr的特性,容易误操作,从而引发对空指针访问!!!
所以,现实中的项目或者工程实践中,很多都明确规定不允许使用auto_ptr!但是作为我们学习,我们还是应该了解auto_ptr管理权转移是如何做到的,下面我们自己写一个非常简陋的auto_ptr,了解管理权转移的过程,具体实现思想如下所示:
1:在构造函数中获取资源,在析构函数中释放资源,将管理资源的责任托管给一个对象,利用对象的生命周期对其进行管理-->(即RAII思想)!!!
2:重载operator*和operator->,使其能像指针一样访问资源!
3: 在构造函数中,先用参数(智能指针)所管理的资源构建对象,然后再将参数(智能指针)悬空-->(即管理权转移)!
具体实现如下所示:
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr)//构造函数
:_ptr(ptr)
{
}
//拷贝构造:管理权转移
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)//让智能指针指向参数(智能指针)所指向的资源!
{
ap._ptr = nullptr;//再将之前指向资源的指针(作为参数的智能指针)置空!!!
}
//拷贝赋值:也是运用管理权转移
auto_ptr& operator=(auto_ptr<T>& ap)
{
if (this != &ap)//不能自己给自己赋值
{
delete _ptr; //先释放掉自己现在管理(指向)的资源
_ptr = ap._ptr; //再接管ap对象所管理(指向)的资源
ap._ptr = nullptr; //管理权转移后将ap置空!
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~auto_ptr()
{
if (_ptr!=nullptr)//如果指向的资源不为空
{
delete _ptr;//释放资源
cout << "~auto_ptr" << endl;
}
}
private:
T* _ptr;
};
auto_ptr的拷贝赋值也是利用管理权转移的思想,和拷贝构造差不多!
///
3.2:unique_ptr
正是由于auto_ptr在处理拷贝时的管理权转移的方法不太靠谱,有很大的缺点,所以在C++11中开始提供更靠谱的unique_ptr;
而unique_ptr对待智能指针拷贝问题的处理方法是:防止拷贝!!!既然拷贝有问题,那就不允许使用拷贝,简单粗暴的封掉拷贝构造!!!
所以unique_ptr只适用于不用拷贝的场景!
下面我们用一用库里面的unique_ptr,具体如下所示:
同auto_ptr一样,我们也再实现一个简陋版的unique_ptr,方便我们学习的时候理解unique_ptr的防拷贝特性!!!具体实现思想如下所示:
1:在构造函数中获取资源,在析构函数中释放资源,将管理资源的责任托管给一个对象,利用对象的生命周期对其进行管理-->(即RAII思想)!!!
2:重载operator*和operator->,使其能像指针一样访问资源!
3:将拷贝构造封掉!C++98中,封掉拷贝构造的方法是:只声明不实现,同时将其私有化!C++11中,直接使用关键字delete即可!(防拷贝)!
具体实现如下所示:
template<class T>
class unique_ptr
{
public:
unique_ptr(T*ptr)
:_ptr(ptr)
{
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~unique_ptr()
{
delete _ptr;
cout << "~unique_ptr()" << endl;
}
//C++11的方式防拷贝:使用关键字delete,在拷贝构造和拷贝赋值后面+delete!
unique_ptr(const unique_ptr<T>& up) = delete;//拷贝构造
unique_ptr& operator=(const unique_ptr<T>& up) = delete;//赋值重载
// //C++98的方式防拷贝:声明拷贝构造,但是不实现,同时将其私有化!
//private:
// unique_ptr(const unique_ptr<T>& up);
// //同样,也需要将拷贝赋值也封起来!
// unique_ptr& operator=(const unique_ptr<T>& up);
private:
T* _ptr;
};
///
3.3:shared_ptr
3.3.1:基本设计思想与实现
前面我们说过,unique_ptr是简单粗暴的防拷贝来解决智能指针拷贝构造的问题,它只适用于不拷贝的场景,但是有很多时候,我们还是需要拷贝构造智能指针,所以C++11中开始提供更靠谱的并且支持拷贝的shared_ptr!
shared_ptr对待智能指针拷贝问题的解决方法是:引用计数!!!其具体思想是:
1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
2. 指向这份资源的智能指针每多一个,那么引用计数就++一次,当对象被销毁时(也就是析构函数调用),就说明该对象不使用该资源了,那么该对象所指向的资源的引用计数就减--!
3. 如果引用计数--后=0,就说明自己是最后一个使用该资源的对象,此时,就必须释放掉该资源;
4. 如果引用计数--后>0,就说明除了自己还有其他对象在使用该份资源,此时就不能释放该资源了,如果释放,那其他对象就成野指针了!
综上,shared_ptr通过引用计数的方式,就可以让多个智能指针管理(指向)同一份资源,即解决了智能指针的拷贝问题,同时只有引用计数减到0时才会释放资源,也保证了同一份资源不会被重复析构的问题!!!下面我们用一用库中的shared_ptr:
在我们实现简陋版的shared_ptr之前,我们首先需要搞清楚一个问题:引用计数是成员变量没有问题,但是是什么类型的成员变量呢???是直接写成int use_count???还是搞成静态的static int use_count??? 或者是其他的呢??下面我们一一分析一下:
1:如果我们直接定义成int use_count(即将引用计数放在栈区):
如果放在栈区,那么每一个对象都有属于自己的计数,这个计数只有自己能看见、能改变,外部是无法干扰的,而我们想要的引用计数是对同一份资源的计数,只要是指向这份资源的对象就可以看到同一个的计数,就可以改变这个计数(拷贝或者析构)!所以不能将引用计数放到栈区!!!
2:既然要看到同一个计数,那直接定义成static int use_count(即将引用计数放到静态区):
如果放到静态区,虽然可以让指向同一份资源的智能指针看到同一个引用计数,但是静态变量是属于全局的,当又构建一个指向另一份资源的智能指针时,静态的引用计数也将随之一起++,可是我们要求的是只有指向同一份资源的智能指针才能操作引用计数呀,所以不能将引用计数放到静态区!
3:那么此时我们可以定义成int*use_count(即将引用计数放到堆区):
如果放到堆区,那么只有第一次管理这份资源的智能指针在构造的时候才能给引用计数开辟一份空间,管理其他资源的智能指针无法看到、改变这个引用计数,当如果有其他的对象也想管理这份资源时(即拷贝构造时),我们除了要给它管理的资源,还有将资源所对应的引用计数也给它,这时,就达到我们的要求了:即资源的引用计数是共享的,但是共享只限于指向同一份资源的智能指针,指向不同资源的智能指针看不到同一个引用计数!!!
注意:因为我们是将引用计数放到堆区,是new 出来的,那么当引用计数--之后为0时,我们不仅要将资源释放掉,同时我们也要释放掉引用计数所在的空间!简陋版的shared_ptr如下所示:
template<class T>
class shared_ptr
{
public:
//构造函数:
shared_ptr(T*ptr)
:_ptr(ptr)
,_use_count(new int(1))//在第一次构造时,new出来一个引用计数!
{
}
//拷贝构造
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_use_count(sp._use_count)//将引用计数的地址也拷贝过来!
{
++(*_use_count);//引用计数++;
}
//拷贝赋值
shared_ptr& operator=(const shared_ptr<T>& sp)
{
// if(&sp!=this):这样虽然能防止自己给自己赋值:sp=sp!
//但是如果sp和sp1指向同一份资源!那么此时sp=sp1这样赋值,if(&sp!=this)拦不住!
//虽然sp=sp1再赋值没问题,但是这样很没有用!没必要!
if (_ptr!=sp._ptr)//所以此时我们只需要看我们是否指向同一份资源即可!
{
if (--(*_use_count) == 0)
{
cout << "delete" << _ptr << endl;
delete _ptr;
delete _use_count;
}
_ptr = sp._ptr;
_use_count = sp._use_count;
++(*_use_count);
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
//获取引用计数的值
int Get_ues_count()
{
return *_use_count;
}
~shared_ptr()
{
//如果引用计数--之后等于0,则表示此时只有一个对象指向这份资源!
//那么我们在析构最后这一个对象时,需要将资源释放掉!
//当然如果不是最后一个指向这份资源的对象,在if判断的时候就已经--计数了!!!
if (--(*_use_count)==0)
{
cout << "delete" << _ptr << endl;
delete _ptr;
delete _use_count;
}
}
private:
T* _ptr;
int* _use_count;//引用计数!放到堆上!
};
3.3.2:线程安全问题
虽然我们通过引用计数解决了智能指针拷贝的问题,但是由于shared_ptr中我们需要对引用计数进行++--的操作,那么在多线程中,如果同时操作同一个shared_ptr,将有可能会导致引用计数的更新出现竞争。
具体来说,当一个线程持有shared_ptr时,如果另一个线程同时尝试对其进行拷贝构造、析构或拷贝赋值等操作(++--引用计数),那么这些操作会在引用计数的更新上产生竞争。而对引用计数来说,++--不是原子的(不是线程安全的),这可能会导致引用计数的结果 错误的增加或减少,从而影响shared_ptr的正确性和可靠性。下面我们给出一个例子,如下所示:
void func(const MKL::shared_ptr<int>& sp, size_t n)
{
//拷贝构造n次,但是每次拷贝构造后的对象都直接析构了!
for (size_t i = 0; i < n; ++i)
{
//拷贝构造
MKL::shared_ptr<int> sp1(sp);
}
}
int main()
{
size_t n = 10000;
MKL::shared_ptr<int> sp(new int(1));
//线程传参时如果是引用传参,那么我们使用ref函数传递!
thread t1(func,std::ref(sp),n);
thread t2(func, std::ref(sp),n);
t1.join();
t2.join();
//如果线程安全:此时sp这个智能指针的引用计数应该为1!!!
cout << sp.Get_ues_count() << endl;
return 0;
}
我们这里用两个线程对同一个智能指针进行拷贝构造,即不断的对这个智能指针的引用计数进行++--操作,如果引用计数是安全的,那么最终sp这个智能指针指向的引用计数的值应该为1,因为拷贝构造的都是临时对象,出了作用域就直接销毁了,但是我们运行的结果并不是1,那么则表明引用计数是存在并发问题的,即线程不安全!!!那如何解决引用计数的线程安全问题呢?
有两种方法,1:我们将引用计数的类型改为atomic对象的指针!但是并不一定能保证线程安全,如果想使用,最好通过一个类进行封装引用计数,C++库里面的实现方法大概就是封装引用计数,但是封装的太复杂,我们暂且不考虑;
2:比较常用的就是通过mutex锁,对引用计数进行加锁解锁保护,来实现引用计数的线程安全,下面我们就用对引用计数加锁解锁来实现!具体实现思想如下所示:
1:定义一把锁,这把锁同引用计数一样,即不能放在栈区,也不能定义成全局的,我们的目的是为了让指向同一份资源的智能指针在访问引用计数时受到限制,所以这个锁我们需要定义在堆区,在构造函数中new一个锁出来!
2:在调用拷贝构造,拷贝赋值函数时,我们不仅仅要拷贝对应的资源和引用计数,我们还要将引用计数所对应的锁也一并拷贝、赋值过去!
3:为了对引用计数的++--加锁解锁操作更简单便捷,为了使代码更简洁,我们把含有对引用计数++--的代码封装一下;
具体实现如下所示:
template<class T>
class shared_ptr
{
public:
//构造函数:
shared_ptr(T*ptr)
:_ptr(ptr)
,_use_count(new int(1))//在第一次构造时,new出来一个引用计数!
,_mtx(new mutex)在第一次构造时,new出来一把锁!
{
}
//拷贝构造
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_use_count(sp._use_count)//将引用计数的地址也拷贝过来!
,_mtx(sp._mtx)//同时也把锁拷贝过来!
{
Addcount();//引用计数++;
}
//拷贝赋值
shared_ptr& operator=(const shared_ptr<T>& sp)
{
if (_ptr!=sp._ptr)//所以此时我们只需要看我们是否指向同一份资源即可!
{
Release();
_ptr = sp._ptr;
_use_count = sp._use_count;
Addcount();
}
return *this;
}
//引用计数++
void Addcount()
{
_mtx->lock();//加锁
++(*_use_count);
_mtx->unlock();//解锁
}
//引用计数--
void Release()
{
_mtx->lock();//加锁
if (--(*_use_count) == 0)
{
cout << "delete " << _ptr << endl;
delete _ptr;
delete _use_count;
//delete _mtx;//这里能直接删除锁吗?不能,锁删掉了下面该如何解锁!!!
}
_mtx->unlock();//解锁
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
//获取引用计数的值!
int Get_ues_count()
{
return *_use_count;
}
//获取智能指针指向的资源!
T* Get_ptr()
{
return _ptr;
}
~shared_ptr()
{
Release();
}
private:
T* _ptr;
int* _use_count;//引用计数!放到堆上!
mutex* _mtx;//对引用计数的锁!也放在堆区!
};
这样,我们通过加锁解锁的方式即可解决引用计数线程安全的问题,使得每次运行的结果都是1!!!
但是这里有一个问题:
就是在加锁状态下,如果引用计数--之后等于0了,我们应该删除释放掉锁,但是删除锁之后我们再解锁程序将直接崩溃!(Release函数的问题);
具体解决方法是:
加锁,定义一个bool类型的变量,初始化成false,如果引用计数-之后等于0,那么我们在if语句中将变量置为true,如果引用计数-之后不等于0,不进入if语句内,此时变量还是false,然后解锁,最后判断这个变量的值,如果是true则表示需要我们释放锁,那直接delete;如果是false,则表明还有其他的智能指针指向这个锁,锁就不用释放,具体实现如下所示(只需要改动Release函数):
//引用计数--
void Release()
{
_mtx->lock();//加锁
//定义一个标志
bool delete_mutex_flag = false;
if (--(*_use_count) == 0)
{
cout << "delete " << _ptr << endl;
delete _ptr;
delete _use_count;
//如果引用计数--之后等于0,则表示资源需要释放,标志置为true;
//如果引用计数--之后不等于0,则表示资源不需要释放,if判断进不来,标志还是false;
delete_mutex_flag = true;
}
_mtx->unlock();//解锁
//此时对标志进行判断(true就释放锁,false就不释放)!
if (delete_mutex_flag == true)
{
delete _mtx;
}
}
所以此时我们就通过加锁的方式,保证了多线程并发的情况下,智能指针的引用计数的线程安全问题!
注意:这里虽然我们通过加锁保护了智能指针中的引用计数的线程安全,但是智能指针所指向的空间并不受锁保护。如果多个线程同时访问同一个shared_ptr指向的空间,仍然可能出现数据竞争和线程安全问题。具体如下所示:
我们可以看到,此时引用计数的大小为1,即引用计数是安全的,但是我们两个线程一起对日期类进行++,结果应该都是2000000,而运行的结果却不是那么多,所以,并发访问时,智能指针所指向的空间不是线程安全的(即使是C++库中的shared_ptr,它所指向的空间也不是线程安全的!!!),需要我们自己去进行加锁保护,具体如下所示:
//自定义的一个类!
struct Date
{
int _year = 0;
int _month = 0;
int _day = 0;
};
//shard_ptr本身是线程安全的,因为我们刚刚给引用计数加了锁,进行保护!
//但是shard_ptr所管理的对象却不是线程安全的!!!
void Func(MKL::shared_ptr<Date>& sp, size_t n, mutex& mtx)
{
for (size_t i = 0; i < n; ++i)
{
// 这里智能指针拷贝会++计数,智能指针析构会--计数,但是我们对引用计数的++--进行了加锁保护,所以这里是线程安全的。
MKL::shared_ptr<Date> sp1(sp);
{
//为了防止所管理的对象不会出现并发访问问题,所以我们需要再对其进行线程加锁解锁保护!
mtx.lock();
sp1->_year++;
sp1->_month++;
sp1->_day++;
mtx.unlock();
}
}
}
int main()
{
MKL::shared_ptr<Date> sp(new Date);
cout << sp.Get_ptr() << endl;
const size_t n = 10000000;
mutex mtx;
thread t1(Func, std::ref(sp), n, std::ref(mtx));//如果没有ref是传不过去的!
thread t2(Func, std::ref(sp), n, std::ref(mtx));
t1.join();
t2.join();
cout << sp.Get_ues_count() << endl;
cout << sp->_year << endl;
cout << sp->_month << endl;
cout << sp->_day << endl;
return 0;
}
3.3.3:定制删除器
shared_ptr
的定制删除器功能主要是为了解决一些特殊资源的管理问题,例如:动态分配的数组、指向C++对象的指针等。
因为在使用shared_ptr
管理这些特殊资源时,如果采用默认的删除器(即直接使用delete
),可能会导致资源的泄漏或者错误的释放,从而引起程序的崩溃等问题。因此,使用定制删除器可以更加精确地控制资源的释放过程,从而避免这些问题的发生。下面给出一些示例:
struct Date
{
int _year = 0;
int _month = 0;
int _day = 0;
~Date()
{
}
};
int main()
{
//如果是这样new 一块连续的内存,此时删除是有问题的,程序直接崩溃!
std::shared_ptr<Date> sp1(new Date[10]);
//这样也不行,不能只打开!
std::shared_ptr<FILE> fsp1(fopen("smartptr.cpp", "r"));
}
如上所示,如果此时这些对象的生命周期结束时,调用析构函数(直接delete),那么可能会使得程序崩溃。因为new []是开辟了一段连续的空间,需要通过delete[]来释放,而fopen则是打开一个文件,需要通过fclose来关闭文件,所以使用定制删除器是必要的,因为使用定制删除器可以更加精确地控制资源的释放过程,从而避免这些问题的发生。
在shared_ptr的构造中,C++11提供了带定制删除器的构造函数, 其中,p是让shared_ptr管理的资源,而del就是删除器,但是这个删除器是带有参数模版的,意味着定制删除器是一个课调用对象,可以是函数指针,仿函数,lambda表达式等!下面,我们通过定制删除器解决上面new []和fopen的释放问题,具体如下所示:
//shared_ptr的定制删除器
struct Date
{
int _year = 0;
int _month = 0;
int _day = 0;
~Date()
{
}
};
template<class T>
struct deletearray//仿函数
{
void operator()(T* ptr)
{
cout << "void operator()(T* ptr)" << endl;
delete[] ptr;
}
};
int main()
{
//如果是这样new 一块连续的内存,此时删除是有问题的,程序直接崩溃!
//std::shared_ptr<Date> sp1(new Date[10]);
shard_ptr支持在构造函数的时候给一个删除的方法!!!
std::shared_ptr<Date> sp1(new Date[10], deletearray<Date>());//所以我们需要自己写一个删除的方法(函数指针,lambala表达式,仿函数!);
//上面写的是仿函数的方法,下面写lambala表达式的方法:
std::shared_ptr<Date> sp2(new Date[10], [](Date* ptr) {delete[] ptr; });
//这样也不行,不能只打开!
//std::shared_ptr<FILE> fsp1(fopen("smartptr.cpp", "r"));
//用lambda表达式关闭文件:
std::shared_ptr<FILE> fsp1(fopen("smartptr.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
}
如果我们想模拟实现一个简陋的定制删除器,有两种方法,直接定义成模版参数,作为成员函数,或者通过包装器对其进行包装,下面我们用包装器来模拟一个,具体如下所示:
template<class T>
class shared_ptr_delete
{
public:
//构造函数:
shared_ptr_delete(T* ptr = nullptr)
:_ptr(ptr)
, _use_count(new int(1))//在第一次构造时,new出来一个引用计数!
, _mtx(new mutex)在第一次构造时,new出来一把锁!
{
cout << "shared_ptr(T*ptr)" << endl;
}
//带有定制删除器的构造函数:
template<class D>
shared_ptr_delete(T* ptr, D del)
:_ptr(ptr)
, _use_count(new int(1))//在第一次构造时,new出来一个引用计数!
, _mtx(new mutex)在第一次构造时,new出来一把锁!
, _del(del)
{
}
//引用计数++
void Addcount()
{
_mtx->lock();//加锁
++(*_use_count);
_mtx->unlock();//解锁
}
//引用计数--
void Release()
{
_mtx->lock();//加锁
//定义一个标志
bool delete_mutex_flag = false;
if (--(*_use_count) == 0)
{
cout << "delete " << _ptr << endl;
//delete _ptr;因为我们现在实现了删除器,所以我们用删除器删除!
_del(_ptr);
delete _use_count;
//如果引用计数--之后等于0,则表示资源需要释放,标志置为true;
//如果引用计数--之后不等于0,则表示资源不需要释放,if判断进不来,标志还是false;
delete_mutex_flag = true;
}
_mtx->unlock();//解锁
//此时对标志进行判断(true就释放锁,false就不释放)!
if (delete_mutex_flag == true)
{
delete _mtx;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
//获取引用计数的值!
int Get_ues_count()
{
return *_use_count;
}
//获取智能指针指向的资源!
T* Get_ptr()
{
return _ptr;
}
~shared_ptr_delete()
{
Release();
}
private:
T* _ptr;
int* _use_count;//引用计数!放到堆上!
mutex* _mtx;//对引用计数的锁!也放在堆区!
//定制删除器!同时给一个默认的删除!要不然有delete的情况!
function<void(T*)>_del = [](T* ptr) {delete[] ptr; };
};
3.4:weak_ptr
3.4.1:shared_ptr的循环引用问题
shared_ptr在特定的场景下可能会出现循环引用的问题!!!一般来说,循环引用的发生与对象的关系密切相关;例如:对象之间存在相互引用的关系,下面给出一个例子:
struct ListNode
{
ListNode* _next;
ListNode* _perv;
int _val;
~ListNode()
{
cout << "ListNode" << endl;
}
};
int main()
{
ListNode* n1 = new ListNode;
ListNode* n2 = new ListNode;
n1->_next = n2;
n2->_perv = n1;
delete n1;
delete n2;
}
在这个例子中,我们定义了一个链表镀锡,分别new了两个节点,同时让这两个节点相互链接!这是我们以往的写法,但是C++11之后,因为害怕节点在new的时候,或者链接的时候抛异常,所以我们就不推荐这种写法了,我们推荐用将这两个节点用智能指针管理起来,具体如下所示:
我们可以看到,虽然我们将节点交给智能指针进行管理,按正常逻辑,函数结束后将调用节点的析构函数,但是为什么当程序结束后,new出来的两个节点没有调用析构函数进行释放呢???下面我们再看:
当我们屏蔽掉节点链接中的任意一句,此时当函数结束时,智能指针对象所管理的对象怎么又能调用析构函数对new出来的节点释放掉了???
答:可见是因为智能指针节点间相互链接导致的-----这种问题我们称之为shared_ptr的循环引用问题!是shared_ptr设计的缺陷,在一些特定场景下会出现!
那什么是循环引用呢?为什么上述代码会出现循环引用呢???下面我们来分析一下:
所以,此时循环引用就成了一个闭环,两个资源一直在等待对方析构!那么循环引用就导致了内存泄漏!!!所以为了解决上述情况下出现的循环引用问题,库中单独实现了一个weak_ptr(弱指针),用于补shared_ptr的这个坑!!!
3.4.2:weak_ptr解决循环引用问题
weak_ptr是C++11中引入的一种智能指针,它指向由shared_ptr
管理的对象,但不像shared_ptr
那样会增加对象的引用计数,也不会拥有该对象。weak_ptr的主要特征:
1:它不是常规的智能指针,它不支持RAII!
2:它支持像指针一样的使用!
3:主要用于解决shared_ptr
的循环引用问题。
此时只需要将ListNode中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,因为weak_ptr不会增加引用计数,引用计数仍然是1;那么当n1和n2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源。具体操作如下所示:
//用库中的weak_ptr来解决shared_ptr的循环引用问题:
struct ListNode
{
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _perv;
int _val;
~ListNode()
{
cout << "~ListNode" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
cout << n1.use_count() << endl;
cout << n2.use_count() << endl;
n1->_next = n2;
n2->_perv = n1;
cout << n1.use_count() << endl;
cout << n2.use_count() << endl;
return 0;
}
此时我们可以打印链接前后两个对象的引用计数的值,我们可以看到,连接前后引用计数没有变化,还是1,则说明weak_ptr确实不会增加管理的对象的引用计数!!!
下面我们也模拟实现一个非常简陋版本的weak_ptr;具体如下所示:
template<class T>
class weak_ptr
{
public:
weak_ptr()//无参构造
:_ptr(nullptr)
{
}
//用shard_ptr指针构造!
weak_ptr(MKL::shared_ptr<T>& sp)
:_ptr(sp.Get_ptr())
{
}
//weak_ptr的拷贝赋值函数仅仅是对资源的拷贝!
weak_ptr& operator=(MKL::shared_ptr<T>& sp)
{
_ptr = sp.Get_ptr();
return *this;
}
T& operator*()
{
return &_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
此时用我们自己写的weak_ptr也可以解决此时的循环引用问题! !!!