1 前言
我们来回顾一下在学习异常机制中遇到的一种问题:在try catch
语句中,如果我们开辟了一段空间,但是发生了异常,会直接终止掉函数栈桢,导致内存泄漏问题。所以此时就要在catch
语句中进行一个特殊处理。如果我们开辟了多段空间,那么这个操作就会变得更加复杂:假如new
失败了,就会直接返回到上层的catch语句,也导致了内存泄漏问题!使用传统是异常机制来解决问题会产生大量冗余的语句 — 大量的try catch
嵌套!
为了解决这个问题,可以使用智能指针!可以简单的来进行解决!
智能指针类似lock_guard
,是对指针的封装,可以实现在超出生命周期之后自动销毁的功能!
void func()
{
int* p1 = new int[10];
int* p2 = nullptr;
try
{
p2 = new int[20];
try
{
double a, b;
cin >> a >> b;
Division(a, b);
}
catch (...)
{
delete[] p1;
cout << "delete: p1" << endl;
delete[] p2;
cout << "delete: p2" << endl;
throw ;
}
}
catch(...)
{
delete[] p1;
cout << "delete: p1" << endl;
throw;
}
delete[] p1;
cout << "delete: p1" << endl;
delete[] p2;
cout << "delete: p2" << endl;
return;
}
在这个程序中,开辟空间和销毁空间是一个重要问题,为了防止被抛出异常就直接销毁堆栈,就要设置多重的try catch
来保证不会发生内存泄漏!对于这样的问题,我们可以设计一个smartptr
类来帮助我们解决!
class SmartPtr
{
public:
SmartPtr(int* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
delete[] _ptr;
cout << "delete!" << _ptr << endl;
}
private:
int* _ptr;
};
这样一个类包装了一个指针,我们不需要在显式delete
了,只要生命周期结束,就会自动释放空间!
这样在开辟空间时,就直接进行构造不就好了!这样就直接避免了复杂嵌套的try catch
语句!
void func()
{
SmartPtr sp1(new int[10]);
SmartPtr sp2(new int[20]);
double a, b;
cin >> a >> b;
Division(a, b);
return;
}
再也不用担心忘记释放开辟的空间了!内存泄漏问题直接远去了~
我们把这种封装称之为RAII
:
RAII(Resource Acquisition Is Initialization 资源请求立即初始化)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->
去访问所指空间中的内容,因此AutoPtr
模板类中还得需要将* ->
重载下,才可让其像指针一样去使用!还需要进行一个拷贝构造的特殊处理,否则就会出现对同一片地址析构两次的场景
在C++memory
库中有以下几种智能指针:
我们来看auto_ptr
是如何解决拷贝问题的:
也就是说auto_ptr
支持两个对象指向同一片空间!通过拷贝时转移管理权来解决这种析构多次的问题(类似移动构造)。但是这样的处理方式实际上是很不合理的!sp1
并不是一个将亡值,sp2
凭什么将sp1
的资源转移走!?“我还活着了 , 怎么就把我埋了!”,在接下来代码中,如果我们再次调用了sp1
就会直接导致程序的崩溃!所以这样的设计是一个失败的设计!
所以auto_ptr
尽量就不要进行使用!
所以auto_ptr
尽量就不要进行使用!
所以auto_ptr
尽量就不要进行使用!
在C++11中加入了shared_ptr unique_ptr weak_ptr
,一般建议使用unique+ptr
和 shared_ptr
。来看一下他们支持什么操作:
unique_ptr
构造支持无参构造,移动构造… , 就是不支持拷贝构造!因为拷贝有问题所以就不让拷贝!直接避免了问题出现!get
:获取到智能指针内部的指针!release
:显式释放空间!-> *
:支持的指针操作!shared_ptr
支持所有的构造,包括拷贝构造!其引入了引用计数
的概念(Linux中很常见)!get
:获取到智能指针内部的指针!release
:显式释放空间!-> *
:支持的指针操作!make_shared
:类似make_pair
,可以进行创建shared_ptr
我们一般推荐使用shared_ptr
,其独有的引用计数机制,极大程度复原了指针的实际用法,并且能做到RAII
技术!
但是,shared_ptr
存在一个问题:循环指向问题!这种问题主要出现在循环链表中,每个节点有两个指针,分别指向前一个节点和后一个节点。当我们有两个节点时,我们都使用shared_ptr
进行包装管理:
struct Node
{
bit::shared_ptr<Node> _next;
bit::shared_ptr<Node> _prev;
~Node()
{
cout << "~Node()" << endl;
}
};
int main()
{
bit::shared_ptr<Node> sp1(new Node);
bit::shared_ptr<Node> sp2(new Node);
sp1->_next = sp2;
sp2->_prev = sp1;
return 0;
}
这样按理说每个节点的引用计数都为2(自身 + 另外节点中的智能指针),在程序结束运行时,会调用sp1 sp2
的析构函数,这样会让其引用计数变为1
。接下来就是复杂的问题了,由于刚才并没有让引用计数变为0,两个节点中的的_next; _prev;
都还托管着数据,但是他们两个谁先析构呢?这类似经典的先有鸡 先有蛋
问题,这就是循环指向问题!
解决这个问题单凭shared_ptr
是没有办法解决的,这里就要引入weak_ptr
了:
weak_ptr
并不支持直接来进行管理指针资源,不支持RAII
。但支持无参构造和拷贝构造,专门用来辅助解决shared_ptr
的循环指向问题!我们只需要将Node
里面的指针使用weak_ptr
来进行托管就可以了:
struct Node
{
weak_ptr<Node> _next;
weak_ptr<Node> _prev;
~Node()
{
cout << "~Node()" << endl;
}
};
int main()
{
shared_ptr<Node> sp1(new Node);
shared_ptr<Node> sp2(new Node);
sp1->_next = sp2;
sp2->_prev = sp1;
return 0;
}
因为weak_ptr
本质赋值或拷贝时,只指向资源,不会增加引用计数!所以就不会造成先有鸡先有蛋
的问题!
智能指针内部还支持自定义删除器,因为在构造时并不能保证默认析构可以释放掉我们开辟的空间,比如
malloc
的时候,默认的delete
是不能满足条件的delete
delete[]
来进行释放空间所以为了更是适配内存管理的多样性,智能指针支持自定义删除器,即支持用户显式传递删除方法!
这里可以使用仿函数来进行传递,但是在C++11之后使用lambda
表达式更加简约直观!
int main()
{
shared_ptr<A> sp1(new A(1 , 1));
shared_ptr<A[]> sp2(new A[10]);
shared_ptr<FILE> sp3(fopen("file.txt", "w"), [](FILE* sp) { fclose(sp); });
shared_ptr<int> sp4( (int*)malloc(4) , [](int* sp) { free(sp); });
shared_ptr<A> sp5(new A[10], [](A* sp) {delete[] sp; });
return 0;
}
这样就解决了不同类型指针释放方式不一致的问题!
我们来实践一下shared_ptr
,其在面试中常常会考到,这智能指针的主要思想是RAII
,将指针封装起来,保证其在生命周期内存在,离开生命周期就自动释放掉!
首先智能指针内部需要一个指针变量来储存数据。重要的是如何将引用计数加入其中,如果直接使用一个int count
肯定是不行的,这样每个对象都有自己的count,无法做到引用计数的功能。如果使用静态变量,那么所有的类对象只有一个计数,这样肯定也是不可以的!那么要如何解决这个问题呢?为引用计数单独开辟一块空间,进行拷贝的时候就将这个空间进行传值,这样所有进行拷贝的对象都可以读取到同一个引用计数的数据!
构造函数可以直接写出来,析构就在引用计数为0的时候进行释放空间!
template< class T>
class shared_ptr
{
public:
//默认构造函数
shared_ptr(T* ptr = nullptr)
:_ptr(ptr),
_pcount(new int(1))
{
}
~shared_ptr()
{
release();
}
void release()
{
if (--(*_pcount) == 0)
{
//最后管理的一个对象 , 释放资源
delete _ptr;
delete _pcount;
}
}
private:
//内部指针
T* _ptr;
//引用计数
int* _pcount;
};
这里最为重要的就是这个拷贝构造和赋值重载如何进行书写!我们需要模拟到和原生指针一样,可以让不同的指针对象指向同一块空间 ,并且不能发生重复析构的问题:
_ptr _pcount
进行拷贝就可以,不要忘记进行引用计数的++1
的时候会出现野指针问题//拷贝构造
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr),
_pcount(sp._pcount)
{
(*_pcount)++;
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if( _ptr == sp._ptr)
{
return *this;
}
this->release();
_ptr = sp._ptr;
_pcount = sp._pcount;
(*_pcount)++;
return *this;
}
首先为了适配自定义删除器,我们需要多加一个成员变量_del
,使用function包装器进行包装,可以省去很多不必要的操作!
成员变量添加:
std::function<void(T*)> _del = [](T* p){ delete p; } ;
这个包装器就是用来包装删除器的,加入了删除器,我们就要再写一个单独的构造函数来满足:
template<class D>
shared_ptr(T* ptr = nullptr , D del = [](T* p) { delete p; })
: _ptr(ptr),
_pcount(new std::atomic<int>(1)),
_del(del)
{
}
这样在显式调用构造的时候就可以进行自定义删除器的添加了:
bit::shared_ptr<FILE> sp3(fopen("file.txt", "w"), [](FILE* sp) { fclose(sp); });
bit::shared_ptr<int> sp4( (int*)malloc(4) , [](int* sp) { free(sp); });
bit::shared_ptr<A> sp5(new A[10], [](A* sp) {delete[] sp; });
为了让shared_ptr
可以有原生指针的使用方法,我们需要对* ->
进行一个重载!这我们已经很熟悉不过了!
然后就是设计一个get()
和use_count()
函数!都很简单!
T* get()
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *(_pcount);
}
上面已经实现了正常情况下的智能指针的使用,我们来看多线程情况下会不会出现问题。
在下面的程序中,我们设置了三个线程同时对sp1
内部的链表进行尾插,尾插是临界的,我们用锁进行保护。
我们用锁进行了保护,可是还是出现了错误!
为什么会出现这样的问题?首先我们分析一下临界区,在share_ptr
中引用计数是临界的!为什么呢?因为引用计数的操作++ --
是非原子的!多个线程中我们不断进行copy
拷贝,会对引用计数不断进行++--
,导致了问题!为了从根本上解决这个问题,我们就要保证操作是原子的!我们可以在类中加入一个锁来保证++--
中进行保护。但是最直接的就是将引用计数
变成原子的就可以了!
//引用计数
std::atomic<int>* _pcount;
这样就可以保证拷贝和析构的时候就是原子的了就不会出现问题了!!!就可以保证线程安全了!
注意我将shared_ptr
完善之后:
最后我们来回顾一下内存泄漏问题:
对于C++来说,内存泄漏是很严重的问题!C++没有和JAVA的垃圾回收机制。 在正常的一个程序中,内存泄漏其实影响并不大,我们开辟一段空间,如果没有释放,在进程结束的时候也会被释放掉,因为我们开辟的空间都是虚拟内存,进程结束之后会把虚拟地址一并收拾带走。就怕进程异常结束,变成僵尸进程挂起,此时虚拟地址和物理内存依然存在映射,此时就完蛋了。再加上如果是长期运行的代码,内存泄漏的不断积累会导致内存空间越来越小!
C/C++程序中一般我们关心两种方面的内存泄漏:
内存泄漏可以通过第三方库来进行检测,当时这些并不是很好用,并且在实际工作中,编译运行一次程序可能需要很长时间,那么通过第三方库来检测是很费事的!所以尽量在使用中就要避免内存泄漏的问题:
总而言之: 内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。