指针(上篇)"/>
c++彻底消灭——内存泄漏、野指针(上篇)
文章目录
- [摘要]
- 1. 需求分析
- 场景1
- 场景2
- 2. 解决方案
- 3. 初次尝试
- 4. 遗留问题
- 5. 总结
[摘要]
c++被誉为最难学的编程语言,一方面是由于其功能过于强大、过于底层,导致语法灵活多变;另一方面是由于其内存管理极其复杂。其中,最主要的,被诟病最多的,就是其内存管理。
c++的内存结构中的使用频率超高的堆内存完全由程序员自己管理,这就导致c++这门语言对程序员的水平要求极高,一不小心就会导致内存泄漏,或者使用已释放的内存,进而导致程序输出与预期不符,甚至导致内存耗尽、程序崩溃等严重问题。c++程序界普遍遵守的“谁开辟,谁释放”也不总是那么有效,遇到非顺序执行程序,比如异常、跳转等,可能跳过释放步骤;另外某些共享资源,可能不是“我”开辟的,但是某些条件下,“我”需要释放。当这些情况遇上多线程,问题将变得更加复杂。
总之,c++程序员需要时刻与内存管理做斗争,劳心费力可能最后写出来的程序还是一堆bug,关键是程序员写程序没考虑到的地方,问题可能也比较难找。
本文主要目的就是建立一套自动内存管理方案,将c++程序员从繁重的内存管理工作中解放出来,在使用new和delete时再也不用担心内存释放和泄露的问题,妈妈再也不用担心我的内存管理问题啦!!!
1. 需求分析
在c++程序中,new是基本不存在什么问题的,发生问题一般是关于delete的。下面我们引入两个人物对话来阐明需求:Ethan大神(哈哈,就是小弟我啦,容许我自恋一下),小A(虚拟人物,不配有名字☺)。
场景1
小A:Ethan大神,你快来看看,我这段程序怎么崩溃了?
Ethan:让我看看你的代码。
小A写的代码:
int main()
{int a = 5;int* pA = &a;delete pA;pA = nullptr;return 0;
}
Ethan:你这个代码有问题啊,你的变量
a
是存于栈中,但是却试图用delete释放它,这当然错了,delete只能释放堆内存,所以导致崩溃啦。
小A:喔,我知道了,原来问题这么简单呀,可是,我现在还没办法用好指针,在写较为复杂的程序的时候,特别是类内保存的指针或者函数调用参数的指针,我怎么知道它是开辟在栈内存还是堆内存的呀,难保我不会用delete去释放它呀,有没有什么办法让我使用delete的时候自动帮我判断是否是堆指针,不是堆指针就直接跳过不释放啊。
好啦,需求1出来啦:
- 需求1:可以对任意指针使用delete,delete需要自动判断是否是堆内存。
场景2
小A:Ethan大神,我这又出问题了,麻烦你帮忙看看好不?
Ethan:什么问题?
小A:你看这段代码,它又崩溃了。
小A的代码:
int main()
{int* pA = new int;int* pA1 = pA;delete pA;pA = nullptr;delete pA1;pA1 = nullptr;return 0;
}
Ethan:不止代码被你整崩溃了,我都被你整崩溃了,你这里只用new申请一次堆内存,但是你却对这个地址使用了两次delete。第二次delete释放一个早已经释放的地址,当然不允许啦。
小A:原来如此,我是新手嘛,没法避免这样的情况呀,对了,Ethan大神,有没有什么方法能避免这种情况,让我不管delete多少次,程序都能智能识别这部分内存是否早已经释放,这样我就不用每次使用delete的时候都小心翼翼了。
OK,需求2也出来啦:
- 需求2:对指向同一对象的多个指针,可以delete多次,delete需要自动判断内存是否已经得到释放。
注意:delete操作符首先是调用对象的析构函数进行析构,再调用operator delete释放内存,而我们在不创建新的内存管理函数条件下,可以改进的只是operator delete,因此,多次delete会多次调用析构函数,析构函数中有相关操作的需要注意是否已经操作过一次。
2. 解决方案
- 针对需求1,网上有方案是通过判断指针地址值来判断一个指针指向的地址是堆内存还是栈内存,但是都会存在多种问题;既然c++使用new来申请堆内存,那么我们是不是可以在申请堆内存的时候做点手脚,表示这是堆内存。自然想到的就是在申请的内存头部增加一段头部信息,申明这是堆内存。
- 针对需求2,最常见的是采用引用计数,delete一次,引用计数减少一次,直到减少为0,则真正释放内存;同时,为了让引用计数正确无误,在指针赋值的时候需要增加引用计数。但是赋值运算符 “=“无法进行全局重载,只能在类内重载,但是类内重载不具有普适性,同时需要对每个类重载”=”,相当麻烦。因此我们需要摒弃使用"="来为指针赋值,创建新的指针赋值函数或重载其他运算符作为指针赋值运算符。
3. 初次尝试
按照上述方案,我们先放一版代码出来,代码中有详细注释:
#include <memory>
#include <exception>
#include <mutex>#define HEAP_SIGN_STR ("HeapYes") ///<堆内存标志
#define NUM_BYTE_HEAP_SIGN 8 ///<堆内存标志大小,为HEAP_SIGN_STR字符串长度+1static std::mutex memUseLock;void* operator new(size_t sz)
{//分配空间大小=对象大小sz+堆内存标志大小NUM_BYTE_HEAP_SIGN+引用计数区大小sizeof(int)void* p;while ((p = malloc(sz + NUM_BYTE_HEAP_SIGN + sizeof(int))) == 0){if (_callnewh(sz + NUM_BYTE_HEAP_SIGN + sizeof(int)) == 0){//分配失败,抛出异常static const std::bad_alloc nomem;_RAISE(nomem);}}//将堆内存标志拷贝到头部memcpy(p, HEAP_SIGN_STR, NUM_BYTE_HEAP_SIGN);p = (char*)p + NUM_BYTE_HEAP_SIGN;//初始化引用计数为1*((int*)p) = 1;//将指针指向对象数据区,并返回该指针,后续对象在该数据区构造存储p = (char*)p + sizeof(int);return p;
}void operator delete(void* p)
{int* pCount = (int*)((char*)p - sizeof(int)); //引用计数区指针char* pStr = (char*)pCount - NUM_BYTE_HEAP_SIGN;//堆内存标志区指针if (memcmp(pStr, HEAP_SIGN_STR, NUM_BYTE_HEAP_SIGN) != 0){//如果堆内存标志不为HEAP_SIGN_STR,证明指针不是堆指针return;}std::lock_guard<std::mutex> lock(memUseLock); //加锁防止重复释放内存if (--(*pCount) == 0){free((void*)pStr);return;}//!=0时,要么是已经释放过了;要么是还有引用,不应该释放;//两种情况都应该直接返回。return;
}//增加引用计数,指针复制时自动调用,对用户透明
inline void add_ref(void* p)
{int* pCount = (int*)((char*)p - sizeof(int)); //引用计数区指针char* pStr = (char*)pCount - NUM_BYTE_HEAP_SIGN;//堆内存标志区指针std::lock_guard<std::mutex> lock(memUseLock);if (memcmp(pStr, HEAP_SIGN_STR, NUM_BYTE_HEAP_SIGN) != 0){//如果堆内存标志不为HEAP_SIGN_STR,证明指针不是堆指针return;}++(*pCount);
}//指针复制方法1:函数法。
template<typename Tx, typename Ty>
inline void ptr_copy(Tx*& pDst, Ty* pSrc)
{pDst = pSrc;add_ref((void*)pSrc);
}//指针复制方法2:重载操作符
class PtrBase
{
public:virtual ~PtrBase() {}PtrBase() {}PtrBase(const PtrBase&) = delete;PtrBase& operator = (const PtrBase&) = delete;
};template<typename T>
class PtrWrapper :public PtrBase
{
public:PtrWrapper(T* p){m_p = p;}T* Get(){return m_p;}~PtrWrapper(){delete m_p;}
private:T* m_p;
};class Ptr :public PtrBase
{
public:template<typename T>Ptr(T*& p){m_ptr = new PtrWrapper<T>(p);add_ref(p); //左值传递需要增加引用,因为PtrWrapper要保存指针拷贝。}template<typename T>Ptr(T*&& p){//右值传递不需要增加引用,因为PtrWrapper保存的就是原始指针m_ptr = new PtrWrapper<T>(p); }template<typename T>void operator &= (T*& pDst){try{pDst = (dynamic_cast<PtrWrapper<T>*>(m_ptr))->Get();add_ref(pDst);MemoryManager::GetInstance()mit(pDst);}catch (const std::bad_cast & e){throw e;}}~Ptr(){delete m_ptr;}
private:PtrBase* m_ptr;
};int main()
{int* a = new int; //只new一次,后面delete了两次int* b = nullptr;int* c = nullptr;Ptr(a) &= b; //重载运算符法复制指针,重载&=表示a并且b相等,指向相同位置。//ptr_copy(b, a); //函数法复制指针,与上面的Ptr(a) &= b二选一Ptr(new int) &= c;delete a; //delete堆内存。a = nullptr;delete b; //再次delete相同的堆内存,不会出现问题。b = nullptr;delete c;c = nullptr;return 0;
}
4. 遗留问题
下面我们继续分析上述代码还存在的问题,以及尚未解决的问题。
-
假设用户只使用了new,忘记使用delete。上述代码依然没有解决内存泄漏的问题。
-
我们看下面的代码:
int func(int* p) {delete p;p = nullptr; }int main() {int* a = new int;func(a);return 0; }
在上面的代码中,
a
是堆指针,将其作为参数传给func
,由于指针传参没有经过copy_ptr
或者Ptr
的&=
,导致引用计数不变,但是在函数中不小心错误使用了delete
,这就导致内存被释放,a
成为野指针,而用户的本意可能是想要在后续代码中,继续使用指针a
,这个时候bug就出现了。
因此,若直接使用上述内存管理方案,需要注意以下几点:
- 用
copy_ptr
或者Ptr
的&=
赋值的指针,用完一定要delete;否则,不要使用delete;因此,建议除函数指针参数外,其他地方的指针拷贝一律使用copy_ptr
或者Ptr
的&=
,超出作用域即delete。 - 用new表达式得到的指针,虽然直接使用的"="赋值(如上面的
int* a = new int;
),不用时也需要使用delete释放。 - 建议在遵守前3条的基础上,所有指针超出作用域之前使用delete释放,然后将指针置为
nullptr
;
5. 总结
本篇最后放出的代码初步解决了本篇开始提出的两个需求,但是还没有完全解决c++的内存问题。
关于c++内存管理更深入的问题,我抽空写下一篇文章继续和大家探讨。。
更多推荐
c++彻底消灭——内存泄漏、野指针(上篇)
发布评论