目录
一、为什么要有智能指针
在传统的C++中,我们通常使用new
和delete
来手动分配和释放内存。但是,手动管理内存非常容易出错,容易导致内存泄漏或者悬空指针的问题。智能指针的出现就解决了这个问题。
除了常规的忘记释放内存外,在C++引入了异常后,内存泄露的问题愈发严重,大家可以看看下面这段代码,当我输出第二个参数为0,产生整数除以0的异常后,会发生什么问题:
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 抛异常会如何?
// 3、如果div调用这里又会抛异常会如何?
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
delete p1;
delete p2;
cout << "p1、p2释放" << endl;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
运行结果:
咋一看好像没什么问题,异常被捕获了,并且输出了我们想要的错误信息。
但是p1、p2指向的空间好像因为无法异常的跳转,其指向的空间没有得到释放,引发了经典的内存泄漏问题。
二、 内存泄漏
2.1 什么是内存泄漏,内存泄漏的危害
- 什么是内存泄漏:
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
- 内存泄漏的危害:
长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
- 内存泄漏是指针丢了还是内存丢了?
内存泄漏是指在程序运行过程中,动态分配的内存没有被正确释放,导致无法再被使用或者回收。一般情况下,内存泄漏是指程序中的指针丢失了对应的内存地址,从而导致无法释放这块内存。
2.2 内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
- 堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据需要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
- 系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
2.3 检测内存泄漏
在linux下内存泄漏检测:linux下几款内存泄漏检测工具
在windows下使用第三方工具:VLD工具说明
其他工具:内存泄漏工具比较
检测工具内部原理:
申请内存用一个容器记录下来,释放内存时,从容器中删除掉。程序结束时,或者没有任务时,容器中的资源可能就是内存泄漏的。
2.4如何避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
总结一下:
内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。
三、智能指针的原理与使用
3.1 RAII
即在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。
这种做法有两大好处:
- 不需要显式地释放资源
采用这种方式,对象所需的资源在其生命期内始终保持有效
例如现在,我们使用RAII思想设计释放资源的类:SmartPtr,来解决刚刚的异常跳转问题。
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(new int);
cout << div() << endl;
}
int main()
{
try {
Func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
正常输入的结果:
出现 了异常,出了作用域可以正常调用析构函数来释放空间
这便是RAII的设计思路,也是智能指针核心设计思想之一,即使用对象管理指针,出作用域自动调用析构函数来释放空间。
上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可
以通过->去访问所指空间中的内容,因此:SmartPtr模板类中还得需要将* 、->重载下,才可让其
像指针一样去使用。
//1、使用RAII思想设计delete资源的类
//2、像指针一样的行为
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
//运算符重载两个操作符 -- 模拟指针的操作
T& operator* ()
{
return *_ptr; //返回_ptr的指向
}
T* operator->() //有两个->,其中一个被省略了。
{
return _ptr; //返回指针
}
private:
T* _ptr;
};
总结一下智能指针的原理:
RAII特性
重载operator*和opertaor->,具有像指针一样的行为。
上面我们基本实现了smartPtr,但是smartPtr的一大痛点就是拷贝,现在使用smartPtr默认的拷贝构造函来看看smart能不能进行拷贝:
直接就出现了报错,原因如下:
因为我们没有编写构造函数,所以编译器使用默认构造函数,这就导致其中的_ptr指向了同一空间,在出作用域时,会调用析构函数,这就导致对同一空间的二次释放,导致了报错。
现在我们就再来解决智能指针拷贝构造的问题:
来看看C++98版本的库中的auto_ptr
3.2 auto_ptr
C++98版本的库中就提供了auto_ptr的智能指针。
关于auto_ptr的文档解释:std::auto_ptr文档
auto_ptr的实现原理:
- 管理权转移的思想。
接下来我们使用库中的auto_ptr来实现拷贝,来观察库中的auto_ptr的拷贝构造做了什么事:
发现!!
auto_ptr的拷贝构造是将原来的aptr1置为空,然后单独使用aptr2对象来指向该区域。
他进行的是资源权转移,是不负责任的拷贝,会导致被拷贝对象悬空。
这里给出auto_ptr拷贝构造函数的实现:
//auto_ptr拷贝的实现--简单的赋值
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr=nullptr
}
而auto_ptr的赋值运算符重载同样也是资源转移,例如以下代码:
而auto_ptr的赋值运算符重载的实现是怎样的呢?
auto_ptr的赋值,会先将被赋值对象指向的内容进行释放,然后进行赋值操作,再将赋值对象的指向置为空,实现如下:
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
// 检测是否为自己给自己赋值
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
delete _ptr;
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
结论:auto_ptr是一个失败设计,很多公司明确要求不能使用auto_ptr,接下来我们看看常用的三种智能指针。
四、常用的智能指针
在C++98中推出auto_ptr,一直饱受诟病,在C++11中推出了三种功能较完善的智能指针,这也是实际中使用比较频繁的三种智能指针。
4.1 unique_ptr
C++11版本的库开始提供更靠谱的unique_ptr。
关于unique_ptr的文档解释:https://cplusplus.com/reference/memory/unique_ptr/
- unique_ptr的实现原理:
简单粗暴的防拷贝。
- 使用场景:
只适用于不需要拷贝的一些场景,功能比较局限。
以下是C++11中对unique_ptr对于拷贝构造函数与赋值重载函数的处理:
那C++98没有delete,scoped_ptr(unique_ptr的前身)是如何实现的呢?
C++98中对于scoped_ptrde 拷贝构造函数和赋值重载函数的处理(声明但不实现+私有化)
4.2 shared_ptr
- shared_ptr的原理:
是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。
- shared_ptr的实现:
- shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
shared_ptr的实现:
我们需要在shared_ptr中添加一个指针变量,指向计数器用于计数,其拷贝构造的时候就将其引用计数++,一个shared_ptr对象销毁时就将计数--,当计数为0的时候再释放其指向的空间。
代码如下:
namespace Brant
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr)
,_pCount(new int(1)) //开辟一个int空间用于计数
{}
//sp2(sp1)
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_pCount(sp._pCount) //将计数器赋值
{
(*_pCount)++;
}
~shared_ptr()
{
if (--(*_pCount) == 0 )
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pCount;
}
}
//运算符重载两个操作符 -- 模拟指针的操作
T& operator* ()
{
return *_ptr; //返回_ptr的指向
}
T* operator->() //有两个->,其中一个被省略了。
{
return _ptr; //返回指针
}
int use_count() { return *_pCount;}
T* get() const { return _ptr; }
private:
T* _ptr;
int* _pCount;
};
}
这样就开辟的空间就能很好的管理和释放了:
也可以将上面的测试用例一起进行测试:
接下来就是实现shared_ptr的赋值运算符重载:
- 赋值运算符重载的实现:
1. 判断是否为自己给自己赋值,(应以指向的空间或计数器为判断条件)
2. 减去原来的计数,计数等于0则释放
3. 修改指向
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//自己给自己赋值 例如 sp4=sp4,但是sp1=sp5无法解决
//if (this == &sp) { return *this; }
if (this->_ptr == sp._ptr) { return *this; }
//减去原来的计数,并等于0时释放
if (--(*_pCount) == 0)
{
delete _ptr;
delete _pCount;
}
//赋值
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
return *this;
}
shared_ptr的实现非常重要,面试常考,需要闭着眼睛都能写出来。
这里只是很简单的实现了shared_ptr的核心思想,shared_ptr还有关于多线程线程安全等问题,后期再进行补充。
4.3 循环引用
shared_ptr看着很完美,但是其存在一个循环引用的问题,我们来看看循环引用是怎么出现的吧~
首先我们要引入一下双向链表中节点:
struct Node
{
int _val;
Node* _next;
Node* _prev;
~Node()
{
cout << "~Node()" << endl;
}
};
这是使用普通指针的写法,现在我们将其中的普通指针全部改为shared_ptr:
struct shared_Node
{
int _val;
std::shared_ptr<shared_Node> _next;
std::shared_ptr<shared_Node> _prev;
~shared_Node()
{
cout << "~shared_Node()" << endl;
}
};
这样一个由智能指针创建的双向链表节点就构建好了。
然后我们创建两个节点将其链接起来。
void test_shared_ptr2()
{
std::shared_ptr<shared_Node> sn1(new shared_Node);
std::shared_ptr<shared_Node> sn2(new shared_Node);
sn1->_next = sn2;
sn2->_prev = sn1;
}
此时没有任何问题,接下来我们运行一下观察结果:
发现,为什么我们使用shared_ptr在程序结束的时候没有调用shared_ptr的析构函数?按理来说控制台应该会输出两行 "~shared_Node()" 才对。
接下来我们画图来分析一下两个节点的关系:
然后当我们程序结束,sn1和sn2智能指针调用各自的析构函数后,其计数器的改变为:
问题不难发现,当sn1和sn2智能指针调用析构函数让计数器 -- 后,左边的链表节点中的_Next指针任然指向右边的链表节点,而右边的_Prev指针任然指向左边的链表节点,这就导致其对应计数器没有置0,没有进行空间的释放。
而左边节点的释放需要让_prev指针销毁调用析构函数,右边节点的释放需要让_next指针销毁调用析构函数。
就像人打架一样,你拉着我的手,想我让我放开,我拉着你的手,要你你放开。
很明显,这种情况,即使是shared_ptr也无法解决,这种场景就叫做循环引用。
此时就需要引入一个新的指针指针来解决这个问题——weak_ptr.
4.4 weak_ptr
关于weak_ptr的文档解释:weak_ptr
weak_ptr不是常规智能指针,没有RAII,不支持直接管理资源,
weak_ptr主要用shared_ptr构造,用来解决shared_ptr循环引用的问题,不增加计数。
例如上面的_next和_prev是weak_ptr时,他不参与资源释放管理,可以访问和修改到资源,但是不增加计数,则不存在循环引用的问题了。
现在我们使用waek_ptr对上面的代码进行修改吧:
1. 首先修改链表节点中的智能指针类型,将shared_ptr改为weak_ptr:
struct weak_Node
{
int _val;
std::weak_ptr<weak_Node> _next;
std::weak_ptr<weak_Node> _prev;
~weak_Node()
{
cout << "~weak_Node()" << endl;
}
};
然后我们修改test函数即可:
ps:use_count()函数可以返回当前计数器的值;weak_pt
void test_weak()
{
std::shared_ptr<weak_Node> n1(new weak_Node);
std::shared_ptr<weak_Node> n2(new weak_Node);
n1->_next = n2; //使用shared_ptr构造
n2->_prev = n1;
cout << "n1:" << n1.use_count() << endl;
cout << "n2:" << n2.use_count() << endl;
}
运行结果如下:
weak_ptr的模拟实现:
//辅助型智能指针,配合解决shared_ptr的循环引用问题
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
//使用shared_ptr构造
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
{}
//使用weak_ptr构造
weak_ptr(const weak_ptr<T>& wp)
:_ptr(wp._ptr)
{}
T& operaotr* () { return *_ptr; }
T* operator->() { return *_ptr; }
//不用考虑将之前指向的释放掉 -- 因为weak_ptr不参与资源的管理
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get(); //获取shared_ptr的指向
return *this;
}
private:
T* _ptr;
};
4.5 定制删除器
以下面的Node自定义类型举例:
struct Node
{
int _val;
Node* _next;
Node* _prev;
};
当我们使用new Node[5]的时候,程序便会发生发错。
在Visual Studio 2022中,std::shared_ptr<Node> sp1(new Node[5])
开辟的空间会导致释放崩溃的原因是因为std::shared_ptr
默认使用delete
来释放内存,而不是delete[]
。
当我们new Node[5]的时候,会调用malloc 开辟一块空间,然后调用5次构造函数来构造Node对象。而delete是不知道需要调用多少次析构函数的,所以Visual Studio会在开辟空间的头部多开辟4个字节,用于记录对象个数,并返回第一个对象的地址。这样shared_ptr
在释放空间时便会发生错误。
为了解决这个问题,需要使用std::shared_ptr
的自定义删除器来正确释放数组。在new对象的同时提供一个自定义的删除器,使用delete[]
来释放数组内存。
调用方式如下:
我们这里可以传入一个仿函数对象,同样也可以传入一个lambda表达式。
甚至,这里也可以不使用new,使用malloc;或者直接使用shared_ptr接管文件对象,都是可以的:
而unique_ptr也是同理可以提供定制删除器,但是其传参是在模板参数中。
使用:
如果想让我们自己定义的shared_ptr和unique_ptr支持定制删除器,我们可以再添加一个模板参数
然后修改其析构函数上的删除操作:
五、总结
智能指针常见考点:
为什么需要智能指针?
忘记释放/异常跳转安全
RAII是什么
将资源交给对象去管理
智能指针的发展历史
auto_ptr、unique_ptr、shared_ptr、weak_ptr之间的区别和使用场景
模拟实现各种智能指针
什么是循环引用,如何解决?解决的原理是什么?
为什么要使用定制删除器,写个demo。