本篇是续着前一篇异常中知识点来讲的,各位不了解的可以先看一下:【C++】异常。
下面我们先分析一下下面这段程序有没有什么内存方面的问题?
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;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
上面的问题分析出来我们发现有什么问题?
如果是p1处的new抛异常了,问题不大。
如果是p2处的new抛异常了,直接跳走,p1的空间就得不到释放,导致内存泄漏。
如果是div()处抛异常了,直接调走,p1和p2的空间就得不到释放,导致内存泄漏。
异常中也是有一个跟此处很相似的例子,只不过那里空间开的是数组的空间。但也是抛异常导致内存泄漏。
内存泄漏,老生常谈的问题了。这里细讲一下:
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定
总结一下,内存泄漏非常常见,解决方案分为两种:
a. 事前预防型。如智能指针等。
b. 事后查错型。如泄漏检测工具。
上面避免内存泄漏中提到了RAII,这个就是智能指针中用到的技术。
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
Resource Acquisition Is Initialization翻译一下就是资源申请即初始化。
听起来比较晦涩,说人话就是把管理一份资源的责任托管给了一个对象,在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。
看例子:
这就算是一个简单的智能指针,就是在类中搞一个指针而已,用这个指针来维护某一段地址。只是叫个智能指针听起来高大上了一点。
这样将空间托管给类对象,类对象只要生命周期一到就会自动调用析构函数将空间释放掉。不会说像内置类型指向某一块空间,生命周期结束之后不会自动释放所指向的空间,还得要在其生命周期结束前手动释放。
空间都会被释放。
借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
所以说,有了异常之后指针这些东西就不要自己随便搞了,你把握不住的,让类对象来把握。
测试:
成功。
其实库中也是有智能指针的,C++98中就有auto_ptr,但是实现的很不好,被吐槽了很多年。
但是智能指针最大的问题不在前面的->、*什么的,在于拷贝问题。
智能指针能深拷贝吗?
答案是不能的,分两点:
所以这里智能指针需要的就是浅拷贝。
但又有问题了,既然是浅拷贝,那么当多个智能指针共同维护一段空间时,该什么时候释放呢?
ok,库中给出了4种不同的智能指针,一个就是刚刚说的C++98中的auto_ptr,剩下的三个是C++11之后提供的unique_ptr / shared_ptr / weak_ptr。先接着前面的auto_ptr来介绍。
C++98中提供的这个auto_ptr只能说非常的挫。。。
看起来没什么问题,但是如果我调试一下:
可以看到,sp1中的数据直接没了,其中的指针直接指向了空,而sp2接管了sp1原先维护的数据。
auto_ptr的拷贝是进行资源管理权的转移,是一种非常不负责任的做法, 若托管元空间的sp1给了sp2,sp1托管的就变成空了,所以就导致被拷贝对象(sp1)的悬空,不清楚底层的人一解引用sp1中的内容整个程序就崩了,所以前面说auto_ptr实现的很挫。
而且如果两个智能指针同时维护一块空间,会导致程序崩掉:
因为两次析构了同一块空间:
这种实现导致auto_ptr被骂了很多年。而且很多公司明确要求不能使用它,但有部分公司使用的都是和规矩使用就行,比如说不去解引用托管前的智能指针。
但虽说实现的很挫,我们还是要了解了解的,可以避免我们以后写出这种代码。
所以这里就模拟实现一下auto_ptr,其实就和前面smart_ptr差不了太多,主要就是拷贝构造和拷贝赋值重载。
其实就是把名字换了一下,然后再写拷贝构造:
就这么简单,先托管给当前的智能指针,然后再将原托管的智能指针赋值为空。但要注意,不能用const auto_ptr
再说赋值重载:
测试:
成功。
完全ok的。
因为auto_ptr用起来非常难受,C++11中又提供了unique_ptr / shared_ptr / weak_ptr这三种智能指针。
很简单,C++11后,直接在拷构和拷赋后面加上delete就行:
但C++98的话,就得只声明不定义,而且还要将两个函数搞成私有的:
这样做的目的是防止有老六在类外实现这两个函数。
可以看到,unique_ptr只适用于不需要拷贝的一些场景,比较局限。
需要拷贝的话,就要用shared_ptr。
先看怎么用:
可以看到二者是公用一块空间的,而且最后还只释放了一次。
那么其底层用的就是引用计数,这个知识点在我前面的博客中是有的,不懂的同学点传送门:【C++】手把手教你模拟实现string类,只看最后那点就行。
给每个空间生成对应的count,count代表当前空间有多少个智能指针指向这里,在每个对象析构的时候--count,最后一个析构的对象再真正释放空间资源。
需要变的就是引用的计数,构造,拷贝构造,拷贝赋值了。
先说计数。
我们应该怎么设置这个计数?
可以让每个对象都生成对应的int count吗?
不可以,因为当多个对象指向同一空间的时候,++或--count加的是各自的count,而非针对某个空间的count。
可以生成一个static int count吗?
不可以,看图:
再配合这个:
请问图中sp1、sp2、sp3、sp4的引用计数_count各是几?
答案是都是4。
因为static成员属于整个类的,所有相同类型的类对象共享一个count,这里指针类型都为int,所以4个智能指针中的count都是同一个count。
那么这就出问题了。按照上面的顺序,析构的时候先析构sp4,但是sp4的count为4,- -后不为0,所以就不会释放掉p2的空间,导致内存泄漏。
所以这里用static是行不通的。
再换一种方法:我们可以对每个空间资源开一份单独的count空间,专门用来计数:
这样就好说了。
我们来实现一下:
拷赋:
这里赋值拷贝细节稍微多一点,赋值之前要判断当前被赋值的对象是否是和赋值对象相同,相同就不能赋值,不然就会出错。而且赋值的时候要看被赋值的对象所指的空间是否有多个对象维护,如果有多个,直接- -count就行了,如果只有当前一个,就要把那块空间释放掉。
赋值前:
可以。
注意,库中的shared_ptr构造函数加了explicit关键字,不支持隐式类型转换:
如果我想直接让自定义类型shared_ptr赋值给原生指针:
是不行的。
但是我们可以改一下Node:
如果我们只留下一条赋值语句:
是可以释放的。这里就是自定义类型成员析构函数的自动调用。
上面的next和prev赋值时,会导致其资源空间对应的的引用计数++,从而两个节点引用计数都变为2,当释放空间是,n1,n2调用析构函数,但是因为计数为2,故二者的析构只会让两节点的引用计数- -,所以此时两节点的引用计数都变为了1,左侧节点1,来自右侧节点的prev,右侧节点的1来自左侧节点的next,当右prev释放的时候,就能够释放掉左侧节点,当左侧节点的next释放时就能释放掉右侧节点,但是Npde自定义类型空间释放的时候才会调用其中的shared_ptr
这就是循环引用。
我们可以用weak_ptr来解决,weak_ptr不是常规的指针,没有RAII,不支持直接管理资源,weak_ptr主要用shared_ptr构造,用来解决shared_ptr循环引用问题。
所以可将Node再改一下:
weak_ptr中有一个构造函数专门用来用shared_ptr来构造weak_ptr对象的:
所以就能这样:
weak_ptr所指向的空间,不参与资源释放管理,但可以访问和修改资源,不会增加计数,不存在循环引用的问题了。
所以就算next和prev指向了之后,个节点的引用计数任然为1。此时析构就能析构成功。
我再在模拟实现的shared_ptr中添加一个 use_count:
我们这里Node用库中的weak_ptr接收不了我们实现的shared_ptr,所以这里我们就自己模拟实现一下weak_ptr:
默认、拷贝、shared_ptr :
shared_ptr要获得其中的_ptr,但是_ptr是一个私有的,所以要么友元,要么直接提供一个函数接口来返回这个_ptr,但是友元一般不用,会破坏封装性,所以这里就用函数接口了:
这里就只实现一个shared_ptr:
因为weak_ptr不参与资源的释放,所以不需要考虑那么多,直接赋值就行。
但因为这里sp是const的,所以得要让getPtr改为const的,不然调不到。
几个指针就到这,但是注意库中的这几个可不是这么简单,比如说weak_ptr也会有引用计数,但是实现的比较复杂,这里就不搞了,weak_ptr的引用计数有线程安全问题,但由于我之前没讲过线程,没法讲,等我把线程的博客写了再把这块补上。
听起来挺牛的。
但是没那么难。
这里要解决的就是前面的new[ ]的问题,当new[ ]时,我们这里底层实现的是delete,而非delete[ ],所以new[ ]是删不干净的。
看这篇:浅谈 C++ 中的 new/delete 和 new[]/delete[]
里面说了,当new [ ] 内置类型的时候,delete[ ]不会调用析构函数,所以在开空间时不会在空间前方开一个4字节的用来统计[ ]中的new的个数。而自定义类型因为要调用析构时,才会在前面开4个字节的空间来保存要析构次数。
所以说上面的代码,析构Node的时候,需要调用其构造函数,那么就会在new[ ]的时候再开4字节的空间。此时若调用delete,则只会调用一次析构,但是Node的析构并没有释放空间,而是delete再调用free的时候才会出问题,因为这里free不会去释放前面4个字节的空间。但是delete[ ]就不一样了,
delete[ ]时会调用free会释放(char*)ptr - 4,这就会释放掉那四个空间。
那么既然直接用delete不行,库中是怎么搞的呢?
上面我圈红色的,直接在构造函数中传一个仿函数对象,这个对象决定了用哪种方式来释放空间。
写一个专门释放[ ]的仿函数:
再比如说,用malloc和free:
同样的,unique_ptr也可以释放new[ ]出来的。
但是实现的方式和shared_ptr不太一样。
这次传的是类型,shared_ptr中传的是对象。一个是模版参数,一个是函数参数。
同样也可以用文件:
但是要写对应的仿函数:
这里可以用特化,但是上面实现的两个类名跟这个没有啥关系,所以就直接再写一个仿函数了。
这里就只模拟一下shared_ptr,但是用的方法是unique_ptr的。因为库中shared_ptr中是单独搞了一个成员变量(类对象)来搞释放功能的,实现起来的话前面的逻辑都要改,这里就偷个懒,直接用模版参数来搞。
然后加到参数上:
放在operator = 和析构中:
强调一下本篇中几个面试常问的问题:
为什么需要智能指针?
忘记释放空间/异常安全 等会导致内存泄漏。
什么是RAII
获得资源及初始化,将资源托管给对象释放。
auto_ptr、unique_ptr、shared_ptr、weak_ptr的发展史
4个智能指针的区别是什么?
模拟实现简洁版的智能指针。
什么是循环引用?如何解决循环引用?解决的原理是什么?
前面两个给的是简答,后面四个本篇详细讲了,不再赘述。
这篇讲完了。
到此结束。。。