内存泄漏
内存泄漏是指程序在动态分配内存后,忘记或无法释放已经不再使用的内存,从而导致系统内存资源被逐渐耗尽的问题。这种情况下,即使程序本身并没有出现逻辑错误,也会因为内存泄漏而导致程序运行时间越来越长,甚至最终崩溃。
案例分析:
下面是一个典型的 C++ 内存泄漏案例:
while (true) { int* p = new int(10); // 这里忘记释放内存,导致内存泄漏 }
在这个程序中,我们在一个无限循环中不断分配新的内存块。然而,我们忘记在循环体内释放这些内存块,导致程序运行时内存占用会越来越大,直到最终耗尽系统内存,程序崩溃。
这种情况下,即使程序本身没有逻辑错误,也会因为内存泄漏而导致严重的问题。
内存泄漏的危害:
- 系统内存资源被逐渐耗尽:内存泄漏会导致程序运行时内存占用越来越大,直到最终耗尽系统内存。这会严重影响程序的性能和稳定性。
- 程序可能会崩溃:内存耗尽后,程序会尝试访问非法内存地址,从而导致程序崩溃。
- 资源浪费:即使程序没有崩溃,内存泄漏也会导致大量内存资源被无谓地占用,造成资源浪费。
内存泄漏是 C++ 程序中一个非常常见的问题,如果不能及时发现并修复,会对程序的性能、稳定性和安全性造成严重影响。
RAII
机制就是一种自动管理资源的机制,其可以帮助程序员自动释放资源,来避免内存泄漏,C++中,智能指针就是基于RAII产生的。
RAII
RAII
(Resource Acquisition Is Initialization) 是 C++ 中一种非常重要的内存管理机制,它可以帮助我们有效地管理资源,避免内存泄漏等问题。
RAII
的核心思想是:
- 将资源的分配和释放绑定在对象的生命周期上。
- 在对象构造时获取资源,在对象析构时释放资源。
以下示例就是一个基本的RAII
:
template <class T> class smartPtr { public: smartPtr(T* ptr) :_ptr(ptr) {} ~smartPtr() { delete _ptr; } private: T* _ptr; }; int main() { smartPtr<int> ptr1 = new int(5); smartPtr<double> ptr2 = new double(3.14); smartPtr<vector<int>> ptr3 = new vector<int>(10); return 0; }
在上述示例中,smartPtr
类负责管理资源的获取和释放。在 main
函数中,我们创建了三个 smartPtr
对象 ptr1
、 ptr2
、 ptr3
。
当这些对象进入作用域时,构造函数会被调用,将smatrPtr
的_ptr
成员初始化为对应动态内存的指针。
当三个对象离开作用域时,析构函数会被自动调用,自动delete _ptr
释放资源。
通过 RAII
机制,我们可以确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。这种做法也使得代码更加简洁易读,并且可以实现异常安全性。
这样做的好处是:
- 确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。
- 资源的获取和释放过程被封装在对象的构造和析构函数中,使得代码更加简洁易读。
- 可以利用 RAII 机制实现异常安全性,即使程序抛出异常,资源也能被正确释放。
而以上案例,就是C++智能指针最根本的原理,C++一共提供了四种智能指针:auto_ptr
、unique_ptr
、shared_ptr
、weak_ptr
。
智能指针
讲解这四种智能指针之前,我们先看看我刚刚案例中的smartPtr
存在的问题:
template <class T> class smartPtr { public: smartPtr(T* ptr) :_ptr(ptr) {} ~smartPtr() { delete _ptr; } private: T* _ptr; }; int main() { smartPtr<int> ptr1 = new int(5); smartPtr<int> ptr2 = ptr1; return 0; }
在main函数中,先构造了ptr1,指向一个int类型的动态内存。随后拿ptr1拷贝构造出了ptr2,此时ptr1和ptr2都指向这个int的动态内存。那么此时就要面临一个问题:当ptr1和ptr2出了作用域,那么两个对象都会调用析构函数,导致同一块内存被delete两次!这会直接导致进程崩溃,C++的智能指针需要解决这个问题。
另外的,智能指针本质上是一个类,不自带*
,->
等操作,所以要对操作符进行重载:
T operator*() { return *_ptr; } T* operator->() { return _ptr; }
*
,->
等操作本质都是在对_ptr
做操作,至于为什么要这样操作,可见博客[C++:迭代器的封装思想]。
接下来我们正式讲解C++自带的各种智能指针:
C++的智能指针,包含在
<memory>
头文件中
讲解以下四个智能指针时,默认包含了<memory>
头文件。
auto_ptr
auto_ptr
是C++标准中,最早出现的智能指针,属于C++98
。
构造函数:
auto_ptr
自带一个通过指针来进行初始化的构造函数,但是其被explicit
修饰,这说明不允许进行类型转换:
auto_ptr<int> ptr1(new int(5));//正确 auto_ptr<int> ptr2 = new int(10);//错误
auto_ptr只允许通过第一种方式,直接构造,第二种方式的本质是类型转换,从int*转为auto_ptr<int>,只要有对应类型的构造函数那么C++就可以支持这个类型转换,但是如果构造函数被explicit修饰,这个类型转换功能就会被禁止,而auto_ptr就被禁止了。
其实,C++的四种智能指针,都不允许通过原生指针的类型转化来构造,也就是四种智能指针都只能通过小括号来初始化。
不过拷贝构造是允许的:
auto_ptr<int> ptr1(new int(5)); auto_ptr<int> ptr3(ptr1); auto_ptr<int> ptr2 = ptr1;
后两种拷贝方式,都是正确的,因为拷贝构造没有被explicit
修饰。
提到拷贝,我们刚讲到RAII中,如果多个类指向同一块空间,会导致资源重复释放的问题,那么auto_ptr
是如何解决的呢?
当auto_ptr
发生拷贝,原先的auto_ptr
会变成空指针
为了方便观察,我们需要得知指针指向的地址:auto_ptr
可以get
接口看到内部存储的地址。
示例:
auto_ptr<int> ptr1(new int(5)); auto_ptr<int> ptr2 = ptr1; cout << ptr1.get() << endl; cout << ptr2.get() << endl;
输出结果:
0000000000000000 00000294B7187000
可以看到,经过拷贝后ptr1
内部的值变为了0000000000000000
,也就是空指针,原先指向动态内存的指针交给了ptr2
。
如果是delete
,delete
空指针的结果是啥也不干,因此可以保证内存的安全。
不过这种解决方案过于简单粗暴了,后续在C++11
又出了三个智能指针,以更加优秀的方式来处理这个问题。
unique_ptr
unique_ptr
以一种非常简单粗暴的方式来处理智能指针的拷贝问题:
unique_ptr
不允许拷贝
这确实很简单粗暴,从源头上解决问题,如果你确定你不需要对指针进行拷贝,可以考虑使用unique_ptr
。
相比于auto_ptr
,unique_ptr
还有一个优化,那就是下标访问opertaor[]
。
示例:
auto_ptr<int> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout << ptr1[5] << endl;//错误
这个auto_ptr
指向了一个通过new[]
开辟的数组,原生指针是支持下标访问的,但是auto_ptr
不支持,以上代码的第二行是错误的。
看到unique_ptr
:
unique_ptr<int> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout << ptr1[5] << endl;//错误
对于这种情况,unique_ptr
也会报错,如果想要unique_ptr
支持下标访问,要用更加特殊的语法:
unique_ptr<int[]> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout << ptr1[5] << endl;//正确
如果指向的动态内存是数组的形式,一次性开辟的,模板参数要写为type[]
的形式,来告诉unique_ptr
该指针维护的动态内存,是以数组的形式开辟的。以上示例中,模板参数为int[]
,那么此时就可以通过下标来访问这个动态内存了。
shared_ptr
shared_ptr
是对拷贝处理的最好的一个智能指针,其机制也比较复杂,实际中更常用这一种智能指针。
shared_ptr
通过引用计数来处理多个指针指向同一块内存的问题
对于同一块内存,有多少个shared_ptr
指向该内存,那么count
就是多少:
上图中,动态内存被三个shared_ptr
指向,那么count = 3
,现在ptr3
离开作用域:
ptr3
离开作用域后,count = 2
,说明此时还有两个指针指向这个动态内存,那么ptr3
的析构函数不会释放掉动态内存!
当ptr2
离开作用域:
此时count = 1,整个动态内存只有一个ptr1指向,只要ptr1离开作用域,那么count = 0,此时ptr1的析构函数就会把动态内存释放掉。
也就是说shared_ptr通过记录有几个指针指向动态内存,来决定析构的时候是否释放内存,将动态内存的释放交给最后一个shared_ptr来完成,既能确保每一个shared_ptr都是有效的,又可以确保资源不会被重复释放。
要注意的是:不是所有shared_ptr
都共用一个count
,对于每一块动态内存,都有独立的count
,互不影响。
上图中,ptr1
、ptr2
、ptr3
指向A
,ptr4
和ptr5
指向B
。
此时ptr1
、ptr2
、ptr3
共用一个count
,ptr4
和ptr5
共用一个count
。
与unique_ptr
一样,shared_ptr
也可以支持下标访问:
shared_ptr<int[]> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout << ptr1[3] << endl;
循环引用
shared_ptr
会存在一个循环引用的问题。
现在我们有如下链表节点:
class ListNode { public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} ListNode* _prev; ListNode* _next; int _val; }; int main() { ListNode* l1 = new ListNode(5); ListNode* l2 = new ListNode(10); l1->_next = l2; l2->_prev = l1; delete l1; delete l2; return 0; }
以上代码中,l1
是l2
的后一个节点,因此l1->_next = l2
,l2->_prev = l1
,最后程序结束前,delete
掉两个节点,这段代码是没问题的。
现在我们把以上代码中的原生指针,改为shared_ptr
:
class ListNode { public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} shared_ptr<ListNode> _prev; shared_ptr<ListNode> _next; int _val; }; int main() { shared_ptr<ListNode> l1(new ListNode(5)); shared_ptr<ListNode> l2(new ListNode(10)); l1->_next = l2; l2->_prev = l1; return 0; }
请问这串代码有问题吗?
其实在以上代码中,就已经造成了内存泄漏问题,这就是典型的循环引用问题,我来解析一下:
一开始,通过两个shared_ptr
指向了两个ListNode
的动态内存:
此时两块内存都分别只被一个shared_ptr
指向,count = 1
。
随后执行l1->_next = l2
,l2->_prev = l1
:
此时ListNode
内部的指针互相指向,由于ListNode
内部的指针也是shared_ptr
,count
都变成了2
。
随后l2
离开作用域,此时count--
:
由于l1->_next
依然指向橙色的内存,此时count = 1
,不会释放内存。
随后l1
离开作用域,count--
:
由于l2->_prev指向绿色区域(l2其实已经不存在了),count = 1,此时不释放内存。
现在陷入一个死循环:如果绿色区域要被释放,那么橙色的_prev就要先释放,但是橙色的_prev要释放,那绿色的_next要先释放,造成一个死循环,导致内存永远无法释放,内存泄漏。这就是循环引用问题。
如果在类的内部使用这种相互指向的shared_ptr,就很容易发生循环引用的问题。
这个问题在于,_prev和_next这两个成员存在的意义,在于互相访问资源,我们并不需要_prev和_next来完成资源的释放,所以_prev和_next完全没必要用shared_ptr,用一般的指针即可:
class ListNode { public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} ListNode* _prev; ListNode* _next; int _val; };
但是现在存在一个问题:
l1->_next = l2; l2->_prev = l1;
这两天语句是错误的,因为l2
的类型是shared_ptr<ListNode>
,而l1->_next
的类型是ListNode*
,shared_ptr
的类型转换是被禁止的,所以只能通过get
接口:
l1->_next = l2.get(); l2->_prev = l1.get();
这样未免太不简洁,此时就需要我们的最后一个智能指针weak_ptr
出场了。
weak_ptr
weak_ptr
是一种不参与资源管理的智能指针,其只存在三种构造函数:
- 无参默认构造,此时
weak_ptr
初始化为空指针 - 拷贝构造,拷贝其它
weak_ptr
- 通过
shared_ptr
初始化,此时shared_ptr
和weak_ptr
指向同一块内存
当shared_ptr
和weak_ptr
指向同一块内存的时候,weak_ptr
不会增加引用计数!
weak_ptr
离开作用域的时候,不会释放自己指向的资源,其只负责访问资源。特点在于可以通过shared_ptr
初始化。
因此刚刚的循环引用可以被优化为:
class ListNode { public: ListNode(int val) : _val(val) {} weak_ptr<ListNode> _prev; weak_ptr<ListNode> _next; int _val; };
要注意的是,weak_ptr
不支持原生指针初始化,哪怕是nullptr
也不可以,因此在ListNode
的初始化列表中,删掉了_next
和_prev
的初始化。
现在以下代码就完全合法了:
shared_ptr<ListNode> l1(new ListNode(5)); shared_ptr<ListNode> l2(new ListNode(10)); l1->_next = l2; l2->_prev = l1;
weak_ptr
可以通过shared_ptr
初始化,因此可以直接将shared_ptr
赋值给weak_ptr
,又由于weak_ptr
不参与计数,最后只要l1
,l2
离开作用域,空间就会被正常释放。
deleter
对于智能指针,有时候需要用特殊的方式来对资源进行释放,比如文件指针:
shared_ptr<FILE> fp(fopen("test.txt", "w"));
对于指针fp
,不能简单地delete pf
,而是通过fclose(pf)
,此时我们就要用到自定义删除器deleter
了。
对于shared_ptr
和unique_ptr
,两者的deleter
语法不太相同,此处分开讲解:
shared_ptr
shared_ptr
的语法为:
shared_ptr<T> p(new T, deleter_function);
其中, deleter_function
是一个满足删除器要求的可调用对象
,包括函数指针
,仿函数
,lambda
三种。
比如通过lambda
来完成文件的fclose
:
shared_ptr<FILE> fp(fopen("test.txt", "w"), [](FILE* ptr) { fclose(ptr); });
通过仿函数:
struct deleteFile { void operator()(FILE* ptr) { fclose(ptr); } }; int main() { shared_ptr<FILE> fp(fopen("test.txt", "w"), deleteFile()); return 0; }
也就是说,对于shared_ptr
,只需要把删除器的可调用对象,直接作为第二个参数传入即可。
unique_ptr
unique_ptr
的删除器语法比较别扭,要求在模板参数中传入可调用对象的类型。
unique_ptr<T, 可调用对象类型> p(new T, 可调用对象);
同样的,可调用对象支持函数指针
,仿函数
,lambda
三种。
以刚刚的关闭文件为例:
- 使用函数指针:
void deleteFunc(FILE* ptr) { fclose(ptr); } int main() { unique_ptr<FILE, void(*)(FILE*)> fp2(fopen("test.txt", "w"), deleteFunc); return 0; }
该函数指针的类型为void(*)(FILE*)
,作为unique_ptr
的第二个模板参数。
- 使用仿函数:
struct deleteFile { void operator()(FILE* ptr) { fclose(ptr); } }; int main() { unique_ptr<FILE, deleteFile> fp(fopen("test.txt", "w"), deleteFile()); return 0; }
仿函数的类型是deleteFile
,即类名,作为unique_ptr
的第二个模板参数。
- 使用
lambda
表达式:
auto expression = [](FILE* ptr) { fclose(ptr); }; unique_ptr<FILE, decltype(expression)> fp(fopen("test.txt", "w"), expression);
这里, expression
是一个lambda
表达式,由于lambda
的类型是随机的,只能通过decltype(expression)
来检测类型,作为unique_ptr
的第二个模板参数。