目录
前言
1.内存泄漏及其危害
2.内存泄漏分类:
3.如何检测内存泄漏
4.如何避免内存泄漏
一、为什么需要智能指针?
二、智能指针的使用及其原理
1.RAII
2.智能指针
3.std::auto_ptr
4.std::unique_ptr
6.std::weak_ptr
前言
在学习智能指针前,我们需要对内存泄漏有一定的了解。(若已了解内存泄漏,可直接跳转第一节)
1.内存泄漏及其危害
内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
2.内存泄漏分类:
- 堆内存泄漏(Heap leak)堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
- 系统资源泄漏指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定
3.如何检测内存泄漏
在linux下内存泄漏检测 :Linux下几款C++程序中的内存泄露检查工具_c++内存泄露工具分析-CSDN博客
在windows下使用第三方工具:
VS编程内存泄漏:VLD(Visual LeakDetector)内存泄露库_visual leak detector vs2020-CSDN博客
其他工具:
内存泄露检测工具比较 - 默默淡然 - 博客园 (cnblogs.com)
4.如何避免内存泄漏
1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
2. 采用RAII思想或者智能指针来管理资源。
3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
总结一下:内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。
一、为什么需要智能指针?
我们知道异常(机制)可以检测程序的不正常行为并对该行为做出响应,可以快速识别出程序执行中的异常,但在throw表达式响应异常随之寻找匹配的catch语句时,会中断程序的正常执行,如果throw表达式后有对资源的释放,执行异常便会造成内存泄漏。
对于这个问题,在异常一文中,我们是通过重新抛出异常的方法优化这个问题,但重新抛出异常的过程中,可能又涉及资源没有释放,这个问题本质上还是没有解决。
double Division(int a, int b)
{//当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";}return (double)a / (double)b;
}
void Func()
{int* array1 = new int[10];int* array2 = new int[20];// 捕获异常释放资源再抛异常再捕获,并输出异常信息try {int len, time;cin >> len >> time;cout << Division(len, time) << endl;}catch (...){cout << "delete []" << array1 << endl;delete[] array1;cout << "delete []" << array2 << endl;delete[] array2;throw; // 捕到什么抛什么}cout << "delete []" << array1 << endl;delete[] array1;cout << "delete []" << array2 << endl;delete[] array2;
}
int main()
{try{Func();}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
在这段代码中,我们利用重新抛出异常,防止内存泄漏,但如果是在给array2申请空间时,new引发了异常,array1申请的资源没有释放,这该怎么办呢?
这些问题的本质就是,我们申请资源后,需要我们自动释放,有没有一种办法,可以不用我们自己释放,程序会自动释放呢?熟悉类和对象的同学,应该立马就想到了析构函数,析构函数会在对象生命周期结束时被编译器自动调用,释放对象申请的资源。那么我们可不可以将指针封装成类,对指针定义就是对类实例化,指针申请资源,就是对象申请资源,指针申请资源后我们要释放,对象申请资源,当对象要销毁时编译器会调用它的析构函数释放资源,这样是不是就解决这个问题了?
看上去好像如此简单,我们只要用类封装指针即可,如下所示:
template<class T>
class SmartPtr
{
public:// RAIISmartPtr(T* ptr):_ptr(ptr){}~SmartPtr(){delete[] _ptr;cout << "delete[] " << _ptr << endl;}
private:T* _ptr;
};
double Division(int a, int b)
{//当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";}return (double)a / (double)b;
}
void Func()
{SmartPtr<int> array1(new int[10]);SmartPtr<int> array2(new int[10]);int len, time;cin >> len >> time;cout << Division(len, time) << endl;
}
int main()
{try{Func();}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
我们用一个异常例执行该程序,看看array1和array2会不会正常释放空间。
正常释放。
我们在用一个非异常例执行,
也正常释放。
也就是说,不管程序有没有执行异常,我们在程序中申请的资源都可以正常释放,而这,仅仅时是因为我们对指针进行了类的封装。上面我们只用类模拟指针的构造和析构,指针还有解引用、拷贝等运算和操作。
下面,我们就正式学习一下智能智能吧。
二、智能指针的使用及其原理
1.RAII
RAII - cppreference.com
RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),它是在一些面向对象语言中的一种惯用法。RAII源于C++,在Java,C#,D,Ada,Vala和Rust中也有应用。1984-1989年期间,比雅尼·斯特劳斯特鲁普和安德鲁·柯尼希在设计C++异常时,为解决资源管理时的异常安全性而使用了该用法[1],后来比雅尼·斯特劳斯特鲁普将其称为RAII[2]。
RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。
RAII有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效
RAII和智能指针的关系:
智能指针是RAII思想的一种实现。
// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr
{
public://RAIISmartPtr(T* ptr):_ptr(ptr){}~SmartPtr(){cout << "~SmartPtr()->" << _ptr << endl;delete _ptr;}
private:T* _ptr;
};
2.智能指针
上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指空间中的内容,因此:SmartPtr模板类中还得需要将* 、->重载下,才可让其像指针一样去使用。
//头文件:SmartPrt.h
template<class T>
class SmartPtr
{
public://RAIISmartPtr(T* ptr):_ptr(ptr){}~SmartPtr(){cout << "~SmartPtr()->" << _ptr << endl;delete _ptr;}//像指针一样//解引用T& operator*(){return *_ptr;}//当T是含有多个类型参数的类型时(拥有多个成员变量)//访问某个成员变量时,可能需要->T* operator->(){return _ptr;}
private:T* _ptr;
};
我们运行下面的程序:
#include "SmartPtr.h"
void TestSmartPtr1()
{SmartPtr<int> sp1(new int);*sp1 = 1;SmartPtr<pair<string, int>> sp2(new pair<string, int>("xxxx", 1));sp2->first += "y";//编译器会优化成sp2.operator->()->first += "y"sp2->second += 1;//sp2.operator->()->second += 1;cout << sp2.operator->()->first << " " << sp2->second << endl;
}
int main()
{TestSmartPtr1();return 0;
}
输出:
Ok,代码没有问题。真的没有问题吗?我们模拟下指针的赋值运,
void TestSmartPtr2()
{SmartPtr<int> sp1(new int);SmartPtr<int> sp2 = sp1;
}
int main()
{TestSmartPtr2();return 0;
}
输出:
我们发现控制台打印了两次析构函数,且都指向同一个地址,然后程序出现异常。
原来是对同一块空间析构了两次,这怎么办?
我们来看看智能指针的发展历史,看看能不能找到解决方法。
3.std::auto_ptr
C++98版本的库中提供了auto_ptr的智能指针。我们来看看auto_ptr是怎么解决这个问题的。
我们执行下面的程序:
void test_auto_ptr1()
{auto_ptr<int> ap1(new int);auto_ptr<int> ap2 = ap1;
}int main()
{test_auto_ptr1();return 0;
}
发现没有抛异常,我们打个端点去调试一下,
发现赋值后,ap1为空,ap2的地址为ap1原来的地址。
我们模拟一下auto_ptr的功能,
//头文件:SmartPtr.h
namespace yls
{template<class T>class auto_ptr{public:auto_ptr(T* ptr):_ptr(ptr){}~auto_ptr(){cout << "~auto_ptr()->" << _ptr << endl;delete _ptr;}auto_ptr(auto_ptr<T>& ap):_ptr(ap._ptr){ap._ptr = nullptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:T* _ptr;};
}
再次执行主函数,
void test_auto_ptr1()
{yls::auto_ptr<int> ap1(new int);yls::auto_ptr<int> ap2 = ap1;}int main()
{test_auto_ptr1();return 0;
}
输出:
这里我们对空指针调用了析构函数,没有报错,因为delete遇到空指针,编译器不会调用析构函数,当然,我们也可以修改一下析构函数,只对非空指针析构,
~auto_ptr(){if (_ptr){cout << "~auto_ptr()->" << _ptr << endl;delete _ptr;//delete对空指针有特殊处理,当然也可以不delete空指针_ptr = nullptr;}}
我们再次运行,就不会打印空指针了,
auto_ptr使用“管理权转移”解决“对一块空间析构两次”问题,直接将空间交给拷贝对象,被拷贝对象置空,这样会造成什么问题呢?我们知道普通的指针赋值,是两个指针指向同一块空间,都有管理权限,而不是复制后只有一个指针有管理权限,所以auto_ptr的赋值不能够完全模仿真正的指针赋值。对于一些不了解auto_ptr的同学,可能会犯以下错误,
因为管理权限的转移,导致ap1悬空,对ap1解引用,从而引发异常。
所以很多公司都明确规定了,不要用auto_ptr,因为用管理权转移进行赋值,会造成所有的拷贝对象和被拷贝对象,只有最后一个拷贝对象有权限管理资源,其余全部置空。
4.std::unique_ptr
cplusplus.com/reference/memory/unique_ptr/
为了解决赋值后指针悬空的问题,C++11推出了unique_ptr,它的实现原理就是禁止拷贝,不允许拷贝,就不会发生赋值后指针悬空的问题。
unique_ptr有两种实现方案,一种是针对只支持C++98的编译器,将unique_ptr的拷贝构造、赋值重载函数只声明不定义,且设置为私有防止外部访问甚至类外定义,具体模拟代码如下:
namespace yls
{// 按C++98语法实现unique_ptrtemplate<class T>class unique_ptr{public:unique_ptr(T* ptr):_ptr(ptr){}~unique_ptr(){if (_ptr){cout << "~unique_ptr()->" << _ptr << endl;delete _ptr;_ptr = nullptr;}}unique_ptr(unique_ptr<T>& up):_ptr(up._ptr){up._ptr = nullptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:/* 1、只声明不实现2、限定为私有*/unique_ptr(const unique_ptr<T>& up);unique_ptr<T>& operator=(const unique_ptr<T>& up);T* _ptr;};
}
C++11语法种,可以使用关键字delete禁止生成不想生成的默认构造函数,代码如下:
namespace yls
{// 利用delete实现unique_ptrtemplate<class T>class unique_ptr{public:unique_ptr(T* ptr):_ptr(ptr){}~unique_ptr(){if (_ptr){cout << "~unique_ptr()->" << _ptr << endl;delete _ptr;_ptr = nullptr;}}unique_ptr(unique_ptr<T>& up):_ptr(up._ptr){up._ptr = nullptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}unique_ptr(const unique_ptr<T>& up) = delete;unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;private:T* _ptr;};
}
但有些场景需要指针拷贝和赋值怎么办?那么就要看shared_ptr。
5.std::shared_ptr
cplusplus.com/reference/memory/shared_ptr/
C++11提供更靠谱的并且支持拷贝的shared_ptr 。
shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。
1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
2. 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
3. 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
4. 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
那我们该怎么实现shared_ptr呢?我们用什么来记录被拷贝的次数?如何记录?
我们再增加一个私有成员_count记录被拷贝的次数可行吗?
答案是效率太低了,假设我们这里有四个unique_ptr的对象指向同一块空间,其中一个对象释放空间,其余三个对象中的_count都要减1,这样设计会很容易造成“指针”拷贝和释放中计数出错,相当于计数和“指针操作”不同步。
有人提议用static修饰_count,将_count设置成静态变量,这样可行吗?
这样也不可行。因为静态变量是所有对象共有的,指向不同空间的对象也共享同一个静态变量,比如对象sp1指向一块空间,对象sp2指向一个不同的空间,如果sp1释放了空间,_count减1,sp2中的_count也会减1,造成计数混乱。
注意:这里的计数,是跟指向同一块空间的对象个数绑定的,指向同一块空间的对象增1,该空间对应的计数就加1,指向同一块空间的对象减1,该计数就减1,直到减为0,也就没有对象指向该空间,此时才会调用析构函数,真正释放空间。
那么什么可以跟同一块空间绑定的且可以增减变换呢?指针是不是可以,指针就是存储地址空间的变量,可以变化,一个地址可以有多个指针,多个指针可以指向同一块地址,但是怎么存储指向同一块空间的指针数呢?我们可以用一个整形指针指向的空间存储计数(整形指针解引用后的整数表示计数)。
按照以上思想,我们实现以下模拟:
//shared_ptr
namespace yls
{template<class T>class shared_ptr{public:shared_ptr(T* ptr):_ptr(ptr),_pcount(new int(1)){}~shared_ptr(){if (--(*_pcount) == 0){cout << "~shared_ptr()->" << _ptr << endl;delete _ptr;delete _pcount;}}shared_ptr(shared_ptr<T>& sp):_ptr(sp._ptr),_pcount(sp._pcount){++(*_pcount);}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:T* _ptr;int* _pcount;};
}
我们执行下面程序,发现只析构一次,解决了不能拷贝和重复析构的问题。
我们来写一下赋值重载,
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{//不指向同一块空间if (_ptr != sp._ptr){if (--(*_pcount) == 0){cout << "~shared_ptr()->" << _ptr << endl;delete _ptr;delete _pcount;}++(*sp._pcount);_ptr = sp._ptr;_pcount = sp._pcount;}return *this;
}
发现析构函数和赋值重载中有共同的语句块,我们可以重写一个函数release,替代它们,如下:
void release()
{if (--(*_pcount) == 0){cout << "~shared_ptr()->" << _ptr << endl;delete _ptr;delete _pcount;}
}
~shared_ptr()
{release();
}shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{//不指向同一块空间if (_ptr != sp._ptr){release();++(*sp._pcount);_ptr = sp._ptr;_pcount = sp._pcount;}return *this;
}
我们执行下面的主函数,
void test_shared_ptr1()
{yls::shared_ptr<string> sp1(new string("xxxxxxxxxxxxxxxxxx"));yls::shared_ptr<string> sp2(sp1);yls::shared_ptr<string> sp3(new string("yyyyyyyyy"));sp1 = sp3;
}
int main()
{test_shared_ptr1();return 0;
}
打个断点调试,
将sp1 = sp3左右调换,
重新调试一下,
我们再调试一下自己跟自己赋值,
没有问题。
shared_ptr很完美了,但还有一种场景,shared_ptr不适用,如下:
struct ListNode
{int val;yls::shared_ptr<ListNode> next;yls::shared_ptr<ListNode> prev;~ListNode(){cout << "~ListNode()" << endl;}
};void test_shared_ptr2()
{yls::shared_ptr<ListNode> n1(new ListNode);yls::shared_ptr<ListNode> n2(new ListNode);// 循环引用n1->next = n2;n2->prev = n1;
}int main()
{test_shared_ptr2();return 0;
}
这段代码中,new ListNode会报错,因为没有无参的构造函数,所以我们在shared_ptr的构造函数中价格默认值(缺省值),如下,
shared_ptr(T* ptr = nullptr):_ptr(ptr),_pcount(new int(1)){}
这样有了合适的默认构造函数就不会报错。
我们对shared_ptr中的release函数打印的一行注释,如下:
void release()
{if (--(*_pcount) == 0){//cout << "~shared_ptr()->" << _ptr << endl;delete _ptr;delete _pcount;}
}
只观察ListNode的析构函数,运行修改后的程序,
发现打印为空,不会调用析构函数。
我们注释掉 “n2->prev = n1;”
n1->next = n2;
//n2->prev = n1;
运行程序,
发现又调用了两次析构。 这是为什么呢?
我们打个断点调试一下吧,
在赋值前,n1和n2的next个prev都为空:
在赋值后,
n1的next指向了n2,下面我们看看析构了几次。
按照先构造后析构的原则,应该n2先析构,n1后析构,我们看看,
第一次先析构n2没问题,
析构后,n2的计数为1,这个是因为n1中的next还指向这块空间。
第二次析构n1,因为n1的计数为1,减减后为0,编译器会真的释放n1的空间,同时释放内部成员申请的空间。
第三次析构,对成员变量prev
第四次析构,对成员变量next
由于计数为1,减减为0,会真的delete(释放)掉next指向的空间,也就是n2所指向的空间。
那么为什么“n1->next = n2;n2->prev = n1;"会造成不析构呢?
在调用析构函数前,我们可以看到,n1和n2的_pcount都为2,我们开始析构,
同样,编译器先对n2析构,
析构后n2的_pcount变为1,
接着对n1析构,
同样的,n1的_pcount也变为1,
哦吼,到这里编译器将不会再调用析构函数,直接返回上一栈帧,再返回到主函数。
原来如此。不调用析构函数的原因是n1和n2的_pcount都不为0。
这该怎么办呢?
6.std::weak_ptr
标准库里用weak_ptr解决这个问题,相当于weak_ptr是对shared_ptr的补充,专门处理循环引用的问题。
struct ListNode
{int val;std::weak_ptr<ListNode> next;std::weak_ptr<ListNode> prev;~ListNode(){cout << "~ListNode()" << endl;}
};void test_shared_ptr2()
{std::shared_ptr<ListNode> n1(new ListNode);std::shared_ptr<ListNode> n2(new ListNode);//我们可以用use_count()打印计数cout << n1.use_count() << endl;cout << n2.use_count() << endl;// 循环引用n1->next = n2;n2->prev = n1;cout << n1.use_count() << endl;cout << n2.use_count() << endl;
}int main()
{test_shared_ptr2();return 0;
}
运行,
发现weak_ptr类型的智能指针循环引用不改变计数。我们模拟实现一下,
//weak_ptr
namespace yls
{template<class T>class weak_ptr{public:weak_ptr(T* ptr = nullptr):_ptr(ptr){}weak_ptr(const shared_ptr<T>& sp):_ptr(sp.get()){}weak_ptr<T>& operator=(const shared_ptr<T>& sp){_ptr = sp.get();return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:T* _ptr;};
}
执行下面主函数,
7.shared_ptr析构数组(删除器)
我们上面都是new单个对象,如果是new一个数组呢?
void test_shared_ptr3()
{yls::shared_ptr<string> sp1(new string[10]);
}int main()
{test_shared_ptr3();return 0;
}
会引发异常,那我们怎么实现shared_ptr中对数组的delete呢?
我们先看看标准库里是怎么做的,
标准库里是用仿函数 ,我们来练习一下,
template<class T>
struct DelArray
{void operator()(T* ptr){delete[] ptr;}
};
void test_shared_ptr3()
{std::shared_ptr<ListNode> sp1(new ListNode[10],DelArray<ListNode>());
}int main()
{test_shared_ptr3();return 0;
}
运行后控制台输出如下:
除了用仿函数,我们还可以用lambda表达式,
template<class T>
struct DelArray
{void operator()(T* ptr){delete[] ptr;}
};
void test_shared_ptr3()
{//定制删除器std::shared_ptr<ListNode> sp1(new ListNode[10],DelArray<ListNode>());std::shared_ptr<ListNode> sp2(new ListNode[10], [](ListNode* ptr) { delete[] ptr; });//文件智能指针std::shared_ptr<FILE> sp3(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
}int main()
{test_shared_ptr3();return 0;
}
下面我们就来模拟一下库里的这个功能。
我们知道库里的shared_ptr只有一个模板参数,class D是构造函数的第二个模板参数,我们重载一个构造函数,如下:
template<class D>
shared_ptr(T* ptr, D del):_ptr(ptr), _pcount(new int(1)),_del(del){}
我们必须要一个成员变量来接受D类型的参数,这该怎么办呢?构造函数的第二个参数,也就是可调用对象(仿函数和lambda)的参数都是T*,返回值都是void,那我们可不可以用一个包装器来包装可调用对象呢,将该对象设置成私有成员呢,可以的,如下所示:
private:T* _ptr;int* _pcount;function<void(T*)> _del;
同时,我们把release修改一下,
void release()
{if (--(*_pcount) == 0){//cout << "~shared_ptr()->" << _ptr << endl;/*delete _ptr;delete _pcount;*/_del(_ptr);}
}
我们运行自己模拟的shared_ptr,如下:
看上去没有问题, 但是当我们初始化sp4时,会报错
这是因为sp4没有可调用对象去初始化_del,所以我们要在_del声明时给他个缺省值,如下,
private:T* _ptr;int* _pcount;function<void(T*)> _del = [](T* ptr) { delete ptr; };
这样就不会报错了。
shared_ptr完整模拟代码:
//shared_ptr
namespace yls
{template<class T>class shared_ptr{public:shared_ptr(T* ptr = nullptr):_ptr(ptr),_pcount(new int(1)){}template<class D>shared_ptr(T* ptr, D del):_ptr(ptr), _pcount(new int(1)),_del(del){}void release(){if (--(*_pcount) == 0){//cout << "~shared_ptr()->" << _ptr << endl;/*delete _ptr;delete _pcount;*/_del(_ptr);}}~shared_ptr(){release();}shared_ptr(shared_ptr<T>& sp):_ptr(sp._ptr),_pcount(sp._pcount){++(*_pcount);}shared_ptr<T>& operator=(const shared_ptr<T>& sp){//不指向同一块空间if (_ptr != sp._ptr){release();++(*sp._pcount);_ptr = sp._ptr;_pcount = sp._pcount;}return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T* get()const{return _ptr;}int use_count()const{return *_pcount;}private:T* _ptr;int* _pcount;function<void(T*)> _del = [](T* ptr) { delete ptr; };};
}
简单总结: