[请回答C++] C++11&智能指针&引用计数&weak_ptr

Intro of SmartPtr
其实C++ 里也是有垃圾回收的,不过不是 Java、Go 那种严格意义上的垃圾回收,而是广义上的垃圾回收(太瓦了),这就是构造 / 析构函数和RAII 惯用法(Resource Acquisition IsInitialization)
我们为什么需要智能指针?
- malloc出来的空间,没有进行释放,存在内存泄漏的问题。
- 异常安全问题。如果在malloc和free之间如果存在抛异常,那么还是有内存泄漏。这种问题就叫异常安全。
内存泄漏
内存泄漏和危害
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费
内存泄漏的危害都了解:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
常见的内存泄漏
堆内存泄漏(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()
{
int* ptr = new int;
//...
cout << div() << endl;
//...
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
异常抛出被收到之后,不会造成程序终止,这时候用掉的空间不会随着程序终止而结束
这种情况下我们可以利用异常的重新捕获解决,当然也可以使用智能指针
//异常捕获的方式
void func()
{
int* ptr = new int;
try
{
cout << div() << endl;
}
catch (...)
{
delete ptr;
throw;
}
delete ptr;
}
//智能指针
void func()
{
SmartPtr<int> sp(new int); //智能指针选一个
//...
cout << div() << endl;
//...
}
代码中将申请到的内存空间交给了一个SmartPtr对象进行管理。
在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
在SmartPtr对象析构时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对*和->运算符进行重载。
这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。
如何避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。
智能指针
RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
🌮 不需要显式地释放资源。
🌮 采用这种方式,对象所需的资源在其生命期内始终保持有效。
RAII常用于智能指针和lock_guard、unique_guard
SmartPtr
智能指针即需要RAII还需要能像指针一样使用
template<class T>
class SmartPtr
{
public:
// RAII
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
void test()
{
SmartPtr<int> sp1(new int);
*sp1 = 10;
SmartPtr<pair<string, int>> sp2(new pair<string, int>("sort", 1));
sp2->first;
sp2->second;
}
对于当前的智能指针来说我们重点对待智能指针的拷贝问题:
对于当前实现的SmartPtr类,如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。比如:
int main()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(sp1); //拷贝构造
SmartPtr<int> sp3(new int);
SmartPtr<int> sp4(new int);
sp3 = sp4; //拷贝赋值
return 0;
}
编译器默认生成的拷贝构造函数对内置类型完成值拷贝(浅拷贝),因此用sp1拷贝构造sp2后,相当于这sp1和sp2管理了同一块内存空间,当sp1和sp2析构时就会导致这块空间被释放两次。
编译器默认生成的拷贝赋值函数对内置类型也是完成值拷贝(浅拷贝),因此将sp4赋值给sp3后,相当于sp3和sp4管理的都是原来sp3管理的空间,当sp3和sp4析构时就会导致这块空间被释放两次,并且还会导致sp4原来管理的空间没有得到释放。
需要注意的是,智能指针就是要模拟原生指针的行为,当我们将一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决智能指针拷贝问题方式的不同,从而衍生出了不同版本的智能指针。
C++智能指针
auto_ptr
C++98版本的库中就提供了auto_ptr的智能指针。需要包头文件<memory>
但是为什么后来这个被显示已经deprecated了呢?
auto_ptr的实现原理:管理权转移的思想,下面简化模拟实现了一份AutoPtr来了解它的原理
简易版的auto_ptr的实现步骤如下:
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对*和->运算符进行重载,使auto_ptr对象具有指针一样的行为。
- 在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空。
- 在拷贝赋值函数中,先将当前对象管理的资源释放,然后再接管传入对象管理的资源,最后将传入对象管理资源的指针置空。
// 很多公司都明确的规定了,不能使用auto_ptr
template<class T>
class auto_ptr
{
public:
// RAII
auto_ptr(T* ptr)
:_ptr(ptr)
{}
~auto_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
// 管理权转移
// sp2(sp1);
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
// ap1 = ap3
// ap1 = ap1
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{//this是一个指针
if (this != &ap)
{
delete _ptr;
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
auto_ptr的问题:当对象拷贝或者赋值后,前面的对象就悬空了,不能用原对象
std::auto_ptr<int> sp1(new int);
// 拷贝
std::auto_ptr<int> sp2(sp1);
*sp2 = 10;
*sp1 = 20;
C++98中设计的auto_ptr问题是非常明显的,所以实际中很多公司明确规定了不能使用auto_ptr
unique_ptr
C++11中开始提供更靠谱的unique_ptr
unique_pre解决拷贝问题的方式就是禁止拷贝
简易版的unique_ptr的实现步骤如下:
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对*和->运算符进行重载,使unique_ptr对象具有指针一样的行为。
- 用C++98的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用C++11的方式在这两个函数后面加上=delete,防止外部调用。
// C++11
// 简单粗暴的解决拷贝问题:禁止拷贝
template<class T>
class unique_ptr
{
public:
// RAII
unique_ptr(T* ptr)
:_ptr(ptr)
{}
~unique_ptr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
unique_ptr(const unique_ptr<T>&) = delete;
unique_ptr<T>operator=(const unique_ptr<T>&) = delete;
private:
T* _ptr;
};
shared_ptr
C++11中开始提供更靠谱的并且支持拷贝的shared_ptr
shared_ptr是C++11中引入的智能指针,shared_ptr通过引用计数的方式解决智能指针的拷贝问题。
- 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源。
- 当新增一个对象管理这块资源时则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行–。
- 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了。
通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。
shared_ptr的模拟实现
shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。
自己实现简单版本的话可以不用写锁
shared_ptr的模拟实现
- 在shared_ptr类中增加一个成员变量count,表示智能指针对象管理的资源对应的引用计数。
- 在构造函数中获取资源,并将该资源对应的引用计数设置为1,表示当前只有一个对象在管理这个资源。
- 在拷贝构造函数中,与传入对象一起管理它管理的资源,同时将该资源对应的引用计数++。
- 在拷贝赋值函数中,先将当前对象管理的资源对应的引用计数–(如果减为0则需要释放),然后再与入对象一起管理它管理的资源,同时需要将该资源对应的引用计数++。在析构函数中,将管理资源对应的引用计数–,如果减为0则需要将该资源释放。
- 对*和->运算符进行重载,使shared_ptr对象具有指针一样的行为。
计数为0的时候才析构空间
template<class T>
class shared_ptr
{
private:
void AddRef()
{
_pmutex->lock();
++(*_pcount);
_pmutex->unlock();
}
void ReleaseRef()
{
_pmutex->lock();
bool flag = false;
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
flag = true;
}
_pmutex->unlock();
if (flag == true)
{
delete _pmutex;
}
}
public:
// RAII
shared_ptr(T* ptr)
:_ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
{}
~shared_ptr()
{
ReleaseRef();
}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _pmutex(sp._pmutex)
{
AddRef();
}
// sp1 = sp1
// sp1 = sp2
// sp3 = sp1;
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//赋值先把自己--,然后再++
if (_ptr != sp._ptr)
{
ReleaseRef();
_pcount = sp._pcount;
_ptr = sp._ptr;
_pmutex = sp._pmutex;
AddRef();
}
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pcount;
}
private:
T* _ptr;
int* _pcount;
mutex* _pmutex;
};
在ReleaseRef函数中,当引用计数被减为0时需要释放互斥锁资源,但不能在临界区中释放互斥锁,因为后面还需要进行解锁操作,因此代码中借助了一个flag变量,通过flag变量来判断解锁后释放需要释放互斥锁资源。
shared_ptr只需要保证引用计数的线程安全问题,而不需要保证管理的资源的线程安全问题,就像原生指针管理一块内存空间一样,原生指针只需要指向这块空间,而这块空间的线程安全问题应该由这块空间的操作者来保证。
引用计数的选择
在此有一个计数选择的问题:用什么来存计数
static int _count;//?
int* _pcount;//?
shared_ptr中的引用计数count也不能定义成一个静态的成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数。
如果是用后者,需要再有一个指针指向计数区域,每个指向同一个空间的指针都指向着同一个空间
线程安全问题
计数的指针安全但是指针管理的对像是不安全的
- 智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时++或–,这个操作不是原子的,引用计数原来是1,++了两次,可能还是2.这样引用计数就错乱了。会导致资源未释放或者程序崩溃的问题。所以只能指针中引用计数++、–是需要加锁的,也就是说引用计数的操作是线程安全的。
- 智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题。
加锁解决线程安全问题
要解决引用计数的线程安全问题,本质就是要让对引用计数的自增和自减操作变成一个原子操作,因此可以对引用计数的操作进行加锁保护,也可以用原子类atomic对引用计数进行封装,这里以加锁为例。
在shared_ptr类中新增互斥锁成员变量,为了让管理同一个资源的多个线程访问到的是同一个互斥锁,管理不同资源的线程访问到的是不同的互斥锁,因此互斥锁也需要在堆区创建。
在调用拷贝构造函数和拷贝赋值函数时,除了需要将对应的资源和引用计数交给当前对象管理之外,还需要将对应的互斥锁也交给当前对象。
当一个资源对应的引用计数减为0时,除了需要将对应的资源和引用计数进行释放,由于互斥锁也是在堆区创建的,因此还需要将对应的互斥锁进行释放。
为了简化代码逻辑,可以将拷贝构造函数和拷贝赋值函数中引用计数的自增操作提取出来,封装成AddRef函数,将拷贝赋值函数和析构函数中引用计数的自减操作提取出来,封装成ReleaseRef函数,这样就只需要对AddRef和ReleaseRef函数进行加锁保护即可。
循环引用
循环引用分析:
- node1和node2两个智能指针对象指向两个节点,引用计数变成1,我们不需要手动delete。
- node1的
_next
指向node2,node2的_prev
指向node1,引用计数变成2。 - node1和node2析构,引用计数减到1,但是
_next
还指向下一个节点。但是_prev
还指向上一个节点。 - 也就是说
_next
析构了,node2就释放了。 - 也就是说
_prev
析构了,node1就释放了。 - 但是
_next
属于node的成员,node1释放了,_next
才会析构,而node1由_prev
管理,_prev
属于node2成员,所以这就叫循环引用,谁也不会释放。
循环引用导致资源未被释放的原因:
当资源对应的引用计数减为0时对应的资源才会被释放,因此资源1的释放取决于资源2当中的prev成员,而资源2的释放取决于资源1当中的next成员。
而资源1当中的next成员的释放又取决于资源1,资源2当中的prev成员的释放又取决于资源2,于是这就变成了一个死循环,最终导致资源无法释放。
而如果连接结点时只进行一个连接操作,那么当node1和node2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都被释放了,这就是为什么只进行一个连接操作时这两个结点就都能够正确释放的原因。
weak_ptr
weak_ptr比较特殊,可以支持无参构造和支持shared_ptr去构造,但是不支持原生指针去构造
原理就是weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数。
简易版的weak_ptr的实现步骤如下:
- 提供一个无参的构造函数,比如刚才new ListNode时就会调用weak_ptr的无参的构造函数。
- 支持用shared_ptr对象拷贝构造weak_ptr对象,构造时获取shared_ptr对象管理的资源。
- 支持用shared_ptr对象拷贝赋值给weak_ptr对象,赋值时获取shared_ptr对象管理的资源。
- 对*和->运算符进行重载,使weak_ptr对象具有指针一样的行为。
template<class T>
class share_ptr
{
//新增一个get方法,可以让weak拿到对象
T* get() const
{
return _ptr;
}
}
// 不参与资源管理,不增加shared_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*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
定制删除器
现在有这么些问题:
之前所有的delete都是针对new的,但是倘若我需要new[]
,就会遇到很多错误,这样不匹配,或者是我传了一个文件指针,那么就方便处理,这就需要一个定制删除器
库里提供的删除器还是很好用的,可是实现起来需要有五个类,很麻烦
std::shared_ptr<ListNode> spArr(new ListNode[10], DelArr<ListNode>());
std::shared_ptr<FILE> spfl(fopen("test.txt", "w"), [](FILE* ptr) {
cout << "fclose:" << ptr << endl;
fclose(ptr);
});
定制删除器(考的不多)-- 删除器控制释放资源的方式
稍微模拟一下,这里使用一个仿函数来控制好了
template<class T>
struct Delete
{
void operator()(const T* ptr)
{
delete ptr;
}
};
template<class T, class D = Delete<T>>
class shared_ptr
{
private:
void AddRef()
{
_pmutex->lock();
++(*_pcount);
_pmutex->unlock();
}
void ReleaseRef()
{
_pmutex->lock();
bool flag = false;
if (--(*_pcount) == 0)
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
//delete _ptr;
_del(_ptr);
}
delete _pcount;
flag = true;
}
_pmutex->unlock();
if (flag == true)
{
delete _pmutex;
}
}
public:
// RAII
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
{}
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
, _del(del)
{}
~shared_ptr()
{
ReleaseRef();
}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _pmutex(sp._pmutex)
{
AddRef();
}
// sp1 = sp1
// sp1 = sp2
// sp3 = sp1;
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
ReleaseRef();
_pcount = sp._pcount;
_ptr = sp._ptr;
_pmutex = sp._pmutex;
AddRef();
}
return *this;
}
// 可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
mutex* _pmutex;
D _del;
};
C++11和boost库
C++98中产生了第一个智能指针auto_ptr。
C++boost给出了更实用的scoped_ptr、shared_ptr和weak_ptr。
C++TR1,引入了boost中的shared_ptr等。不过注意的是TR1并不是标准版。
C++11,引入了boost中的unique_ptr、shared_ptr和weak_ptr。需要注意的是,unique_ptr对应的就是boost中的scoped_ptr,并且这些智能指针的实现原理是参考boost中实现的。
最后小结助记:
参考资料:
https://blog.youkuaiyun.com/chenlong_cxy/article/details/127100528