目录
引言:对于程序员来说内存泄露是一个非常头痛的问题,一旦代码量大了之后内存泄漏是很难被检测出来的,还有许多十分隐蔽的内存泄漏是我们根本无法考虑到的。例如在释放内存之前的程序就崩溃,像这种隐蔽的错误我们一般根本是发现不了的,这一点我们的STL库怎么会想不到呢?所以STL库给我们提供了多种自动释放资源的智能指针,但是这其中也有许多的坑,本片文章将带你搞定智能指针以及其中的众多坑!!!
1.STL库中提供的智能指针
我们可以看到STL库中给的是一个范型的类。对于这个给定的类我们可以当作正常的指针来使用,比如解引用,用它来指向一片空间等等,都是可以的,而且当当前指针的作用域结束之他会帮我们自动释放申请的资源。
自动释放资源:可以看到我们在申请资源之后并没有进行手动释放但是我们调用检测内存泄漏的函数,然后在程序运行结束之后我们可以看到并没有内存泄漏
但是我们在这里要注意一点C++98中的智能指针的实现的在拷贝构造或者赋值之后会将原有的指针指向空就例如下面的例子:
这是因为什么呢?这是因为智能指针要自动释放空间,那么如果在赋值之后两个指针指向同一个地址,那么在指针作用域结束之后,两个指针都要自动释放所指向的空间,那么一个空间就会被释放两次,那么从而就一定会导致程序崩溃。
2.智能指针的模拟实现
2.1C++98中指针指针
上面我们了解到了C++98中智能指针的特点和特性那么我们来模拟实现一下:
2.2.C++98到C++11的过度
其实对于直接将被拷贝的对象的指针置为nullptr这种方法是不符合我们对于指针的认知的,因为我们普通的指针变量对于在给另外一个变量赋值之后还是可以对原来所指向的资源进行操作访问的,那么我们为了更加贴近常规的指针变量,我们只需要解决资源多次释放的问题就可以了,我们可以在智能指针类中添加一个成员变量,用这个成员变量来表示资源的释放权限。(这个版本的智能指针其实是在C++98到)
我们来测试代码:
添加了释放归属q成员变量的优缺点:
造成野指针问题的具体例子:
上面增加一个释放归属权的成员变量是C++98到C++11中过度的阶段的产物,造成这一系列问题的根本原因都是因为指针拷贝赋值惹的祸,而在C++11当中为了避免这些情况的发生直接采用了简单粗暴的方式来解决问题,就是直接不允许智能指针拷贝和赋值。
2.3.C++11中的unique_ptr
我们来测试一下:可以看到拷贝构造和赋值直接报错
接下来我们来模拟实现一下:
这里在模拟实现的时候不让指针拷贝构造那么我们我们首先想到的是不要写构造函数和赋值运算符重载不就可以了,但是别忘了,我们不写,编译器会默认帮我们生成一份。所以这里C++11这里在默认成员函数后面跟上=delete就表明告诉编译器不用生成了。
注意:这里的delete也可以跟在普通函数后面。
2.4.C++98中unique_ptr的实现方式
在C++98当中我们实现unique_ptr是无法依靠delete关键字的,那么我们只能依靠C++的语法特性来实现让智能指针无法实现拷贝构造和赋值,就是只在类中声明拷贝构造和赋值运算符重载,不对其进行实现,并将其声明为私有成员变量。
问题:为什么要声明为私有的成员函数,我们只声明不实现不是也可以实现无法在类外访问的要求吗?
这是因为如果设置为共有的,成员函数可以调用,但是在链接阶段就会找不到函数入口地址而报错。但是我们却可以在类外进行实现,这样就又可以使用了。
问题:我们将成员函数已经声明为私有的那么就不可以在类外访问了,那么我们可不可以实现定义成员函数呢?
答案是不可以的,因为声明为私有的成员函数,虽然不可以在类外访问了,但是还可以在类内访问这样就又会造成一大堆错误,而且我们不用,为什么要实现,这不是给自己找麻烦多次一举。
这里其实还有一个坑,那就是我们在实现智能指针的时候,释放资源用的是delete来释放资源,虽然对于C++来说大部分申请的空间都是用new来申请的,用delete来释放即可,但是也不妨有一些特殊情况,智能指针管理的是用malloc来申请的资源或者其他的资源。那么我们就需要对不同类型的资源采取不同的释放方式。(这里我们虽然可以用仿函数释放new[]但是我们一般不用智能指针管理连续空间,因为我们在STL中提供了vector可以有效的管理连续的空间)
2.5 std::shared_ptr
上面的几种方式的智能指针都是存在缺陷的(一般都不建议使用上面的智能指针),那么作为C++标准库牛逼且强大那么是一定不允许这样的情况出现的,所以C++11又推出了shared_ptr共享智能指针,就是可以支持多个智能指针共享同一份资源但是又不会造成野指针的问题。那么他是怎么解决的呢?
引用计数:
1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
2. 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
3. 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
4. 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
我们了解到了原理之后来简单模拟实现一下:
#pragma once
#include<mutex>
#include<iostream>
#include<thread>
namespace wbx
{
//释放用new申请的资源
template <class T>
struct DFdelete
{
void operator()(T* ptr)
{
if (ptr)
{
delete ptr;
ptr = nullptr;
}
}
};
//释放用malloc申请的资源
template<class T>
struct DFfree
{
void operator()(T* ptr)
{
if (ptr)
{
free(ptr);
ptr = nullptr;
}
}
};
//释放文件句柄
struct DFclose
{
void operator()(FILE* fp)
{
if (fp)
{
fclose(fp);
fp = nullptr;
}
}
};
//释放用new[]申请的空间
template<class T>
struct DFarr
{
void operator()(T *ptr)
{
if (ptr)
{
delete[] ptr;
ptr = nullptr;
}
}
};
template<class T, class DF=DFdelete<T>>
class shared_ptr
{
public:
shared_ptr(T* p=nullptr)
:ptr(p)
,pcount(nullptr)
,pmutex(nullptr)
{
if (ptr)
{
pcount = new size_t(0);
pmutex = new std::mutex;
add_mutex();
}
}
T& operator* ()
{
return *ptr;
}
T* operator->()
{
return ptr;
}
~shared_ptr()
{
if (ptr)
{
relese();
}
}
//拷贝构造:每次赋值或拷贝构造都给资源++
shared_ptr(const shared_ptr<T,DF>& sp)
:ptr(sp.ptr),
pcount(sp.pcount)
,pmutex(sp.pmutex)
{
add_mutex();
}
shared_ptr<T, DF>& operator=(const shared_ptr<T,DF>& sp)
{
if (ptr != sp.ptr)
{//防止用相同的资源进行赋值
if (ptr)
{
relese();
}
ptr = sp.ptr;
pcount = sp.pcount;
pmutex = sp.pmutex;
add_mutex();
}
return *this;
}
private:
//释放内存给资源计数器--
void relese()
{
pmutex->lock();
if (pcount)
{
--(*pcount);
}
pmutex->unlock();
if (ptr&&(*pcount) == 0)
{//如果资源存在而且计数为0那么释放资源
DF()(ptr);
ptr = nullptr;
delete pcount;
delete pmutex;
pcount = nullptr;
pmutex = nullptr;
}
}
void add_mutex()
{
if (ptr&&pcount)
{
pmutex->lock();
++(*pcount);
pmutex->unlock();
}
}
size_t* pcount ;
T* ptr;
std::mutex *pmutex;
};
};
可以看到我们在模拟实现的时候可以看到我们在智能指针内部加了一把锁,这是因为在多线程操作的时候对同一个指针进行操作,可能会造成计数器的二义性,所以我们在智能指针实现的时候给计数器加一把锁。就可以防止多线程对于计数器修改时候产生二义性。
我们接下来可以测试一下多线程情况下面我们智能指针的稳定性:
运行结果可以看到并不是我们所期望的30000因为这里是多线程所以运行结果是正确的,而我们也没有造成任何内存泄漏
shared_ptr指针循环引用造成内存泄露问题!
这里我们用一个双向列表的例子来举例说明什么是循环引用
我们运行代码发现他没有给我们调用listnode的析构函数,那么也就是说listnode没有释放掉。
代码模型:
我们看上面的代码,在函数调用结束后释放n1和n2智能指针所指向的资源的时候会给两个资源的计数器都减减,但是也只是减为2,这里还不可以销毁,而链表节点结构体内的两个智能指针是没有办法调用析构函数的,所以不会对资源计数器进行减减那么到最后就会造成资源的泄漏。
循环引用的解决方法
造成循环引用的最根本的原因就是因为引用计数在释放资源的时候没有减为0,所以就造成资源无法释放的问题。那么我们一般针对解决循环引用的方式是给shared_ptr指向的资源内部的指针设置为weak_ptr类型。
weak_ptr的特点:
1.weak_ptr类型的智能指针是不允许直接管理资源的,也就是不允许直接构造的时候就指向一块资源,但是可以给weak_ptr赋值使它指向一块资源。
2.但是他却可以指向shared_ptr指向的资源。(但是这里注意shared_ptr指针是不可以指向weak_ptr的资源的也就是不可以写成p2=p3)
我转到定义可以看到它重载了接收weak_ptr的operator=函数还重载了接收shared_ptr的重载函数,所以它可以指向shared_ptr管理的资源。
了解了一些关于weak_ptr基本的特点我们先来看它解决问题的具体实现方式,
我们运行查看:可以发现两份资源都被释放了
这是为什么呢?
其实shared_ptr和weak_ptr都继承自一个基类ptr_base。
最开始的模型:
这里具体的调用析构的过程是这样的,首先当我们调用的函数的作用域结束之后就要销毁临时变量,那么就要销毁shared_ptr n1和n2那么我们就要调用它的析构函数在调用shared_ptr n1的析构函数的时候,先对引用计数uses-1,那么现在因为uses是0,那么就可以销毁n1对象,因为资源n1是new出来的那么我们就要用delete来释放空间,那么就需要调用list_node的析构函数,那么调用list_node的析构函数就要调用weak_ptr的析构函数因为list_node中两个指针都是weak_ptr类型,那么调用weak_ptr的析构函数之后对于node_list n2所指向的资源的weaks就减为1,此时n2资源的引用计数还不可以释放,那么在调用完list_node的析构函数之后,然后用free将申请的资源释放掉,然后再对n1资源的引用计数中的weaks-1,那么我们看直观的看一下当前的模型:
那么此时就要销毁n2指针对象过程也是一样的先对象n2对象的uses-1那么此时uses为0那么就可以释放资源,此时用delete释放资源,那么调用list_node的析构函数,然后再析构list_node中的prev和next指针对n1中的weaks减为0,那么此时释放n1的引用计数空间,然后释放完list_node空间之后就对当前n2的引用计数weak-1那么当前的n2的引用计数空间也被销毁。
总结一下:
这里uses计数的功能就是计数当前的资源是否可以被释放,而weak的作用就是计数当前资源的计数空间是否可以释放。
3.RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
1.不需要显式地释放资源。
2.采用这种方式,对象所需的资源在其生命期内始终保持有效。
上面的智能指针设计就是利用RAII的思想利用智能指针的生命周期来帮我们自动释放资源。那么我们同样也可以用这个巧妙的方式来预防死锁,我们都知道造成死锁的其中一个可能的原因就是在结束的时候没有将锁解开,那么锁就会一直处于锁的状态。那么我们可以构造一个对象用对象的构造函数和析构函数分别来进行上锁和开锁.
运行结果:
那么我们如何完成一个计时器呢?
可以在构造的时候获取一下时间,同时在析构的时候获取时间,然后两个时间互相减一下就可以得到程序运行的时间了。