1.内存泄漏
1.1什么是内存泄漏
当我们在写C/C++程序的时候,难免会出现内存泄漏的错误,因为C/C++不像Java语言那样,拥有自己的垃圾回收机制,C/C++中对于资源的管理,完全交给程序员自己打理,也就是说使用C/C++的程序员可以直接和内存打交道,写出来的程序效率自然比其他语言的运行速度更快,这是C++的优点,但同样也是C++的缺点,因为,我们难以保证我们是否正确释放了不在使用的资源。比如:当我们因为疏忽大意而忘记释放不在使用的程序;又或者是我们记得释放不再使用的资源,但,因为程序中的执行流乱跳而导致程序没有执行到释放资源的代码;这个时候就会造成内存泄漏。内存泄漏简单来说就是 未释放不再使用的内存资源。
内存是一种有限的资源,使用完之后放回原处(还给操作系统),当程序中其他地方还需要使用的时候,直接向操作系统申请即可;但是,如果使用完之后没有放回原处(未还给操作系统),当其他程序向操作系统申请内存空间的时候,操作系统就会左拼右凑给该程序分配一块内存空间,但是当有非常巨大的内存空间没有还给操作系统时,操作系统就会很尴尬的说,“不好意思,没有,哪个谁谁谁还没还给我呢”,这个时候就会造成程序运行缓慢,严重的话还会造成程序卡死。所以我们应当避免内存泄漏。
1.2如何防止内存泄漏
通常来说,我们申请的内存资源,在不使用的时候记得释放即可,类似于一下代码
int* func()
{
int* ptr = new int[10]; // 申请资源
return ptr;
}
int main()
{
int* ptr = func();
delete[] ptr; // 释放资源
return 0;
}
但是总有一些特殊情况,比如在使用异常的时候,当捕获异常之后,程序执行流直接跳转到匹配的catch语句块中执行,如果在这之间跳过了释放资源的代码语句,就会造成资源泄漏问题;如以下代码:
int divi(int a,int b)
{
if (0 == b)
throw "处零错误";
return a / b;
}
void func()
{
int* ptr = new int;
divi(4,0);
cout << "释放资源" << endl;
delete ptr;
}
int main()
{
try
{
func();
}
catch (...)
{
cout << "出现除零错误" << endl;
}
return 0;
}
可以看出,当出现除0错误的时候,程序跳过了释放资源的语句,造成程序泄漏,所以光记得释放资源也不一定能避免内存泄漏问题,这个时候,C++的前辈们就引入了新的机制,通过智能指针来管理资源。
2.RAII和智能指针的关系
RAII(Resource Acquisition Is Initialization)是一种编程技术,即资源获得即初始化;智能指针是利用这个技术所实现的具体产物;所以学习智能指针之前,很有必要了解一下RAII的思想;RAII的核心思想是将资源的获取(初始化)与对象的构造绑定,将资源的释放与对象的析构绑定,从而把管理一份资源的责任托管给一个对象; 利用C++的作用域和析构函数的特性来自动管理资源,确保资源在不再需要时能够被自动释放,从而避免了资源泄漏和其他资源管理错误。
我们可以类比于局部的临时变量来理解,临时变量只在其作用域有效,当出了作用域就销毁了,这是在栈区上开辟资源的特性;但是我们申请的资源是在堆区的,堆区上的资源 生命周期是随进程的,不会像栈区上的资源那样自动释放,那如果通过栈区上的对象来管理堆区上的资源,是不是就可以保证资源的自动销毁呢?没错,这就是实现智能指针的思想。
RAII的简单代码如下:
template <typename T>
class RAII_test
{
public:
smart_ptr(T* ptr)
:_ptr(ptr) // 资源的获取与对象的构造函数绑定
{}
~smart_ptr() // 资源的释放与对象的析构函数绑定
{
delete _ptr;
}
private:
T* _ptr;
};
上述代码只是展示一下RAII思想,还不能称为智能指针,因为它还不能像指针一样使用;要想实现智能指针,还需要使其具有指针的行为,如:解引用,通过箭头访问。简单的智能指针代码如下:
template<class T>
class SmartPtr
{
public:
// 1.RAII
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
// 2.像指针一样使用
T& operator*() {return *_ptr;}
T* operator->() {return _ptr;}
private:
T* _ptr;
};
3.C++标准库中的智能指针
3.1标准库中智能指针简介
智能指针可谓是解决内存泄漏的神兵利器,C++标准库中提供了四种智能指针,分别是auto_ptr、unique_ptr、shared_ptr、weak_ptr;这时,你就应该有一个大大的疑问了,为什么C++的标准库中要提供四种智能指针呢?提供一个不就好了吗,毕竟他们都是进行管理资源的。这其实和智能指针的拷贝有关。
智能指针之间的拷贝,不同于string,vector这些类的拷贝,这些类拷贝完之后,我们希望拷贝出来的对象拥有自己的资源,所以是深拷贝。而智能指针拷贝完成之后,我们希望拷贝的智能指针应该指向原来的资源,而不是指向自己独有的资源,所以智能指针之间的拷贝应该是浅;但是这就会造成析构两次的问题,第一次析构正常析构,第二次析构的指针就变成野指针了,析构野指针,程序崩溃。所以为了解决智能指针之间的拷贝问题,标准库中提供了四个智能指针。
各个智能指针解决拷贝问题的思想:
auto_ptr:auto_ptr的实现思想是 管理权转移;但是auto_ptr很坑,会导致被拷贝对象置空,一般不建议使用。
unique_ptr:unique_ptr的实现思想是 禁止拷贝;适用于不需要拷贝的场景。
shared_ptr:shared_ptr的实现思想是 通过引用计数来管理资源的释放;允许自由拷贝,但是使用的时候要注意避免循环引用的问题。
weak_ptr:weak_ptr主要 用来解决shared_ptr中的循环引用问题。
auto_ptr和unique_ptr比较简单,下面主要讲解一下shared_ptr。
3.2shared_ptr的引用计数的实现
我们知道shared_ptr是通过引用计数来支持拷贝的,抱着 “知其然,知其所以然” 的态度,我们一起来了解一下shared_ptr是如何通过引用计数来解决对象之间的拷贝问题。
大体思想就是一份资源配一个引用计数,无论多少个对象管理这份资源,都只有一个引用计数。具体实现就是,每个对象存一个找的这个引用计数的指针。如下图所示:
shared_ptr具体实现可以参考下面这份简单的代码:
template<class T>
class myshared_ptr
{
public:
// RAII
myshared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}
myshared_ptr(const myshared_ptr<T>& sp)
{
_ptr = sp._ptr;
_pcount = sp._pcount;
// 拷贝时++计数
++(*_pcount);
}
myshared_ptr<T>& operator=(const myshared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
// 拷贝时++计数
++(*_pcount);
}
return *this;
}
void release()
{
// 说明最后一个管理对象析构了,可以释放资源了
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
// 析构时,--计数,计数减到0,
~myshared_ptr(){release();}
// 像指针一样
T& operator*(){return *_ptr;}
T* operator->(){return _ptr;}
private:
T* _ptr;
int* _pcount;
};
3.3shared_ptr的循环引用问题
shared_ptr千般好万般好,但是在特殊场景下,也会存在缺陷,如以下代码:
struct Node
{
int _val;
myshared_ptr<Node> _next;
myshared_ptr<Node> _prev;
Node(int val = 0)
:_val(val)
{}
~Node()
{
cout << "~Node()" << endl;
}
};
int main()
{
myshared_ptr<Node> ptr1(new Node(1));
myshared_ptr<Node> ptr2(new Node(2));
ptr1->_next = ptr2;
ptr2->_prev = ptr1;
return 0;
}
上面代码的场景如下图:
分析上述场景可知,该场景为有两个结点,结点中的myshared_ptr互相指向对方;析构的时候,ptr2指向的结点先析构,因为ptr1中的成员变量_next指向ptr2所指向的结点,所以引用计数减到1,右侧节点不会释放;析构ptr1指向的结点时,因为ptr2的成员变量_prev指向当前节点,引用计数减到1,左侧结点也不会释放。最终造成两个结点都不会释放,这就是shared_ptr在该场景下的循环引用问题。
为了解决循环引用问题,C++标准库提供了weak_ptr,将定义结点的代码改为下面这份代码即可解决问题:
struct Node
{
int _val;
std::weak_ptr<Node> _next;
std::weak_ptr<Node> _prev;
Node(int val = 0)
:_val(val)
{}
~Node()
{
cout << "~Node()" << endl;
}
};
当使用weak_ptr时,在该场景下,ptr1和ptr2所指向的结点的引用计数,不会因为对方的成员变量weak_ptr类型的对象指向彼此而增加。使用weak_ptr后场景如下:
当对方中的_prev和_next是weak_ptr时,引用计数不会增加,析构的时候,ptr2先析构,引用计数减到0,析构ptr2指向的结点;ptr1再析构,同理,引用计数减到0,析构ptr1所指向的结点;最终,两个结点都析构了。