【C++】C++的内存处理 --- 智能指针

2024-08-13 11:04:06 浏览数 (2)

1 前言

我们来回顾一下在学习异常机制中遇到的一种问题:在try catch语句中,如果我们开辟了一段空间,但是发生了异常,会直接终止掉函数栈桢,导致内存泄漏问题。所以此时就要在catch语句中进行一个特殊处理。如果我们开辟了多段空间,那么这个操作就会变得更加复杂:假如new失败了,就会直接返回到上层的catch语句,也导致了内存泄漏问题!使用传统是异常机制来解决问题会产生大量冗余的语句 — 大量的try catch嵌套!

为了解决这个问题,可以使用智能指针!可以简单的来进行解决!

2 智能指针

2.1 什么是智能指针

智能指针类似lock_guard,是对指针的封装,可以实现在超出生命周期之后自动销毁的功能!

代码语言:javascript复制
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类来帮助我们解决!

代码语言:javascript复制
class SmartPtr
{
public:
	SmartPtr(int* ptr)
		:_ptr(ptr)
	{}
	~SmartPtr()
	{
		delete[] _ptr;
		cout << "delete!" << _ptr << endl;
	}

private:
	int* _ptr;
};

这样一个类包装了一个指针,我们不需要在显式delete了,只要生命周期结束,就会自动释放空间! 这样在开辟空间时,就直接进行构造不就好了!这样就直接避免了复杂嵌套的try catch语句!

代码语言:javascript复制
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 资源请求立即初始化)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

  1. 不需要显式地释放资源。
  2. 采用这种方式,对象所需的资源在其生命期内始终保持有效

上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指空间中的内容,因此AutoPtr模板类中还得需要将* ->重载下,才可让其像指针一样去使用!还需要进行一个拷贝构造的特殊处理,否则就会出现对同一片地址析构两次的场景

2.2 C 库中的智能指针

在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 ptrshared_ptr。来看一下他们支持什么操作:

  1. unique_ptr构造支持无参构造,移动构造… , 就是不支持拷贝构造!因为拷贝有问题所以就不让拷贝!直接避免了问题出现!
  2. get:获取到智能指针内部的指针!
  3. release:显式释放空间!
  4. -> *:支持的指针操作!
  1. shared_ptr支持所有的构造,包括拷贝构造!其引入了引用计数的概念(Linux中很常见)!
  2. get:获取到智能指针内部的指针!
  3. release:显式释放空间!
  4. -> *:支持的指针操作!
  5. make_shared:类似make_pair,可以进行创建shared_ptr

2.3 循环指向问题与weak_ptr

我们一般推荐使用shared_ptr,其独有的引用计数机制,极大程度复原了指针的实际用法,并且能做到RAII技术! 但是,shared_ptr存在一个问题:循环指向问题!这种问题主要出现在循环链表中,每个节点有两个指针,分别指向前一个节点和后一个节点。当我们有两个节点时,我们都使用shared_ptr进行包装管理:

代码语言:javascript复制
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来进行托管就可以了:

代码语言:javascript复制
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本质赋值或拷贝时,只指向资源,不会增加引用计数!所以就不会造成先有鸡先有蛋的问题!

2.4 自定义删除器

智能指针内部还支持自定义删除器,因为在构造时并不能保证默认析构可以释放掉我们开辟的空间,比如

  1. 在进行malloc的时候,默认的delete是不能满足条件的
  2. 在管理文件指针的时候,需要使用fclose来释放空间,而不是默认的delete
  3. 开辟一个数组空间时 , 需要使用delete[]来进行释放空间

所以为了更是适配内存管理的多样性,智能指针支持自定义删除器,即支持用户显式传递删除方法!

这里可以使用仿函数来进行传递,但是在C 11之后使用lambda表达式更加简约直观!

代码语言:javascript复制
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;
}

这样就解决了不同类型指针释放方式不一致的问题!

3 手搓shared_ptr

我们来实践一下shared_ptr,其在面试中常常会考到,这智能指针的主要思想是RAII,将指针封装起来,保证其在生命周期内存在,离开生命周期就自动释放掉!

3.1 框架搭建

首先智能指针内部需要一个指针变量来储存数据。重要的是如何将引用计数加入其中,如果直接使用一个int count肯定是不行的,这样每个对象都有自己的count,无法做到引用计数的功能。如果使用静态变量,那么所有的类对象只有一个计数,这样肯定也是不可以的!那么要如何解决这个问题呢?为引用计数单独开辟一块空间,进行拷贝的时候就将这个空间进行传值,这样所有进行拷贝的对象都可以读取到同一个引用计数的数据!

构造函数可以直接写出来,析构就在引用计数为0的时候进行释放空间!

代码语言:javascript复制
	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;

	};

3.2 拷贝构造和赋值重载

这里最为重要的就是这个拷贝构造和赋值重载如何进行书写!我们需要模拟到和原生指针一样,可以让不同的指针对象指向同一块空间 ,并且不能发生重复析构的问题:

  1. 拷贝构造直接将对象的_ptr _pcount 进行拷贝就可以,不要忘记进行引用计数的
  2. 赋值重载就要考虑的多一点:
    • 首先需要对原本指向的空间进行一次析构,保证原本的空间引用计数 - -
    • 然后进行拷贝,引用计数
    • 注意:避免自己给自己赋值的情况需要进行一次判断!不然在引用计数只有1的时候会出现野指针问题
代码语言:javascript复制
//拷贝构造

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;
}

3.3 自定义删除器

首先为了适配自定义删除器,我们需要多加一个成员变量_del,使用function包装器进行包装,可以省去很多不必要的操作! 成员变量添加:

代码语言:javascript复制
std::function<void(T*)> _del = [](T* p){ delete p; } ;

这个包装器就是用来包装删除器的,加入了删除器,我们就要再写一个单独的构造函数来满足:

代码语言:javascript复制
		template<class D>
		shared_ptr(T* ptr = nullptr , D del = [](T* p) { delete p; })
			: _ptr(ptr),
			_pcount(new std::atomic<int>(1)),
			_del(del)
		{

		}

这样在显式调用构造的时候就可以进行自定义删除器的添加了:

代码语言:javascript复制
	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; });

3.4 功能函数

为了让shared_ptr可以有原生指针的使用方法,我们需要对* ->进行一个重载!这我们已经很熟悉不过了! 然后就是设计一个get()use_count()函数!都很简单!

代码语言:javascript复制
T* get()
{
	return _ptr;
}

T& operator*()
{
	return *_ptr;
}
T* operator->()
{
	return _ptr;
}

int use_count()
{
	return *(_pcount);
}

3.4 多线程下的特殊处理

上面已经实现了正常情况下的智能指针的使用,我们来看多线程情况下会不会出现问题。 在下面的程序中,我们设置了三个线程同时对sp1内部的链表进行尾插,尾插是临界的,我们用锁进行保护。

我们用锁进行了保护,可是还是出现了错误!

为什么会出现这样的问题?首先我们分析一下临界区,在share_ptr中引用计数是临界的!为什么呢?因为引用计数的操作 -- 是非原子的!多个线程中我们不断进行copy拷贝,会对引用计数不断进行 --,导致了问题!为了从根本上解决这个问题,我们就要保证操作是原子的!我们可以在类中加入一个锁来保证 --中进行保护。但是最直接的就是将引用计数变成原子的就可以了!

代码语言:javascript复制
		//引用计数
		std::atomic<int>* _pcount;

这样就可以保证拷贝和析构的时候就是原子的了就不会出现问题了!!!就可以保证线程安全了! 注意我将shared_ptr完善之后:

  1. 智能指针对象本身拷贝是线程安全的
  2. 底层引用计数加减是线程安全的
  3. 指向的资源访问不是线程安全的,该加锁还是要加锁!

4 内存泄漏

最后我们来回顾一下内存泄漏问题:

  • 什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费
  • 内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现 内存泄漏会导致响应越来越慢,最终卡死。

对于C 来说,内存泄漏是很严重的问题!C 没有和JAVA的垃圾回收机制。 在正常的一个程序中,内存泄漏其实影响并不大,我们开辟一段空间,如果没有释放,在进程结束的时候也会被释放掉,因为我们开辟的空间都是虚拟内存,进程结束之后会把虚拟地址一并收拾带走。就怕进程异常结束,变成僵尸进程挂起,此时虚拟地址和物理内存依然存在映射,此时就完蛋了。再加上如果是长期运行的代码,内存泄漏的不断积累会导致内存空间越来越小!

C/C 程序中一般我们关心两种方面的内存泄漏:

  1. 堆内存泄漏(Heap leak): 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
  2. 系统资源泄漏: 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

内存泄漏可以通过第三方库来进行检测,当时这些并不是很好用,并且在实际工作中,编译运行一次程序可能需要很长时间,那么通过第三方库来检测是很费事的!所以尽量在使用中就要避免内存泄漏的问题:

  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  2. 采用RAII思想或者智能指针来管理资源。只要正常使用智能指针一般不会出现内存泄漏!
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

总而言之: 内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。

0 人点赞