程序中一般我们关心两种方面的内存泄漏:
RAII(Resource Acquisition Is Initialization) 是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
以下是一个简单智能指针示例:
#include <iostream>
#include <string>
using namespace std;
// RAII
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete smart point success... " << endl;
delete _ptr;
}
private:
T* _ptr;
};
int Test()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除零错误");
return a / b;
}
void Func()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(new int);
cout << Test() << endl;
}
int main()
{
try {
Func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
实际上的智能指针就是一个类的对象,这个类可以帮我们自动析构智能指针,有效的避免了指针释放问题。而我们不仅仅需要智能指针帮我们自动释放资源,我们更需要其要像普通指针那样的作用,所以少不了 -> 与 &操作:
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete smart point success... " << endl;
delete _ptr;
}
T& operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
上述代码依旧存在问题,当我们用一个指针来构造另一个指针的时候:
int main()
{
SmartPtr<int> sp1(new int());
SmartPtr<int> sp2(sp1);
return 0;
}
而这部分也有一些C++发展历史因素,在C++std库中,有一个指针叫做 auto_ptr指针,我们调用试一试:
#include <memory>
int main()
{
std::auto_ptr<int> sp1(new int(1));
std::auto_ptr<int> sp2(sp1);
return 0;
}
看到这里,你可能会很惊讶,C++标准库里居然会发生这样的事情,实际上在我第一看到的时候也是很惊讶,这也说明了世界上没有什么真正完美的东西。
而auto_ptr指针拷贝的问题,通过管理权转移,被拷贝对象把资源管理权转移给拷贝对象。虽然有些编译器不会引发错误,但是如果在后续代码中,我们需要使用sp1指针做其他事情,这个时候不就发生了内存泄漏了吗?所以有些公司是禁止使用auto_ptr指针的。
auto_ptr指针底层实现(相似):
namespace SmartPoint
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T *ptr)
:_ptr(ptr)
{}
T& operator*()
{
return _ptr;
}
auto_ptr(auto_ptr<T>& ap)
{
_ptr = ap._ptr;
ap._ptr = nullptr;
}
T* operator->()
{
return _ptr;
}
~auto_ptr()
{
delete _ptr;
}
private:
T* _ptr;
};
}
不过呢这里也是因为年代比较久远,属于C++98标准的,而C++11过后,对智能指针进行了大更新,新增了许多实用性指针。而为什么不会像auto_ptr这样犯糊涂了,这是因为这次有了先锋者,C++委员会一部分人作为先锋者,对C++一些语法做了很多的尝试,最终形成的产物就是boost库,而C++11就是吸取boost库中精华的部分。
既然智能指针存在拷贝这种问题,那么就有一种简单粗暴的方法,直接禁止拷贝,而C++11中确实存在这种指针,叫做unique_ptr指针,其实现如下(相似):
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
T& operator*()
{
return _ptr;
}
unique_ptr(unique_ptr<T>& ap) = delete;// 禁用拷贝
unique_ptr<T>& operator=(const unique_ptr<T>& ap) = delete;// 禁用赋值
T* operator->()
{
return _ptr;
}
~unique_ptr()
{
delete _ptr;
}
private:
T* _ptr;
};
对其做的改动仅仅是将拷贝构造函数给delete(禁用)掉了,同时,如果将拷贝构造禁用了,那么不可避免,赋值也一定需要禁用,如果不禁用赋值,那么编译器会默认生成一个赋值重载,但是却是浅拷贝,依旧不可解决问题,而一般我们禁用拷贝构造,都会将赋值重载也禁用。
综上所述,unique_ptr智能指针简单粗暴,适用于不拷贝不赋值的场景。
如果有些场景刚好需要用得到拷贝与赋值重载的智能指针,C++11提供了shared_ptr指针,其具体使用的方式是 使用引用计数来解决多次释放的问题。
这里的引用计数在类内实现起来不能直接使用int类型,因为如果直接使用一个局部变量这样每一个对象都会拿到一份引用计数,而我们的 目的是 每个资源都配有一个引用计数。
那么又有人说,我们将引用计数变为静态的不就行了吗,引用计数也不能使用静态成员变量来实现,静态成员确实让全局看得到,但是当我们新构造了一个对象,也就是新生成了一个对象,它会默认初始化引用计数,将全局的引用计数改为1。如下图所示:
那能有什么好办法来解决这种问题呢?C++11中,shared_ptr的做法是 将每个对象存一个指向引用计数的指针。
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
T& operator*()
{
return _ptr;
}
shared_ptr(shared_ptr<T>& sp)
{
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);// 引用计数++
}
int use_count()
{
return *_pcount;
}
T* operator->()
{
return _ptr;
}
~shared_ptr()
{
if (--(*_pcount) == 0)
{
std::cout << "delete shared_ptr success ..." << std::endl;
delete _ptr;
delete _pcount;
}
}
private:
T* _ptr;
int* _pcount;
};
这里就很好的解决了引用计数的问题,当我们使用引用计数的时候,直接通过指针进行操作即可。前面我们说了,如果要禁用拷贝构造,那么通常需要禁用赋值重载。相反,既然我们需要拷贝构造,同样也需要赋值重载:
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*pcount);
}
这种写法是很多人经常写出来的,实际上这种写法是一定不对的,我们来模拟一个场景,sp1, sp2, sp3三个智能指针指向同一份资源,然后在创建一个新指针:
int main()
{
SmartPoint::shared_ptr<int> sp1(new int(1));
cout << sp1.use_count() << endl;
SmartPoint::shared_ptr<int> sp2(sp1);
cout << sp2.use_count() << endl;
SmartPoint::shared_ptr<int> sp3(sp2);
cout << sp3.use_count() << endl;
SmartPoint::shared_ptr<int> sp4(new int(5));
sp1 = sp4;
return 0;
}
这里sp4已经赋值给了sp1,所以原本sp1, sp2, sp3共用同一份资源,现在只有sp2, sp3共用同一份资源,而sp1与sp4共用同一块资源,那么如此,sp2与sp3的引用计数应与sp1与sp2的引用计数相同,都为2。很明显,我们在赋值引用计数需要先将之前的资源减去,再添加新资源。
这就会造成原本资源的引用计数多了一个,所以我们在赋值重载之前,需要先将原来资源的引用计数自减, 同时如果是第一个创建的元素,引用计数为1,这时候赋值就不能自减了:
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
return *this;
}
void release()
{
if (--(*_pcount) == 0)
{
std::cout << "delete shared_ptr success ..." << std::endl;
delete _ptr;
delete _pcount;
}
}
已经很完善了,但是我们还有一种情况没有考虑到,自己给自己赋值,以及不同对象但是同一资源之间相互赋值。这两种情况都会导致上面步骤白做了。为了避免这种无意义开销,我们可以:
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
return *this;
}
}
void release()
{
if (--(*_pcount) == 0)
{
std::cout << "delete shared_ptr success ..." << std::endl;
delete _ptr;
delete _pcount;
}
}
到此,shared_ptr看起来非常完美了,该有的功能也都有了,但是实际上shared_ptr还有一个致命的缺陷,循环引用:
#include<iostream>
using namespace std;
struct ListNode {
ListNode(int val = 0)
:_val(val)
, _next(nullptr)
, _prev(nullptr)
{}
int _val;
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
};
int main()
{
std::shared_ptr<ListNode> n1(new ListNode(10));
std::shared_ptr<ListNode> n2(new ListNode(20));
n1->_next = n2;
n2->_prev = n1;
return 0;
}
最后两句将n1指向n2,n2的prev指向n1,这样就会导致循环引用,最终内存泄漏。我们来逐步分析一下为什么会导致循环引用,以及其是什么:
如果这两条语句只有任何一条语句执行,都不会报错,就如上图,当n1指向n2时,n2的引用计数++, 假设n2生命周期先到,那么n2 先析构,引用计数-1,然后n1的生命周期结束,n1调用自己的析构函数,引用计数减为零,清理n1资源,而n1的_next指向n2, 所以同时会调用n2的析构函数,那么n2引用计数也减为零,n2清理资源。
但是两条语句放在一起,就会造成内存泄漏:
如果是上图中的代码,那么都会有一次赋值操作,又因为赋值时自己的引用计数本身就是1,所以不会被减去,而对方的引用计数又会增加,所以他们的引用计数都是2。那么我们就来分析一下,这个代码为什么会内存泄漏:
首先,假设n1的生命周期先到,那么n1调用析构函数,n1的引用计数减一,剩余1。然后n2的生命周期也到了,调用n2的析构函数,n2的引用计数也自减,剩余1。我们知道,只有当引用计数为0的时候才会delete, 否则只减去引用计数。 此时双方的引用计数都为1,n1要想析构,需要由n2先析构(因为n2._prev管理着n1,当n2 delete时,会自动调用n1的析构函数),而n2要想析构,需要n1先析构(n1._next管理着n2,当n1被delete时,会自动调用n2的析构函数)。所以这个时候双方都没办法调用对方的析构。这就是叫做循环引用的原因。 右边节点释放 —> _prev析构 —> 左边节点释放 —> _next析构—> 右边节点释放
这就是shared_ptr在特定场景下的缺陷。虽然引用计数能解决多次释放的问题,但是这种遍历也恰恰是一些场景的坑。
如果不是new出来的对象,我们该如何管理呢?实际上,shared_ptr提供了 删除器功能:
删除器实际上就是使用 仿函数,或者 lambda表达式 来释放指针,比如底层使用malloc建立指针,我们仿函数就需要实现一个free,可用shared_ptr指针进行调用从而清理资源。
template<class T>
struct Free
{
void operator()(T* ptr)
{
std::cout << "free: " << std::endl;
free(ptr);
}
};
int main()
{
std::shared_ptr<int> sp1((int*)malloc(4), Free<int>());
return 0;
}
因为shared_ptr在特定场景下会发生循环引用导致内存泄漏,所以C++11准备了weak_ptr可以避免这个场景,weak_ptr不支持RAII。也就是说weak_ptr不参与资源的管理,不支持T*指针去初始化,也没有析构函数,其作用就像是对shared_ptr特殊场景做特殊管理的智能指针类。
struct ListNode {
ListNode(int val = 0)
:_val(val)
{}
int _val;
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _prev;
};
int main()
{
std::shared_ptr<ListNode> n1(new ListNode(10));
std::shared_ptr<ListNode> n2(new ListNode(20));
n1->_next = n2;
n2->_prev = n1;
return 0;
}
如果ListNode内部使用weak_ptr作为智能指针,就不会导致循环引用的问题了,这不代表我们不使用shared_ptr来处理问题,导致循环引用计数的主因就是shared_ptr的拷贝构造与赋值重载,所以我们在底层其他事情依旧交给shared_ptr指针去干,只不过在赋值与拷贝时,交给weak_ptr来做,可以有效避免循环引用计数。
以下是weak_ptr实现(类似):
template<class T>
class weak_ptr
{
public:
weak_ptr()// 不支持指针初始化,所以有空参构造
:_ptr(nullptr)
{}
weak_ptr(shared_ptr<T>& sp)// weak_ptr作为旁观者,不参与资源管理, 这里目的是为了不走shared_ptr的拷贝构造,本质是不让shared_ptr的引用计数增加
{
_ptr = sp.get();
}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)// 同理,weak_ptr的赋值重载本质也是不让特殊场景的指针走shared_ptr的赋值,从而实现赋值不会增加引用计数
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
虽然我们说weak_ptr不参与资源的管理,实际上在C++标准库当中,weak_ptr是拥有引用计数的,以便能够跟踪有多少个 weak_ptr 实例指向同一个资源。这是因为 weak_ptr 需要在确定对象是否仍然有效时与 shared_ptr 交互。