1. 为什么需要智能指针?
现在有一个抛出异常的场景:
#include <iostream> using namespace std; double func2(int a, int b) { // 申请内存 int* array = new int[1024 * 1024 * 1024]; // 1G if (b == 0) { throw "除数不能为0"; } else { return a / b; } } double func1() { try { return func2(10, 0); } catch (int) { cout << "catch (int)" << endl; } catch (string) { cout << "catch (string)" << endl; } } int main() { try { cout << func1() << endl; } catch (const char* str) // 严格匹配,根据throw的类型,拷贝构造或移动构造catch的参数类型 { cout << str << endl; } return 0; }
前面关于C++ 异常我们讲到过,由于throw会逐步释放当前栈帧返回上一个函数栈帧,直到找到类型严格匹配的catch,所以当该栈帧空间结束后,栈帧中的对象的资源需要释放。
特别是动态开辟的空间,需要手动去释放的资源。
对于当前案例,因为确定会抛出异常,且直接回到上一层(没有找到继续返回,直到被捕获,如果直到main()函数中都没有被捕获到便终止程序),会跳过当前throw的函数栈帧后面的语句,尽管后面有对资源的主动释放。
这样便会造成内存泄漏。
2. 内存泄漏
2.1 什么是内存泄漏,内存泄漏的危害
2.2 内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
1. 堆内存泄
描述:
堆malloc
、calloc
、realloc
或者new
等函数从堆中分配的内存。使用完后,必须通过调用相应的free
或delete
函数来释放。如果程序设计有误,导致这些内存未被释放,那么这部分内存将无法再被使用,形成堆内存泄漏(Heap Leak)。2. 系统资源泄漏
描述:
系统资源泄漏是指程序在使用系统分配的资源(如套接字、文件描述符、管道等)时,未能正确释放这些资源。未释放的系统资源会导致系统资源浪费,进而可能引起系统性能下降或不稳定。
2.3 如何检测内存泄漏
在linux下内存泄漏检测:
工具 | 描述 |
---|---|
valgrind | 一个强大的开源程序检测工具 |
mtrace | GNU 扩展,用来跟踪 malloc, mtrace 为内存分配函数(如 malloc , realloc , memalign , free )安装 hook 函数 |
dmalloc | 用于检查 C/C++ 内存泄漏的工具。检测程序运行结束时是否存在未释放的内存,以一个运行库V的方式发布。 |
memwatch | 和 dmalloc 一样,memwatch 也能检测未释放的内存,并记录内存的分配和释放情况,用于发现潜在的内存泄漏问题。 |
mpatrol | 一个跨平台的 C/C++ 内存泄漏检测器。 |
dbgmem | 可以与程序一起运行,并在程序结束时报告内存的使用情况。 |
Electric Fence | 通过引发段错误的方式,帮助开发者在调试时定位内存分配错误和内存泄漏问题。 |
在windows下使用第三方工具:
项目主页:Visual Leak Detector | Enhanced Memory Leak Detection for Visual C++
下载:https://github.com/KindDragon/vld/releases/download/v2.5.1/vld-2.5.1-setup.exe
其他工具:内存泄漏工具比较
2.4 如何避免内存泄漏
1. 规范用法:
使用智能指针:
- C++中使用智能指针如
std::unique_ptr
和std::shared_ptr
来管理动态分配的内存。智能指针会在超出作用域时自动释放内存,避免忘记调用delete
造成的内存泄漏。遵循RAII原则:
- 使用RAII技术,将资源管理交由对象的生命周期来控制。在对象的构造函数中分配资源,在析构函数中释放资源,确保资源在对象生命周期结束时自动释放。
确保所有
new
对应delete
:
- 每个
new
或malloc
调用都应该有相应的delete
或free
。对每个分配的内存都应有明确的释放策略。避免循环引用:
- 在使用智能指针时,注意避免循环引用问题。例如,
std::shared_ptr
之间如果相互引用,将导致内存无法自动释放。可以使用std::weak_ptr
解决此问题。定期检查与测试:
- 使用工具如 Valgrind、ASan(AddressSanitizer)等对程序进行内存泄漏检查,及时发现和修复潜在的内存泄漏问题。
2. 最后防线:
使用内存泄漏检测工具:
- 定期使用内存泄漏检测工具,如 Valgrind,来扫描程序中可能存在的内存泄漏。对于复杂项目,持续集成环境中可以集成这些工具,以便及时发现和解决内存管理问题。
3. 智能指针的使用及原理
3.1 RAII
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种通过对象生命周期来管理程序资源(如内存、文件句柄、网络连接、互斥量等)的技术。其核心思想是在对象构造时获取资源,并在对象析构时自动释放资源。
RAII的两大优势:
不需要显式释放资源:
- 通过将资源的管理交由对象的生命周期处理,避免了手动释放资源的繁琐和错误,降低了内存泄漏或资源泄漏的风险。
资源在对象生命期内始终有效:
- 在对象的整个生命周期中,资源始终保持有效,确保资源在使用过程中处于一个稳定和可用的状态。
通过RAII,程序设计更加简洁、可靠,减少了资源管理的复杂性。
以下是使用RAII思想设计的
SmartPtr
类,并在MergeSort
函数中使用该智能指针类的完整代码:#include <iostream> #include <vector> #include <exception> using namespace std; template<class T> class SmartPtr { public: // 构造函数,接受一个原始指针 SmartPtr(T* ptr = nullptr) : _ptr(ptr) {} // 析构函数,在对象销毁时自动释放内存 ~SmartPtr() { if (_ptr) { delete _ptr; } } private: T* _ptr; // 原始指针 }; void MergeSort(int* a, int n) { // 动态分配内存,并使用SmartPtr管理 int* tmp = (int*)malloc(sizeof(int) * n); // 讲tmp指针委托给了sp对象 SmartPtr<int> sp(tmp); // 假设这里有排序相关逻辑,比如 _MergeSort(a, 0, n - 1, tmp); // 这里假设处理了一些其他逻辑 vector<int> v(1000000000, 10); // ... } int main() { try { int a[5] = { 4, 5, 2, 3, 1 }; MergeSort(a, 5); } catch (const exception& e) { cout << e.what() << endl; } return 0; }
3.2 智能指针的原理
在上述SmartPtr
类基础上,通过重载 *
和 ->
操作符,实现了智能指针的基本功能,使得它的行为更像原生指针:
template<class T>
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;
};
struct Date
{
int _year;
int _month;
int _day;
};
int main()
{
SmartPtr<int> sp1(new int);
*sp1 = 10;
cout << *sp1 << endl;
SmartPtr<Date> sparray(new Date);
sparray->_year = 2018;
sparray->_month = 1;
sparray->_day = 1;
}
总结一下智能指针的原理: 1. RAII特性 2. 重载operator*和opertaor->,具有像指针一样的行为。
3.3 auto_ptr
资源管理权转移
C++98版本的库中就提供了auto_ptr的智能指针。下面演示的auto_ptr的使用及问题。
// C++库中的智能指针都定义在memory这个头文件中
#include <memory>
class Date
{
public:
Date() { cout << "Date()" << endl;}
~Date() { cout << "~Date()" << endl;}
int _year;
int _month;
int _day;
};
int main()
{
auto_ptr<Date> ap(new Date);
auto_ptr<Date> copy(ap);
// auto_ptr的问题:当对象拷贝或者赋值后,前面的对象就悬空了
// C++98中设计的auto_ptr问题是非常明显的,所以实际中很多公司明确规定了不能使用auto_ptr
ap->_year = 2018;
return 0;
}
auto_ptr的实现原理:管理权转移的思想,下面简化模拟实现了一份AutoPtr来了解它的原理
// 模拟实现一份简答的AutoPtr,了解原理
template<class T>
class AutoPtr
{
public:
AutoPtr(T* ptr = NULL)
: _ptr(ptr)
{}
~AutoPtr()
{
if(_ptr)
delete _ptr;
}
// 一旦发生拷贝,就将ap中资源转移到当前对象中,然后另ap与其所管理资源断开联系,
// 这样就解决了一块空间被多个对象使用而造成程序奔溃问题
AutoPtr(AutoPtr<T>& ap)
: _ptr(ap._ptr)
{
ap._ptr = NULL;
}
AutoPtr<T>& operator=(AutoPtr<T>& ap)
{
// 检测是否为自己给自己赋值
if(this != &ap)
{
// 释放当前对象中资源
if(_ptr)
delete _ptr;
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
private:
T* _ptr;
};
int main()
{
AutoPtr<Date> ap(new Date);
// 现在再从实现原理层来分析会发现,这里拷贝后把ap对象的指针赋空了,导致ap对象悬空
// 通过ap对象访问资源时就会出现问题。
AutoPtr<Date> copy(ap);
ap->_year = 2018;
return 0;
}
3.4 unique_ptr
C++11中开始提供更靠谱的unique_ptr
int main()
{
unique_ptr<Date> up(new Date);
// unique_ptr的设计思路非常的粗暴-防拷贝,也就是不让拷贝和赋值。
unique_ptr<Date> copy(ap);
return 0;
}
unique_ptr的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份UniquePtr来了解它的原理
template<class T>
class UniquePtr
{
public:
// 构造函数,接收一个原生指针,如果没有提供则默认为 nullptr
UniquePtr(T * ptr = nullptr)
: _ptr(ptr)
{}
// 析构函数,删除指针所指向的对象
~UniquePtr()
{
if(_ptr)
delete _ptr;
}
// 重载解引用操作符,返回指向的对象引用
T& operator*() {return *_ptr;}
// 重载箭头操作符,返回指针
T* operator->() {return _ptr;}
private:
// C++98 防拷贝:将拷贝构造函数和拷贝赋值操作符声明为私有且不实现
UniquePtr(UniquePtr<T> const &);
UniquePtr & operator=(UniquePtr<T> const &);
// C++11 防拷贝:使用 delete 关键字禁用拷贝构造函数和拷贝赋值操作符
UniquePtr(UniquePtr<T> const &) = delete;
UniquePtr & operator=(UniquePtr<T> const &) = delete;
private:
T * _ptr; // 原生指针
};
3.5 shared_ptr
C++11中开始提供更靠谱的并且支持拷贝的shared_ptr
int main()
{
// shared_ptr通过引用计数支持智能指针对象的拷贝
shared_ptr<Date> sp(new Date);
shared_ptr<Date> copy(sp);
cout << "ref count:" << sp.use_count() << endl;
cout << "ref count:" << copy.use_count() << endl;
return 0;
}
shared_ptr
的原理:
shared_ptr
是通过引用计数的方式来实现多个shared_ptr
对象之间共享资源。以下是其工作原理的详细说明:
- 引用计数:
shared_ptr
在其内部为每个资源维护一份计数,用来记录该资源被多少个对象共享。- 引用计数减少:在对象被销毁时(也就是析构函数调用),表明自己不再使用该资源,对象的引用计数减一。
- 释放资源:如果引用计数为 0,就说明当前对象是最后一个使用该资源的对象,此时必须释放该资源。
- 引用计数不为 0:如果引用计数不是 0,就说明除了自己还有其他对象在使用该资源,不能释放该资源,否则其他对象就会成为野指针。
#include <thread>
#include <mutex>
#include <iostream>
template <class T>
class SharedPtr
{
public:
SharedPtr(T* ptr = nullptr)
: _ptr(ptr)
, _pRefCount(new int(1))
, _pMutex(new std::mutex)
{}
~SharedPtr() { Release(); }
SharedPtr(const SharedPtr<T>& sp)
: _ptr(sp._ptr)
, _pRefCount(sp._pRefCount)
, _pMutex(sp._pMutex)
{
AddRefCount();
}
// sp1 = sp2
SharedPtr<T>& operator=(const SharedPtr<T>& sp)
{
if (_ptr != sp._ptr)
{
// 释放管理的旧资源
Release();
// 共享管理新对象的资源,并增加引用计数
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
_pMutex = sp._pMutex;
AddRefCount();
}
return *this;
}
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
int UseCount() { return *_pRefCount; }
T* Get() { return _ptr; }
void AddRefCount()
{
// 加锁或者使用加1的原子操作
_pMutex->lock();
++(*_pRefCount);
_pMutex->unlock();
}
private:
void Release()
{
bool deleteflag = false;
// 引用计数减1,如果减到0,则释放资源
_pMutex->lock();
if (--(*_pRefCount) == 0)
{
delete _ptr;
delete _pRefCount;
deleteflag = true;
}
_pMutex->unlock();
if (deleteflag == true)
delete _pMutex;
}
private:
int* _pRefCount; // 引用计数
T* _ptr; // 指向管理资源的指针
std::mutex* _pMutex; // 互斥锁
};
int main()
{
SharedPtr<int> sp1(new int(10));
SharedPtr<int> sp2(sp1);
*sp2 = 20;
std::cout << sp1.UseCount() << std::endl;
std::cout << sp2.UseCount() << std::endl;
SharedPtr<int> sp3(new int(10));
sp2 = sp3;
std::cout << sp1.UseCount() << std::endl;
std::cout << sp2.UseCount() << std::endl;
std::cout << sp3.UseCount() << std::endl;
sp1 = sp3;
std::cout << sp1.UseCount() << std::endl;
std::cout << sp2.UseCount() << std::endl;
std::cout << sp3.UseCount() << std::endl;
return 0;
}
std::shared_ptr的线程安全问题
通过下面的程序我们来测试shared_ptr的线程安全问题。需要注意的是shared_ptr的线程安全分为两方面:
引用计数的线程安全:
shared_ptr
的引用计数在多个智能指针对象之间共享,两个线程同时对引用计数进行增减操作可能导致计数错乱,因此引用计数的操作需要加锁或使用原子操作来保证线程安全。资源访问的线程安全:智能指针管理的对象存放在堆上,两个线程同时访问这些对象会导致线程安全问题。
// 1.演示引用计数线程安全问题,就把AddRefCount和SubRefCount中的锁去掉 // 2.演示可能不出现线程安全问题,因为线程安全问题是偶现性问题,main函数的n改大一些概率就变大 // 了,就容易出现了。 // 3.下面代码我们使用SharedPtr演示,是为了方便演示引用计数的线程安全问题,将代码中的SharedPtr // 换成shared_ptr进行测试,可以验证库的shared_ptr,发现结论是一样的。 void SharePtrFunc(SharedPtr<Date>& sp, size_t n) { cout << sp.Get() << endl; for (size_t i = 0; i < n; ++i) { // 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。 SharedPtr<Date> copy(sp); // 这里智能指针访问管理的资源,不是线程安全的。所以我们看看这些值两个线程++了2n次,但 是最终看到的结果,并一定是加了2n copy->_year++; copy->_month++; copy->_day++; } } int main() { SharedPtr<Date> p(new Date); cout << p.Get() << endl; const size_t n = 100; thread t1(SharePtrFunc, p, n); thread t2(SharePtrFunc, p, n); t1.join(); t2.join(); cout << p->_year << endl; cout << p->_month << endl; cout << p->_day << endl; return 0; }
循环引用分析:
智能指针和引用计数:
node1
和node2
是两个智能指针对象,最初它们的引用计数为 1,这意味着不需要手动delete
来释放它们的内存。指针设置:
node1
的_next
成员指向node2
,而node2
的_prev
成员指向node1
。此时,它们的引用计数变为 2,因为每个节点都有一个智能指针引用它。析构时的引用计数变化:当
node1
和node2
被析构时,它们的引用计数减少到 1。此时_next
还指向下一个节点,但_prev
还指向上一个节点。析构影响:如果
_next
析构了,node2
会被释放;如果_prev
析构了,node1
会被释放。循环引用问题:由于
_next
是node1
的成员,而_prev
是node2
的成员,它们之间形成了循环引用。这种循环引用会导致这两个节点无法被释放,因为它们互相持有对方的引用。循环引用的后果:在循环引用的情况下,两个节点由于互相持有对方的引用,它们的引用计数永远不会变为 0,因此它们的内存无法被释放。这就是循环引用的问题。
为了解决这种循环引用问题,可以使用
std::weak_ptr
来打破循环引用。std::weak_ptr
是一种不增加引用计数的智能指针,用于观察但不控制对象的生命周期。这样可以防止循环引用导致的内存泄漏。
// 解决方案:在引用计数的场景下,把节点中的_prev和_next改成weak_ptr就可以了
// 原理就是,node1->_next = node2;和node2->_prev = node1;时weak_ptr的_next和_prev不会增加
// node1和node2的引用计数。
struct ListNode
{
int _data;
weak_ptr<ListNode> _prev;
weak_ptr<ListNode> _next;
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
3.6 weak_ptr
支持管理计数,不支持RAII且不能访问资源。
weak_ptr& operator= (const weak_ptr& x) noexcept;template <class U> weak_ptr& operator= (const weak_ptr<U>& x) noexcept;
template <class U> weak_ptr& operator= (const shared_ptr<U>& x) noexcept;
C++14多出可变模版参数的版本:
weak_ptr& operator= (weak_ptr&& x) noexcept;template <class U> weak_ptr& operator= (weak_ptr<U>&& x) noexcept;
std::weak_ptr::operator=
weak_ptr 赋值
对象成为 x 的拥有组的一部分,从而在其过期之前可以访问该对象的资源,但自身不获取所有权(并且不会增加其引用计数)。如果 x 为空,则构造的 weak_ptr 也为空。
如果 x 是别名,则 weak_ptr 会保留被拥有的数据和存储的指针。
shared_ptr 对象可以直接赋值给 weak_ptr 对象,但若要将 weak_ptr 对象赋值给 shared_ptr,则必须通过成员函数 lock 来完成。
std::weak_ptr<ListNode> wp;
{
std::shared_ptr<ListNode> sp(new ListNode);
wp = sp;
cout << sp.use_count() << endl;
cout << wp.use_count() << endl;
cout << wp.expired() << endl;
}
cout << wp.use_count() << endl;
cout << wp.expired() << endl;
同时指向统一资源,资源先被释放了,而对象wp生命周期还没结束。
输出:
1
1
0
~ListNode()
0
1
std::weak_ptr
的 lock
方法在 C++ 中有一个非常重要的作用,它用于从一个 std::weak_ptr
安全地获取一个 std::shared_ptr
,从而访问指向的资源。
- 当
sp
在作用域内被创建时,它管理着ListNode
资源。如果你调用wp.lock()
,sp
继续管理这个资源,引用计数不变或增加。- 作用域结束后,
sp
的生命周期会结束,如果没有其他std::shared_ptr
持有该资源,资源会被释放。
删除器(仿函数实现)
如果不是new出来的对象如何通过智能指针管理呢?
其实shared_ptr设计了一个删除器来解决这个问题
#include <iostream>
#include <memory>
#include <cstdlib> // For malloc and free
using namespace std;
// 自定义删除器 - 使用 free 函数释放内存
template<class T>
struct FreeFunc
{
void operator()(T* ptr)
{
cout << "free:" << ptr << endl;
free(ptr);
}
};
// 自定义删除器 - 使用 delete[] 释放内存
template<class T>
struct DeleteArrayFunc
{
void operator()(T* ptr)
{
cout << "delete[]" << ptr << endl;
delete[] ptr;
}
};
int main()
{
// 使用 FreeFunc 作为删除器的 shared_ptr
FreeFunc<int> freeFunc;
shared_ptr<int> sp1((int*)malloc(4), freeFunc);
// 使用 DeleteArrayFunc 作为删除器的 shared_ptr
DeleteArrayFunc<int> deleteArrayFunc;
shared_ptr<int> sp2((int*)malloc(4), deleteArrayFunc);
return 0;
}
由循环引用引出weak_ptr,用另一个类保管对象的内容,但需要自己明确指定对象中的成员类型是否需要用weak_ptr管理(将资源中的指针套起来)
C++11和boost中智能指针的关系
1. C++ 98 中产生了第一个智能指针 auto_ptr
2. C++ boost给出了更实用的 scoped_ptr 和 shared_ptr 和 weak_ptr
3. C++ TR1,引入了 shared_ptr 等。不过注意的是TR1并不是标准版
4. C++ 11,引入了 unique_ptr 和 shared_ptr 和 weak_ptr(需要注意的是unique_ptr对应boost的 scoped_ptr)
并且这些智能指针的实现原理是参考boost中的实现的。
RAII扩展学习
RAII思想除了可以用来设计智能指针,还可以用来设计守卫锁,防止异常安全导致的死锁问题。
#include <thread>
#include <mutex>
// C++11的库中也有一个lock_guard,下面的LockGuard造轮子其实就是为了学习他的原理
template<class Mutex>
class LockGuard
{
public:
LockGuard(Mutex& mtx)
:_mutex(mtx)
{
_mutex.lock();
}
~LockGuard()
{
_mutex.unlock();
}
LockGuard(const LockGuard<Mutex>&) = delete;
private:
// 注意这里必须使用引用,否则锁的就不是一个互斥量对象
Mutex& _mutex;
};
mutex mtx;
static int n = 0;
void Func()
{
for (size_t i = 0; i < 1000000; ++i)
{
LockGuard<mutex> lock(mtx);
++n;
}
}
int main()
{
int begin = clock();
thread t1(Func);
thread t2(Func);
t1.join();
t2.join();
int end = clock();
cout << n << endl;
cout << "cost time:" << end - begin << endl;
return 0;
}
今天分享就到这里,下期再见~