我们在上一节异常中提到了 C++ 没有垃圾回收机制,资源需要自己手动管理;同时,异常会导致执行流乱跳;所以 C++ 异常非常容易导致诸如内存泄露这样的安全问题。我们以下面的程序为例:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
delete p1;
delete p2;
cout << "release pointer" << endl;
}
int main()
{
try {
Func();
}
catch (exception& e) {
cout << e.what() << endl;
}
catch (...) {
cout << "Unknow Error" << endl;
}
return 0;
}
上面这段程序最可能发生内存泄漏的情况是 div 函数抛异常,导致程序直接跳转到 main 函数的 catch 语句处,p1 和 p2 指向的空间未被释放:
针对这种情况我们的做法是在 Func 中对 div 异常进行捕获后,将 p1 p2 释放,最后再将异常重新抛出,如下:
虽然这样可以达到目的,但是这样的代码显然很挫,最重要的是 new 也可能会抛异常;在上面的程序中,如果 p1 new 空间失败,此时不会发生内存泄露;但如果 p1 new 空间成功,而 p2 new 空间失败,那么 p1 就会发生内存泄露,此时我们就需要在 “int *p2 = new int” 语句这里再套一层 try catch 语句来释放 p1,那么如果再有 p3、p4 呢?为了缓解异常所引发的内存泄露问题,C++ 设计出了智能指针。
智能指针本质上是一个类,这个类的成员函数根据其功能被分为两类:
这样,我们实际把管理一份资源的责任托管给了一个对象,这样做有如下好处:
简单来说,RAII 就是类的构造函数和析构函数,我们将申请到的资源通过构造函数托付给类的对象来管理,然后在类对象销毁调用析构函数时自动释放该资源,在构造和析构期间该资源可以始终保存有效。
下面是一个简单的智能指针示例:
template<class T>
class SmartPtr
{
public:
//RAII
SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
~SmartPtr()
{
delete _ptr;
cout << "~SmartPtr " << _ptr << endl;
}
//支持指针的各种行为
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T operator[](size_t pos)
{
return _ptr[pos];
}
private:
T* _ptr;
};
如上,我们将 new 出来的资源直接交给类的局部对象,这样在类对象生命周期内该资源都有效,类对象销毁时该资源也会被自动释放,并且我们也可以像使用正常指针一样通过类对象对资源进行各种操作。以后,当 p1 p2 new 空间或者 div 函数抛异常时,由于异常发生会正常释放函数的栈空间,所以局部对象会被正常销毁,那么被局部对象管理的资源也就能够被正常释放了,从而很大程度上缓解了异常的内存泄露问题。
智能指针存在的问题
智能指针虽然能够很好的管理资源,但是智能指针的拷贝与赋值是一个很大的问题,它涉及到资源的管理权问题 – 由谁管理、由一个单独管理还是多个共同管理,我们下文学习到的几种智能指针都是围绕这个问题展开的。
C++ 中的第一个智能指针名为 auto_ptr,由 C++98 提供,但由于 auto_ptr 存在极大的缺陷,同时 C++98 的后一个大版本 – C++11 又发布的很晚,所以 C++ 标准委员会的部分成员发起并创建了 boost 库,其目的是为C++的标准化工作提供可供参考的实现,因此 boost 库又被称为 C++ 库的准标准库。boost 库中提供了另外的几种重要的智能指针 – scoped_ptr、shared_ptr 和 weak_ptr,它们都被 C++11 标准所借鉴,并发布了对应的标准版本 – unique_ptr、shared_ptr 与 weak_ptr,它们也是我们学习的重点。
auto_ptr 是 C++ 中的第一个智能指针,它解决智能指针拷贝问题的方式是 管理权转移,即当用当前对象拷贝构造一个新对象时,会将当前对象管理的资源交给新对象,然后将自己的资源置空。auto_ptr 最大的问题是它会导致 对象悬空,即后面再使用当前对象时,会造成空指针解引用。
由于auto_ptr 非常危险,所以很多公司明确要求不能使用它,并且 C++11 也已经弃用了 auto_ptr,并使用 unique_ptr 来代替它。
下面是对 auto_ptr 的简单模拟实现:
template<class T>
class auto_ptr
{
public:
//RAII
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{};
~auto_ptr()
{
delete _ptr;
cout << "~auto_ptr " << _ptr << endl;
}
//拷贝构造 -- 资源管理权转移
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
//赋值重载 -- 先释放自己原来的资源,再进行资源管理权转移
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
//检查自我赋值
if (_ptr != ap._ptr)
{
this->~auto_ptr();
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
T* get() { return _ptr; }
private:
T* _ptr;
};
unique_ptr 是 C++11 提出的一种更安全的智能指针,它解决拷贝问题的方式是 直接不允许拷贝 – 防拷贝。
下面是 unique_ptr 的简单模拟实现:
template<class T>
class unique_ptr
{
public:
//RAII
unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{};
~unique_ptr()
{
delete _ptr;
cout << "~unique_ptr " << _ptr << endl;
}
//防拷贝
unique_ptr(const unique_ptr<T>& ap) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& ap) = delete;
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
T* get() { return _ptr; }
private:
T* _ptr;
};
shared_ptr 是 C++ 中被使用的最多的一个智能指针,它通过引用计数来解决智能指针的拷贝问题,使得一份资源可以被多个类对象共同管理;同时,shared_ptr 的引用计数是线程安全的。
前面我们提到,auto_ptr 通过转移资源管理权的方式来解决拷贝问题,unique_ptr 通过防拷贝的方式来解决拷贝问题;shared_ptr 则是通过引用计数的方式来解决拷贝问题,即用当前对象拷贝一个新的对象时,我们让新对象与当前对象共同来管理这份资源,并以++引用计数的方式来标识这份资源被多少个对象所管理;当对象销毁时,引用计数–,但是资源并不一定销毁,而只有当引用计数为0时资源才真正销毁。
对于如何设计引用计数,我们有如下几种方案:
下面是 shared_ptr 的初步实现:
template<class T>
class shared_ptr
{
public:
//RAII
//引用计数指向堆上的一块空间
shared_ptr(T* ptr = nullptr)
: _ptr(ptr), _pcount(new int(1))
{}
~shared_ptr()
{
//引用计数为0才进行析构
(*_pcount)--;
if (*_pcount == 0)
{
delete _ptr;
delete _pcount;
cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
}
}
//拷贝构造 -- 共享资源(++引用计数)
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)
{
//调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
this->~shared_ptr();
_ptr = sp._ptr;
_pcount = sp._pcount;
(*_pcount)++;
}
return *this;
}
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
int use_count() { return *_pcount; }
T* get() const { return _ptr; }
private:
T* _ptr;
int* _pcount; //引用计数
};
我们上面的模拟实现的 shared_ptr 在多线程环境下可能会发生线程安全问题,而库中的 shared_ptr 则不会,如下:
可以看到,我们自己实现的 shared_ptr 在多线程环境下运行后引用计数的值是错误且随机的 (正确应该为0),而库中的 shared_ptr 则是正确的,其原因如下:
注:加锁和解锁的过程是原子的 (有特殊的一条汇编指令来完成锁状态的修改),所以锁本身是线程安全的,我们不需要担心锁的安全性。
我们也可以使用互斥锁将模拟实现的 shared_ptr 改造为引用计数线程安全版本,需要注意的是:
下面是线程安全版本的 shared_ptr 的模拟实现:
template<class T>
class shared_ptr
{
public:
//RAII
//引用计数指向堆上的一块空间
shared_ptr(T* ptr = nullptr)
: _ptr(ptr), _pcount(new int(1)), _pmtx(new mutex)
{}
~shared_ptr()
{
//引用计数为0才进行析构
//使用互斥锁来保证引用计数只能被线程串行访问
_pmtx->lock();
(*_pcount)--;
_pmtx->unlock();
if (*_pcount == 0)
{
delete _ptr;
delete _pcount;
delete _pmtx;
cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
}
}
//拷贝构造 -- 共享资源(++引用计数)
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr), _pcount(sp._pcount), _pmtx(sp._pmtx)
{
_pmtx->lock();
(*_pcount)++;
_pmtx->unlock();
}
//赋值重载 -- 先释放自身资源(),再共享资源
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//判断自我赋值 -- 判断资源地址是否相同,而不是对象地址
if (_ptr != sp._ptr)
{
//调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
this->~shared_ptr();
_ptr = sp._ptr;
_pcount = sp._pcount;
_pmtx->lock();
(*_pcount)++;
_pmtx->unlock();
}
return *this;
}
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
int use_count() { return *_pcount; }
T* get() const { return _ptr; }
private:
T* _ptr;
int* _pcount; //引用计数
mutex* _pmtx; //互斥锁
};
需要注意的是,shared_ptr 的引用计数是安全的,因为有互斥锁的包含,但是 shared_ptr 的数据资源是不安全的,因为对堆上的数据资源的访问是人处理的,shared_ptr 无法对其进行保护,如下:
struct Date {
int year = 0;
int month = 0;
int day = 0;
};
//测试shared_ptr的线程安全问题
void shared_ptr_test2()
{
thj::shared_ptr<Date> sp = new Date;
int N = 5000;
thread t1([&]()
{
for (int i = 0; i < N; i++)
{
sp->year++;
sp->month++;
sp->day++;
}
});
thread t2([&]()
{
for (int i = 0; i < N; i++)
{
sp->year++;
sp->month++;
sp->day++;
}
});
t1.join();
t2.join();
cout << sp->year << endl;
cout << sp->month << endl;
cout << sp->day << endl;
}
大家可以用 std 的 shared_ptr 进行测试,结果也是错误的;所以,对于数据资源的安全我们需要自己手动加锁对其进行保护,如下:
void shared_ptr_test2()
{
mutex mtx;
thj::shared_ptr<Date> sp = new Date;
int N = 5000;
thread t1([&]()
{
for (int i = 0; i < N; i++)
{
//加锁,只运行线程串行访问公共数据,访问完毕后再解锁
mtx.lock();
sp->year++;
sp->month++;
sp->day++;
mtx.unlock();
}
});
thread t2([&]()
{
for (int i = 0; i < N; i++)
{
mtx.lock();
sp->year++;
sp->month++;
sp->day++;
mtx.unlock();
}
});
t1.join();
t2.join();
cout << sp->year << endl;
cout << sp->month << endl;
cout << sp->day << endl;
}
shared_ptr 在绝大多数情况下都可以说表现非常完美,但是它在一些特殊场景下还是存在缺陷,如下:
在没有智能指针时对于 new 出来的节点我们需要手动 delete,但是有了智能指针后,我们就可以节点资源交给智能指针对象来管理:
但是我们发现,当我们让 n1 的 next 指向 n2,n2 的 prev 指向 n1 后,程序发生了内存泄露:
这是因为在当前场景下发生了 shared_ptr 的循环引用 (假设将 n1 管理的资源称为资源1,将 n2 管理的资源称为资源2):
注:上面示例中,资源其实就是 ListNode 类型的一个节点,而节点 (资源) 释放,节点里面的变量 _prev 和 _next 才会释放;而要让节点释放,节点的引用计数必须先减为0,所以就出现了下面这种情况:
所以,节点1和节点2就会相互等待对方释放,从而满足自身释放的条件,这就是传说中的循环引用 (这里和死锁有点类型,大家可以和死锁发生的四个条件对比一下)。
为了弥补 shared_ptr 的缺陷,即解决 shared_ptr 存在的循环引用问题,C++ 设计出了 weak_ptr。
weak_ptr 是为了解决 shared_ptr 循环引用问题而专门设计出来的一款智能指针,weak_ptr 解决循环引用的方式很简单 – 不增加资源的引用计数;所以它需要程序员自己在合适的地方来使用它。
weak_ptr 的简单模拟实现如下:
template<class T>
class weak_ptr
{
public:
//RAII
weak_ptr(T* ptr = nullptr)
:_ptr(ptr)
{};
~weak_ptr() {} //析构函数不释放资源
//不增加引用计数
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp.get())
{
_ptr = sp.get();
}
return *this;
}
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
private:
T* _ptr;
};
前面我们都是一次申请一份资源,即 new int / new Date,所以我们析构时可以直接使用 delete;但如果是一次申请多份资源,比如 new int[10] / new vector<int>[10],此时我们释放时就需要使用 delete[] 了,否则程序就会崩溃:
C++ 中通过定制删除器来解决 delete 和 delete[] 的问题,定制删除器本质上是一个仿函数/函数对象,它的思想和我们之前学习的 Less/Great/HashFunc 等仿函数的思想是一样的。
C++ 标准库中定义的 shared_ptr 允许我们将函数对象作为构造函数的参数进行传递,这是因为 shared_ptr 必须通过引用计数的方式来管理所指向的资源,对于一个 shared_ptr
对象来说,它所管理的资源是由其内部包含的指针 (ptr && pcount && pmutex
) 和对应的删除器共同负责管理的,当最后一个 shared_ptr
对象被销毁时,就会调用删除器来释放所指向的内存。所以 shared_ptr
底层实现中是有一个类来专门管理引用计数和删除器的。
shared_ptr 的这种将删除器作为构造函数参数进行传递的方式让我们可以搭配 lambda 表达式进行使用,非常方便:
但是对于其他不需要引用计数的智能指针来说,就只能通过模板参数来传递仿函数进行定制删除了,只是模板参数只能传递类型,而不能传递函数对象,所以就无法配合 lambda 表达式或者是包装器对象进行使用。
当然,我们也可以对我们模拟实现的 shared_ptr 进行改造,不过为了简单起见,这里我们就将其改造为支持通过模板参数来传递仿函数进行定制删除的版本,而不再实现支持通过构造函数传递函数对象进行定制删除的版本了。如下:
//默认使用delete进行释放
template<class T>
struct default_delete {
void operator()(T* ptr)
{
delete ptr;
}
};
template<class T, class D = default_delete<T>>
class shared_ptr
{
public:
//RAII
//引用计数指向堆上的一块空间
shared_ptr(T* ptr = nullptr)
: _ptr(ptr), _pcount(new int(1)), _pmtx(new mutex)
{}
~shared_ptr()
{
//引用计数为0才进行析构
//使用互斥锁来保证引用计数只能被线程串行访问
_pmtx->lock();
(*_pcount)--;
_pmtx->unlock();
if (*_pcount == 0)
{
//delete _ptr;
//delete _pcount;
D del;
del(_ptr);
delete _pcount;
delete _pmtx;
cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
}
}
//拷贝构造 -- 共享资源(++引用计数)
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr), _pcount(sp._pcount), _pmtx(sp._pmtx)
{
_pmtx->lock();
(*_pcount)++;
_pmtx->unlock();
}
//赋值重载 -- 先释放自身资源(),再共享资源
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//判断自我赋值 -- 判断资源地址是否相同,而不是对象地址
if (_ptr != sp._ptr)
{
//调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
this->~shared_ptr();
_ptr = sp._ptr;
_pcount = sp._pcount;
_pmtx->lock();
(*_pcount)++;
_pmtx->unlock();
}
return *this;
}
//支持指针的各种行为
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T operator[](size_t pos) { return _ptr[pos]; }
int use_count() { return *_pcount; }
T* get() const { return _ptr; }
private:
T* _ptr;
int* _pcount; //引用计数
mutex* _pmtx; //互斥锁
};