目录:
- 前言
- 智能指针
- (一)智能指针初始
- 了解内存泄漏
- 1. 内存泄漏分类
- 2. 如何检测内存泄漏
- 3. 如何避免内存泄漏
- 使用智能指针之前,异常安全的处理
- (二)智能指针实现既原理
- 智能指针
- RAII
- 使用智能指针之后,异常安全的处理
- auto_ptr
- (三)c++11智能指针
- unique_ptr
- shared_ptr
- 1. 引用计数要存储在哪个区域
- 2.多线程,智能指针引用计数的访问
- 3.多线程,智能指针指向资源的访问
- 智能指针的循环引用
- weak_ptr
- 删除器
- 总结
前言
打怪升级:第92天 |
---|
智能指针
(一)智能指针初始
-
什么是智能指针
智能指针简单来说就是将指针封装到类中,借助对象的局部作用域有效特性,在出了作用域后自动释放资源。 -
为什么需要使用智能指针
上一篇文章我们讲解了C++异常的概念,我们也了解到异常的捕捉可以跨好几个函数,这就会引发异常安全的问题,在上一篇文章中我们解决异常安全的方法是在每一个可能会引发异常安全的地方都加上try,catch捕捉异常,处理完安全问题后再抛出;
上面的方法固然可以解决问题,但同时也会使得函数逻辑变得复杂许多,今天我们借助类的特性来大大简化这一逻辑。
- [注]: 异常可以直接跳好几个函数,这个只是我们在调试时看到的现象,在底层堆栈中依然是层层出栈的。
- 智能指针单指一个特殊指针吗
不是的,智能指针如今指的是一类指针,智能指针在c++98中就已经有了,当时的智能指针只有一个: auto_ptr,auto_ptr是一个类模板,但是由于auto_ptr的使用十分不尽人意,因此很多个人或公司都禁止使用,直到c++11出来后,为我们带来了许多实用的智能指针,常用的有三种:unique_ptr, shared_ptr,weak_ptr,,它们都包含于头文件 < memory>,下面我们会一一介绍。
了解内存泄漏
1. 内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
- 堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。 - 系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
2. 如何检测内存泄漏
在linux下内存泄漏检测:linux下几款内存泄漏检测工具
在windows下使用第三方工具:VLD工具说明
其他工具:内存泄漏工具比较
3. 如何避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配地去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
总结一下:
内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。
使用智能指针之前,异常安全的处理
#include<iostream>
using namespace std;
void Div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw("division of zero");
else
cout << "a / b = " << a / b << endl;
}
void Func()
{
int* p1 = new int;
int* p2 = new int;
Div();
cout << "delete p1" << endl;
delete p1;
cout << "delete p2" << endl;
delete p2;
}
int main()
{
while (1)
{
try
{
Func();
}
catch (const char* s)
{
cout << s << endl;
}
}
return 0;
}
这段代码大家应该可以理解,由于Div函数抛出异常,直接跳转到main函数中匹配的catch子句,导致func函数中发生内存泄漏。
防止内存泄漏的改进:
#include<iostream>
using namespace std;
void Div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw("division of zero");
else
cout << "a / b = " << a / b << endl;
}
void Func()
{
// p1 new空间时 抛异常会怎样,怎么办
// p2 new空间时 抛异常会怎样,怎么办
// Div 抛异常时怎么办
int* p1 = nullptr;
int* p2 = nullptr;
p1 = new int(10);
try{
p2 = new int(1);
}
catch (...){
cout << "delete p1" << endl;
delete p1;
throw;
}
try{
Div();
}
catch (...){
cout << "delete p1" << endl;
delete p1;
cout << "delete p2" << endl;
delete p2;
throw;
}
cout << "delete p1" << endl;
delete p1;
cout << "delete p2" << endl;
delete p2;
}
int main()
{
while (1)
{
try
{
Func();
}
catch (const char* s)
{
cout << s << endl;
}
catch (std::bad_alloc& e) // new错误 抛出的异常类型:bad_alloc
{
cout << "exception: " << e.what() << endl;
}
}
return 0;
}
原本一个十分简单的逻辑,为了使用异常我们就要防止内存泄漏,这使得原本简单的逻辑变得复杂。
(二)智能指针实现既原理
智能指针
智能指针的两点要去:
- 符合RAII
- 重载 解引用操作符*,与箭头操作符->模拟指针的行为。
RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象创建时获取资源,之后控制资源的访问在对象整个生命周期内都有效,在对象销毁时释放资源,这样将资源的申请与释放与对象绑定到一起。这样做有两个好处:
- 不用手动释放资源;
- 资源在对象的整个生命周期内都有效。
使用智能指针之后,异常安全的处理
此处我们实现了智能指针的两个条件中的一个:RAII,通过对象的生命周期来控制资源的申请和释放;
下面我们要添加对指针的访问,模拟指针的行为:
#include<iostream>
using namespace std;
template<class T>
class smart_ptr
{
public:
smart_ptr(T* mp)
:_ptr(mp)
{}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~smart_ptr()
{
cout << "delete" << endl;
delete _ptr;
}
private:
T* _ptr;
};
void Div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw("division of zero");
else
cout << "a / b = " << a / b << endl;
}
void Func()
{
smart_ptr<int>p1(new int(2));
smart_ptr<int>p2(new int(5));
Div();
}
int main()
{
while (1)
{
try
{
Func();
}
catch (const char* s)
{
cout << s << endl;
}
catch (std::bad_alloc& e)
{
cout << "exception: " << e.what() << endl;
}
}
return 0;
}
为什么使用智能指针可以解决这个问题, 底层很复杂吗? – 不见得
我们在一开始就说了, 触发异常时, 函数调用链仍然会一层层出栈(不然栈区空间就泄漏了, 并且, 如果不出栈, 怎么访问到该函数调用链上的其他函数), 那么在出栈时, 函数栈上的变量就会被销毁, 我们的智能指针虽然是用来管理堆区资源的, 但是智能指针本身创建在栈区, 在智能指针被销毁时, 就会调用它的析构函数同步销毁管理的资源. perfect
auto_ptr
auto_ptr整体逻辑与上方相同,看起来确实解决了异常安全问题,但是,为何auto_ptr会被使用者以及公司所排斥,甚至禁止使用auto_ptr呢?
让我们继续往下看去。
#include<memory> // auto_ptr
#include<iostream>
using namespace std;
void test_ptr()
{
int* p1 = new int(1);
cout << "*p1 = " << *p1 << endl;
// 指针赋值时,p2此时也指向p1指向的那块空间。
int* p2 = p1;
cout << "*p2 = " << *p2 << endl;
cout << "*p1 = " << *p1 << endl;
}
void test_auto_ptr()
{
auto_ptr<int>ap(new int(10));
cout << "*ap = " << *ap << endl;
auto_ptr<int>ap2(ap);
cout << "*ap2 = " << *ap2 << endl;
cout << "*ap = " << *ap << endl;
}
int main()
{
test_ptr();
//test_auto_ptr();
return 0;
}
经过试验我们发现了auto_ptr的缺陷所在:当auto_ptr对象要进行拷贝构造时,新的对象可以访问拷贝来的资源,而被拷贝对象则无法访问了,这个实际的指针用法并不相符。
这中现象我们称为:悬空指针(没有指向一块实际内存的指针)
这种做法我们称为:管理权转移(ap没有权利访问原本属于它的资源了)。
听起来好像很高大上,底层实现很简单,如下所示:
namespace kz
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* mp)
:_ptr(mp)
{}
auto_ptr(auto_ptr& ap) // 拷贝构造
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
auto_ptr& operator=(auto_ptr& ap) // 赋值
{
if (_ptr != ap._ptr) // 自己给自己赋值
{
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~auto_ptr()
{
cout << "delete" << endl;
delete _ptr;
}
private:
T* _ptr;
};
}
由于auto_ptr这样在拷贝、赋值方面的奇葩行为,最开始的智能指针auto_ptr并不受人待见,但是由于以及出来的标准就不会再做更改,所以写出管理权转移操作的大佬当时也是追悔莫及。
(三)c++11智能指针
但是骂归骂,在使用异常时,智能指针在简化代码方面的优势也十分明显,因此大家在使用时还是会自行封装智能指针,为此在c++11中又引入了几个智能指针unique_ptr, shared_ptr, weak_ptr;那么说到这里就不得不提一句boost库了,
由于c++标准更新缓慢,很多大佬等不及体验新版本,而且委员会也有委员想要测试预上线功能的体验效果,因此就有委员会成员发起组织了Boost社区,封装了一系列的很有用的库,c++11的智能指针、右值引用等都脱胎于Boost库。
unique_ptr
见名知意,该智能指针为了解决auto_ptr的管理权转移漏洞,直接简单粗暴地禁止智能指针进行拷贝与赋值。
template<class T>
class unique_ptr
{
public:
unique_ptr(T* p = nullptr)
:_ptr(p)
{}
// 防止拷贝的两种做法
/*// c98 -- 拷贝构造和赋值 声明为私有成员
private:
unique_ptr(unique_ptr& up);
unique_ptr& operator=(unique_ptr& up);*/
// c++11 -- delete不需要的成员函数
unique_ptr(unique_ptr& up) = delete;
unique_ptr& operator=(unique_ptr& up) = delete;
// ... 模拟指针行为
~unique_ptr()
{
if (_ptr)
{
cout << "~unique_ptr()" << endl;
delete _ptr;
}
}
private:
T* _ptr;
};
shared_ptr
见名知意:shared既共享,unique_ptr限制了拷贝与赋值,也就是一份资源只能有一个智能指针指向它,
而shared是使用引用计数来标识可以有多个指针指向同一份资源,引用计数就是一个整数,记录此时有多少个指针指向这份资源。
举个栗子:
10个学生来上晚自习,自习室代表资源,则unique是一个学生用一个自习室,shared是10个学生可以共用一个自习室,此时引用计数为10;
释放资源时,unique是一个学生使用一个自习室,来时开门,走时锁门,
shared是最后一个走的学生锁门,此时引用计数应当变为0,表示自习室中没有学生。
shared_ptr有两点需要注意:
1. 引用计数要存储在哪个区域
要实现shared_ptr,我们肯定是要有一个引用计数,
首先我们知道引用计数需要被多个对象共享,既然要让不同对象看到同一个引用计数,那么它就不能是在栈区,因为各个对象的栈区数据是独立的;
剩下还有两种方法:1. 设置为静态成员,2. 开辟到堆区
- 设置为static成员
静态成员在整个类中只有一个,可以被所以类成员共享,那么设置为静态成员确实可以保证不同对象看到同一份数据,如果该智能指针只申请一份资源还可以满足,
但当资源数增加时,使用不同的资源的对象也是看到的那一个引用计数,显然不符合实际情况,实际应当是一个资源匹配一个引用计数才对。
- 同资源一样,在堆区开辟
同资源一起,每次申请新的资源时,同时申请一个引用计数。
此时就完美解决了不同资源各自拥有自己的引用计数问题。
template<class T>
class shared_ptr
{
public:
shared_ptr(T* p = nullptr)
:_ptr(p)
{
_rcount = new int(_ptr != nullptr);
}
shared_ptr(shared_ptr& sp)
:_ptr(sp._ptr)
,_rcount(sp._rcount)
{
if (_ptr) ++(*_rcount);
}
shared_ptr& operator=(shared_ptr& sp)
{
if (_ptr != sp._ptr)
{
// 释放之前的资源
Destory();
// 链接现在的资源
if (sp._ptr)
{
_ptr = sp._ptr;
_rcount = sp._rcount;
}
}
}
// 释放资源
void Destory()
{
if (--(*_rcount) == 0)
{
cout << "Destory()" << endl;
delete _ptr;
delete _rcount;
_ptr = _rcount = nullptr;
}
}
~shared_ptr()
{
Destory();
}
private:
T* _ptr;
int* _rcount;
};
2.多线程,智能指针引用计数的访问
void Func(kz::shared_ptr<int>& sp, int n)
{
for (int i = 0; i < n; ++i)
kz::shared_ptr<int>tmp(sp);
}
void test_shared2()
{
kz::shared_ptr<int>sp(new int(1));
int n = 10000;
thread t1(Func, ref(sp), n);
thread t2(Func, ref(sp), n);
t1.join();
t2.join();
cout << sp.use_count() << endl;
}
- 注:线程传参是需要将引用对象放入函数模板**ref()**中,保证是引用传参;
- 智能指针是C++中的一种特殊指针,它可以自动管理动态分配的内存,避免内存泄漏和悬挂指针等问题。智能指针通常使用引用计数来追踪资源的所有权,并在不再需要时自动释放资源。
如果不加引用,智能指针的行为可能会变得不可预测,甚至会导致程序崩溃。这是因为智能指针的拷贝构造函数和析构函数都使用了引用计数,通过引用计数来管理资源的生命周期。如果没有使用引用,拷贝构造函数和析构函数无法正确地增加和减少引用计数,从而导致计数不一致,资源无法正确释放。
因此,使用智能指针时,应该始终使用引用来传递和操作智能指针对象,以确保引用计数的正确性和资源的正确释放。
下方为多线程情况下,运行的结果:
有结果可知,我们上面的实现无法满足多线程的要求,那么问题出在哪里,
我们可以看一看_rcount的值:2, 4,以及同时出现两个0(析构两次),这说明我们的_rcount控制上有问题,
因为++,–操作不是原子的,所以多线程访问时应该互斥访问 – 加锁。
template<class T>
class shared_ptr
{
public:
shared_ptr(T* p = nullptr)
:_ptr(p)
,_rcount(new int())
,_mtx(new mutex)
{
if(_ptr) AddRcount();
}
shared_ptr(shared_ptr& sp)
:_ptr(sp._ptr)
,_rcount(sp._rcount)
,_mtx(sp._mtx)
{
if (_ptr) AddRcount();
}
void AddRcount()
{
_mtx->lock();
++(*_rcount);
_mtx->unlock();
}
shared_ptr& operator=(shared_ptr& sp)
{
if (_ptr != sp._ptr)
{
// 释放之前的资源
Destory();
// 链接现在的资源
if (sp._ptr)
{
_ptr = sp._ptr;
_rcount = sp._rcount;
_mtx = sp._mtx;
AddRcount();
}
}
}
T* get()
{
return _ptr;
}
int use_count()
{
return *_rcount;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
// 释放资源
void Destory()
{
bool lockFlag = false;
_mtx->lock();
if (--(*_rcount) == 0)
{
cout << "Destory()" << endl;
delete _ptr;
delete _rcount;
lockFlag = true;
_ptr = _rcount = nullptr;
}
_mtx->unlock();
if (lockFlag) // _mtx需要先解锁再释放,不能直接在上方释放
{
delete _mtx;
_mtx = nullptr;
}
}
~shared_ptr()
{
Destory();
}
private:
T* _ptr;
int* _rcount;
mutex* _mtx;
};
3.多线程,智能指针指向资源的访问
上方我们对智能指针内部引用计数的访问进行了加锁,保证了线程安全,
那么对智能指针指向资源的访问是否线程安全?
显而易见,对资源的访问也不是线程安全的,因为对资源的访问为用户自己的行为,需要用户自行加锁。
void Func3(kz::shared_ptr<int>& sp, int n, mutex& pmtx)
{
for (int i = 0; i < n; ++i)
{
pmtx.lock();
++(*sp);
pmtx.unlock();
}
}
void test_shared3()
{
kz::shared_ptr<int>sp(new int(1));
int n = 10000;
mutex pmtx;
thread t1(Func3, ref(sp), n, ref(pmtx));
thread t2(Func3, ref(sp), n, ref(pmtx));
t1.join();
t2.join();
cout << *sp << endl;
}
智能指针的循环引用
struct ListNode
{
kz::shared_ptr< ListNode> _prev;
kz::shared_ptr< ListNode> _next;
int _val;
};
void test_cycle()
{
kz::shared_ptr<ListNode>d1(new ListNode);
kz::shared_ptr<ListNode>d2(new ListNode);
d1->_next = d2;
d2->_prev = d1;
}
循环等待是shared_ptr的引用计数特性给自己埋下的一个坑,这里的问题就在于next与prev指向智能指针对象时,引用计数进行了++,
导致在析构时rcount值不为0,为了解决这个这个问题,有有了weak_ptr。
weak_ptr
weak_ptr专门为shared_ptr设计,用来避免对shared_ptr拷贝或赋值时改变引用计数rcount的值。
namespace kz
{
template<class T>
class weak_ptr
{
public:
weak_ptr(T* p = nullptr)
:_ptr(p)
{}
weak_ptr(shared_ptr<T>& sp) // 模拟库中weak_ptr的实现逻辑,真正的底层并不是如此简单
:_ptr(sp.get())
{}
weak_ptr& operator=(shared_ptr<T>& sp)
{
if (_ptr != sp.get())
{
if (sp.get())
_ptr = sp.get();
}
return *this;
}
private:
T* _ptr;
};
}
struct ListNode
{
kz::weak_ptr<ListNode> _prev;
kz::weak_ptr<ListNode> _next;
int _val;
};
void test_cycle()
{
kz::shared_ptr<ListNode>d1(new ListNode);
kz::shared_ptr<ListNode>d2(new ListNode);
d1->_next = d2;
d2->_prev = d1;
}
标准库中无法打印其他信息,通过引用计数我们也可以看出与我们实现的一致:weak_ptr不会增加引用计数
删除器
删除器顾名思义就是用来删除操作的,上方我们看到,我们的析构操作都是使用的delete,那么与之对应的申请空间的操作符就是new,
也就是说,我们上方的智能指针所控制的资源只能是单个的资源,并且只能是通过new申请到的;
如果我们传入的是malloc、或new [ ] 申请的资源,delete很可能不会如我们所愿地完成资源的释放。
为了完成资源申请与释放的两两配对,引入了删除器的概念。
删除器:就是自行设置删除函数。
template<class T>
struct FreeFunc
{
void operator()(T* p)
{
cout << "free" << endl;
free(p);
}
};
template<class T>
struct DeleteArrayFunc
{
void operator()(T* p)
{
cout << "delete array" << endl;
delete[]p;
}
};
struct Date
{
~Date()
{}
int _year;
int _month;
int _day;
};
void test_deleter()
{
std::shared_ptr<int[]>sp1(new int[10] {10});
std::shared_ptr<Date>sp2(new Date[10], DeleteArrayFunc<Date>());
std::shared_ptr<int>sp3((int*)malloc(sizeof(int)), FreeFunc<int>());
}
如此就解决了申请与释放操作相匹配的问题。
总结
- 智能指针类似于之前学过的迭代器,但又有所不同,
智能指针是借助对象的局部作用域有效,自动释放资源,顺便对资源进行访问,他管理资源的申请和释放;
迭代器是用来遍历容器,访问数据,并不控制资源的申请和释放。 - 关于shared_ptr:
智能指针是一种用于管理动态分配的内存资源的工具,它可以自动地在适当的时机释放所管理的内存,避免内存泄漏和悬挂指针等问题。智能指针通常用于以下场景:
动态内存管理:在C++中,使用new关键字进行动态内存分配,而使用智能指针可以自动管理分配的内存,避免忘记释放内存或者释放过早导致的问题。
资源管理:除了内存,智能指针还可以用于管理其他类型的资源,比如文件句柄、网络连接等。通过使用智能指针,可以确保在不再需要资源时正确地释放它们,避免资源泄漏。
异常安全性:在面对异常情况时,智能指针可以确保资源的正确释放。当发生异常时,智能指针会自动调用析构函数来释放资源,从而保证程序的异常安全性。
循环引用的管理:在存在循环引用的情况下,使用智能指针可以解决内存泄漏的问题。智能指针使用引用计数的方式来管理资源的生命周期,当引用计数为0时,自动释放资源。
总的来说,智能指针提供了一种方便、安全和可靠的方式来管理动态分配的内存和其他资源,可以减少手动内存管理的工作量,提高程序的健壮性和可维护性。