前言
内存泄漏
RAII
智能指针原理
智能指针分类
auto_ptr
unique_ptr
两个问题
线程安全
循环引用
后记
对于智能指针,听起来很高大上,其实本质上就是一个类。为什么叫指针呢?因为可以像指针一样管理一块资源;为什么又加上智能两个字呢?因为比普通指针更特殊一点,特殊在它是一个类。比如说,当我们使用一个指针去new一块空间时,最后要delete去释放资源,在不注意时就会忘记导致内存泄漏,或者因为像上一章节中throw这样会跳跃的语句,当跳过delete又该怎么办呢?这就需要使用到RAII的思想,在下面将会介绍到,在此之前我们先介绍一下内存泄漏的危害,以此来强调一下智能指针的重要性。
内存泄漏通常发生在程序中有未释放的内存空间,导致程序的内存占用不断增加。这可能是由于以下原因之一导致的:
程序中使用了动态内存分配函数(如malloc、calloc等)分配的内存,但在使用完后未使用free等函数释放内存;
程序中存在指针错误,导致内存无法访问或释放;
程序中存在死循环或递归,导致一些内存空间无法被释放;
程序中存在逻辑错误,导致内存未能被正确地分配或释放,
内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费,导致响应越来越慢,最终卡死。
eg:
void MemoryLeak()
{
//内存申请了最后忘记释放
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int;
//异常安全问题
int* p3 = new int[10];
Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
delete[] p3;
}
如何避免内存泄漏:
①养成好的编程习惯,对容易造成内存泄漏的语句产生敏感,比如new出来的空间紧接着释放,然后在在中间写使用此空间的逻辑,若涉及异常安全问题,应注意在new的空间所在的栈帧捕获拦截一下,将相应资源释放再处理异常;
②使用提前实现的私有内存管理库,自带内存泄漏检测的功能,比如内存池;
③使用内存泄漏检测工具,但这类工具不靠谱且收费;
④采用智能指针或其思想——RAII来管理资源,下面介绍。
RAII 是 Resource Acquisition Is Initialization 的缩写,即资源获取即初始化。用于管理资源(如内存、文件句柄、网络连接等)的生命周期。RAII 基本思想是将资源的创建和释放绑定在一个对象的构造和析构过程中,通过对象的生命周期来管理资源。
在使用 RAII 时,需要创建一个对象来管理资源,这个对象在构造函数中获取资源,然后在析构函数中释放资源。这样一来,无论是因为异常还是正常的程序流程终止,都可以确保资源得以释放,不会导致内存泄漏或资源占用问题。
优点:
- 保证资源分配和释放的可靠性,减少了程序错误的风险;
- 使代码更加简洁、易于维护和阅读;
- 避免了程序员必须手动跟踪资源分配和释放,从而减少了程序员的工作量,
RAII 是面向对象设计和编程的一种实践,在 C++ 中得到了广泛的应用。例如,在 C++ 标准库中就使用了 RAII 的技术实现了自动内存管理、文件操作和线程同步等功能。
eg:
template
class PTR
{
public:
PTR(T* ptr = nullptr)
:_ptr(ptr)
{}
~PTR()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr = nullptr;
};
int main()
{
PTR p1(new int);
PTR p2(new int[10]);
return 0;
}
前面说过,智能指针本质就是一个类,虽应用了RAII思想还不够,还需要具备普通指针的功能,也就是解引用和->访问空间中的内容,重载操作符即可。
eg:
template
class Smartptr
{
public:
Smartptr(T* ptr = nullptr)
:_ptr(ptr)
{
}
~Smartptr()
{
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
在c++98,stl就提供了一版智能指针——auto_ptr,下面是一份模拟实现,框架与上面的Smartptr一样,在此基础上实现了拷贝构造函数和赋值运算符重载,可以看到,auto_ptr的实现原理在于资源管理权的转移。
对于拷贝构造函数,将被拷贝对象内部指针指向的资源赋给当前对象的内部指针,且将被拷贝对象内部指针指向空;对于赋值运算符重载,若当前对象内部指针有指向资源,那么直接将此资源释放,然后将被拷贝对象内部指针指向的资源赋给当前对象的内部指针,再将被拷贝对象内部指针指向空。
可以从模拟实现看出,auto_ptr是一个较为失败的提出,有很多公司明确指出不能使用此智能指针,比如说,当一个智能指针对象赋值给另一个对象时,那么当前智能指针对象在后续就不能用了,因为已经不再管理那一块资源了。可想而知这不是我们想要的功能,我们想要的是在赋值给其他对象之后,自己依旧可以继续管理那块资源,与其他对象一块管理。
模拟实现:
template
class Auto_ptr
{
public:
Auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{
}
~Auto_ptr()
{
if (_ptr)
delete _ptr;
}
Auto_ptr(Auto_ptr& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
Auto_ptr& operator=(Auto_ptr& ap)
{
if (this != &ap)
{
if (_ptr)
{
delete _ptr;
}
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
test:
c++11中,stl又提出另一款智能指针——unique_ptr,实现原理也是简单粗暴,因为上面的auto_ptr在赋值以后被赋值指针在后续就不能继续管理那块资源了,所以unique_ptr直接禁止拷贝和赋值,也就是一个智能指针对象在构造时就决定了管理哪块资源,后续就不能变了,有新的资源需要被管理,只能重新构造一个对象,下面是unique_ptr的模拟实现。
模拟实现:
template
class Unique_ptr
{
public:
Unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{
}
~Unique_ptr()
{
if (_ptr)
{
delete _ptr;
}
}
Unique_ptr(Unique_ptr& up) = delete;
Unique_ptr& operator=(Unique_ptr& up) = delete;
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
test:
与上面两个智能指针不同,c++11中还有一款智能指针——shared_ptr,这款智能指针就如同我们想象的那样了,可以支持赋值拷贝,而且复制拷贝之后,原对象依旧可以管理那一块资源,与赋值对象一块管理。shared_ptr的原理在于通过引用计数的方式来实现多个对象之间共享资源,最后一个析构对象释放资源。具体地,在shared_ptr内部,每个资源都维护了着一份计数,用来记录该份资源被几个对象共享,在对象被销毁时(调用析构函数),就说明自己不使用该资源了,对象的引用计数减一,如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源,如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源。
下面是模拟实现,除了Smartptr基本的框架外,成员属性还多个_pCount用来计数,本质是申请一块int大小的空间记录对应资源的被指向个数,两块资源一一对应,比如说,
这里使用一一对应的一块资源去引用计数就很巧妙,想一下为什么不用静态成员变量去计数?这个问题后面会说。
继续,_pCount在构造函数中初始化为1,在析构函数中如果是最后一个指向资源的对象,则与资源一块被释放,否则将计数减一。最主要的还是拷贝构造函数和赋值运算符重载的实现,对于拷贝构造函数,将指向的资源与计数资源赋值给目标对象之后,将计数加一;对于赋值运算符重载,因为涉及到原对象资源的释放,所以较为复杂一点,首先根据原对象中的引用计数决定是否要释放原对象指向的资源还是计数减一,再指向被赋值对象的资源,将计数加一即可。
模拟实现:
template
class Shared_ptr
{
public:
Shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pCount(new int(1))
{
}
~Shared_ptr()
{
if (--(*_pCount) == 0)
{
delete _ptr;
delete _pCount;
}
}
Shared_ptr(Shared_ptr& sp)
{
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
}
Shared_ptr& operator=(Shared_ptr& sp)
{
if (_ptr != sp._ptr) //这里不建议用(this!=&sp),因为除了同一个对象不能进行赋值操作,
//而且指向同一块资源的两个对象也不进行赋值运算
{
if (--(*_pCount) == 0)
{
delete _ptr;
delete _pCount;
}
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
int* _pCount;
};
test:
为什么不使用静态成员static int _count去计数呢?
因为静态成员属于整个类,属于此类实例化出的所有对象,我们需要一个资源配对一个计数,要是使用静态成员计数,则所有资源都只有这一个计数,比如:
想一下上面实现的shared_ptr还有啥问题不?当我们申请一个数组大小的资源或者使用malloc去申请资源时,类内实现的析构函数还能用吗?我们知道,删除数组资源需要使用delete[],删除使用malloc申请的资源需要用free释放,所以类内只是用delete释放资源是不全面的。这里我们可以通过在类模板参数中传入删除器的类决定释放资源的方式。在stl库里,shared_ptr的删除器是在构造时传入删除器对象,这种不太好实现,至少以现在的简单架构不好实现,所以采用在模板参数处传入删除器的方法,如下。
带删除器版本的shared_ptr模拟实现:
//默认
template
struct Delete
{
void operator()(T* ptr)
{
//cout << "delete" << endl;
delete ptr;
}
};
//删除数组申请的资源
template
struct deleteArray
{
void operator()(T* ptr)
{
cout << "delete[]" << endl;
delete[] ptr;
}
};
//删除malloc申请的资源
template
struct freeFunc
{
void operator()(T* ptr)
{
cout << "free" << endl;
free(ptr);
}
};
template >
class Shared_ptr
{
public:
Shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pCount(new int(1))
{
}
~Shared_ptr()
{
D del;
if (--(*_pCount) == 0)
{
del(_ptr);
delete _pCount;
}
}
Shared_ptr(Shared_ptr& sp)
{
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
}
Shared_ptr& operator=(Shared_ptr& sp)
{
D del;
if (_ptr != sp._ptr) //这里不建议用(this!=&sp),因为除了同一个对象不能进行赋值操作,
//而且指向同一块资源的两个对象也不进行赋值运算
{
if (--(*_pCount) == 0)
{
del(_ptr);
delete _pCount;
}
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
int* _pCount;
};
test:
上面模拟实现的shared_ptr看似是完整的了,但是还是存在很大的问题的——线程安全问题,即shared_ptr的引用计数是多个对象共享的,当多个线程中智能指针的引用计数同时++或--,但是这个操作不是原子的,可能会导致引用计数错乱,会导致资源未释放或者程序崩溃。比如说在下面程序中,在for循环之前,sp1资源的引用计数应该是2,后面进入for循环,两个线程同时进行,每个线程都是去实例化100000个智能指针对象,出了作用域自动释放,也就是for循环之后,sp1的引用计数还是2,但是执行发现:
可以发现结果是错乱的,并不是预料之内的2。
代码:
void ThreadSafety()
{
Shared_ptr sp1(new int);
Shared_ptr sp2(sp1);
vector v(2);
int n = 100000;
for (auto& t : v)
{
t = thread([&](){
for (size_t i = 0; i < n; ++i)
{
Shared_ptr sp(sp1);
}
});
}
for (auto& i : v)
{
i.join();
}
cout << sp1.use_count() << endl;//正常是2
}
因此shared_ptr的模拟实现中的引用计数++、--是需要加锁的,才能解决线程安全问题。在以上shared_ptr的模拟实现的基础上,在成员变量中增加一个互斥锁类型的指针并在构造函数中初始化,即new一个mutex类型。接下来就是在所有修改引用计数的地方进行lock()和unlock(),这个过程并不复杂,主要是知道需要解决线程安全这个问题,代码参考如下。
代码:
template >
class Shared_ptr
{
public:
Shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pCount(new int(1))
,_pmtx(new mutex)
{
}
void release()
{
D del;
int flag = false; //flag记录锁是否应该被释放了
_pmtx->lock();
if (--(*_pCount) == 0)
{
del(_ptr);
delete _pCount;
flag = true; //在这里不能直接将锁释放,因为后面还要解锁
}
_pmtx->unlock();
if (flag == true) //根据flag判断是否释放锁资源
delete _pmtx;
}
~Shared_ptr()
{
release();
}
void addref()
{
_pmtx->lock();
(*_pCount)++;
_pmtx->unlock();
}
Shared_ptr(Shared_ptr& sp)
{
_ptr = sp._ptr;
_pCount = sp._pCount;
_pmtx = sp._pmtx;
addref();
}
Shared_ptr& operator=(Shared_ptr& sp)
{
if (_ptr != sp._ptr) //这里不建议用(this!=&sp),因为除了同一个对象不能进行赋值操作,
//而且指向同一块资源的两个对象也不进行赋值运算
{
release();
_ptr = sp._ptr;
_pCount = sp._pCount;
_pmtx = sp._pmtx;
addref();
}
return *this;
}
T* get() const
{
return _ptr;
}
int use_count()
{
return *_pCount;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
int* _pCount;
mutex* _pmtx;
};
再次执行上面的程序:
从上面的一系列智能指针可以看出,shared_ptr是当中最符合实际情况使用的一个,但是在后续使用的过程中,有人发现有些场景使用智能指针引发了意料之外的结果,导致这个结果的原因就是循环引用的问题,看下面这个例子(其中use_count函数是返回智能指针的引用计数):
struct Node
{
~Node()
{
cout << "~node" << endl; //无别的作用,只是看Node资源有无释放
}
int _data;
shared_ptr _next;
shared_ptr _prev;
};
void testRotateRef()
{
shared_ptr n1(new Node);
shared_ptr n2(new Node);
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;
}
对于链表节点类,我们使用智能指针去管理资源,为什么我们类内的next、prev也使用智能指针定义呢?因为会存在赋值时有类型转换问题,比如n1->_next = n2;,那么问题就来了,我们先编译一下testRotateRef函数:
先甭管引用计数的变化,我们可以看到n1、n2的Node资源并没有释放(也就是没有打印出~Node),这是怎么回事呢?截图中可以看出,n1、n2定义完以后的引用计数都是1很正常,经过n1->_next = n2;n2->_prev = n1;后,引用计数都变成了2也正常,因为对于n2的资源,多了个n1的next智能指针指向n2资源,对于n1资源也是一样,所以引用计数都是2很正常,但是当这些语句走完时,正常来说两个Node节点资源应该释放了,但是并没有,因为两个资源的引用计数都是2,无法得以释放,图解如下:
上图可以看出,n1、n2在出作用域即将释放时,由于引用计数不是1,从而无法释放,导致这一现象的正是循环引用的问题,也就是两个资源互相牵制,使彼此无法正常释放。针对这个现象,c++11提出了weak_ptr的智能指针,捆绑shared_ptr以解决循环引用的问题,可看作shared_ptr的小跟班,可相互赋值,本质就是不参与资源的释放管理,因此不增加计数,但是可以访问修改资源。我们将Node类内的智能指针的类型改为weak_ptr再试一次:
可以看到,指向前后的引用计数并没有变化,而且两个Node节点资源得以释放,这就解决了循环引用的问题。
下面我们也模拟实现一下weak_ptr,如下代码块(其中get函数是返回智能指针的类内原生指针),除了Smart_ptr的基础框架,也除了基本的构造函数、拷贝构造函数、赋值运算符重载以外,类内多了个用shared_ptr类型构造的拷贝构造函数(也有用shared_ptr类型的赋值运算符重载,这里没写),这保证了weak_ptr和shared_ptr之间可以相互赋值,同时无析构函数,因为weak_ptr不参与资源的释放管理,资源的释放管理应该交给shared_ptr,因此无需析构函数释放资源。
template
class Weak_ptr
{
public:
Weak_ptr()
:_ptr(nullptr)
{}
Weak_ptr(const Shared_ptr& sp)
:_ptr(sp.get())
{}
Weak_ptr(const Weak_ptr& wp)
:_ptr(wp._ptr)
{}
Weak_ptr& operator=(const Weak_ptr& wp)
{
_ptr = wp._ptr;
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
智能指针是c++区别于其他语言独有的一个功能,但是并不是说其他语言没有前言中讲述的问题,只是有别的处理方法,比如java有垃圾处理器,能够将new的资源自动回收。智能指针本身不重要,重要的是RAII的思想,能将资源的管理交给一个类,这个方法就很符合面向类和对象的语言,并且智能指针的知识点在面试中被问到的频率非常高,比如手撕一个智能指针,RAII的思想是什么,针对于本文的最后两个问题中的提问点也有很多,这一章节不是特别难,希望大家可以深入学习一下,可以在面试中从容的面对任何提问。