一、内存泄漏是什么

内存泄漏,是指在程序代码中动态申请的、上的内存 由于某种原因、在使用后没有被释放,进而造成内存的浪费。

少部分的内存泄漏不会影响程序的正常运行,不过如果是持续的内存泄漏会耗光系统内存,最终会导致程序卡死甚至系统崩溃。为了避免系统崩溃,在无法申请到内存的时候,要果断调用exit()函数主动杀死进程,而不是试图挽救这个进程。

二、如何察觉到内存泄漏

如果程序在正常地使用过程中,占用的内存随着时间推移不断增长,一般就说明存在内存泄漏的情况。也可以使用专门的工具来检测程序中的内存泄漏:

在vc++中可以使用 VLD(Visual LeakDetector) 进行检测,VLD 是一个免费开源的工具,只需要包含头文件即可,并且可以获取到内存泄漏的代码文件行号。

Tencent tMem Monitor是腾讯推出的一款运行时C/C++内存泄漏检测工具。TMM认为在进程退出时,堆内存中没有被释放且没有指针指向的无主内存块即为内存泄漏,并进而引入垃圾回收(GC, Garbage Collection)机制,在进程退出时检测出堆内存中所有没有被引用的内存单元,因而内存泄露检测准确率为100%。

gperftools 是 google 开源的一组套件,提供了高性能的、支持多线程的 malloc 实现,以及一组优秀的性能分析工具。gperftools 的 heap chacker 组件可以用于检测 C++ 程序中的内存泄露问题,它可以在不重启程序的情况下,进行内存泄露检查。

三、内存泄漏是如何产生的

最简单的解释就是,主动申请的内存块在使用后没有被释放。最常见的几种造成内存泄漏的原因有:

1. malloc/new申请的内存没有主动释放

使用 malloc 申请的内存要主动调用 free,new 申请的内存要主动调用 delete,否则就会导致内存泄漏。例如下面代码中的内存 ptr 在申请后没有被释放就造成了内存泄漏。

int main(){
	void* ptr = malloc(1);
	// use ptr ...
	return 0;
}

2. 使用free释放new申请的内存

malloc/free以及new/delete必须是各自成对出现,如果混用,就会导致意想不到的情况出现。另外,如果用delete释放void指针指向的对象同样也会造成内存泄露。

3. 使用delete去删除数组

使用 new 申请的数组,释放的时候要用 delete[] 删除,如果错误地使用 delete 删除,就会造成内存泄漏。

int main(){
	int* ptr = new int[2];
	// usr ptr ...
	// delete ptr; // 错误!释放数组要用 delete[]
	delete[] ptr; // 正确!
	return 0;
}

4. 基类的析构函数没有定义为虚函数

当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。例如下面代码中析构函数 ~A() 不是virtual,在调用 delete pa 的时候就不会调用子类 B 的析构函数,该对象中子类 B 中的内存无法被释放干净。

class A
{
public:
	A(){}
	~A(){}
}

class B : public A 
{
public:
	B(){}
	~B(){}
private:
	int num;
}

void main(){
	A* pa = new B();
	delete pa;
}

四、该如何避免内存泄漏

内存泄漏会导致程序不稳定,如果是在一个非常复杂的项目中去排查一处内存泄漏的地方,也是非常让人头疼的一件事情,与其研究如何更好地解决问题,不如研究如何避免问题的发生。

在写代码的时候多花些时间保证代码的质量,往往是一种更为高效的方式。前期如果为了赶进度,匆匆写下的代码,在后期用则需要更多的时间去填坑,这就是“欲速则不达”。

为了避免内存泄漏的情况,有几种方法可以尝试。

1. 谨慎使用动态内存

在编写代码的时候,对动态内存保持警惕,保证每一块儿申请的内存都要得到释放。特别是在每个 return 之前,要想一想是否还有内存没有被释放,如果这里不释放,在其他地方是否会正常释放。

这是一种靠脑袋的方式,需要编写代码的时候时刻保持敏感,但是脑袋往往是不可靠的。最好选用其他的方式来保障。

2. 使用RAII

RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),通过对象的初始化实现资源获取,通过对象的销毁实现资源的释放,我们所说的资源就是动态内存。RAII要求,资源的有效期与持有资源的对象的生命周期严格绑定,通过构造函数获取资源,通过析构函数释放资源,这样就有效地避免了资源泄漏。

例如下面的例子中,通过 MemBlock 类的构造函数分配内存,通过析构函数释放内存,在需要使用动态内存的地方只需要定义一个 MemBlock 对象 buff,通过成员函数 get() 获取内存地址,使用之后无需手动释放内存,在 buff 离开作用域的时候,buff 会被自动释放(调用析构函数),在析构函数中调用 free 释放 buff 所持有的内存。如果在所有需要使用内存的地方都用这种方法,只要保证 MemBlock 对象能被析构,就不会造成内存泄漏。

class MemBlock
{
public:
	MemBlock(size_t size) 
	{
		_ptr = malloc(size);
	}
	~MemBlock()
	{
		free(_ptr);
	}
public:
	void* ptr() 
	{
		return _ptr;
	}
protected:
	void* _ptr;
};

void main(){
	MemBlock buff(1024);
	memset(buff.ptr(), 0x00, 1024);
	// 使用内存后无需主动释放
}

当然,如果通过 new 来申请 MemBlock 对象,就又会存在该对象没有被释放的风险,这个时候使用智能指针来存档 MemBlock 对象将会是一个好的选择。

3. 使用智能指针

就是为了解决动态内存的使用安全问题,C++ 才引入了智能指针的概念,智能指针除了具备普通内存的所有功能之外,还可以保证所指向的对象不再被被引用的时候,自动释放该对象。就这样 C++ 开发人员可以通过智能指针挪走头顶的达摩克里斯之剑。

标准库提供的两种智能指针 shared_ptr 和 unique_ptr,二者的区别在于管理底层指针的方法不同,shared_ptr允许多个指针指向同一个对象,内部通过引用计数知道对象被几个指针引用,当引用为0的时候就是该对象将被释放的时候;unique_ptr 则“独占”所指向的对象,它不能被赋值,智能通过 std::move() 将引用转移到另一个 unique_ptr。标准库还定义了一种名为weak_ptr的伴随类,它是一种弱引用,作为观察者指向 shared_ptr 所管理的对象,不会改变对象的引用计数。这三种智能指针都定义在memory头文件中。 使用智能指针直接管理动态内存,在使用之后不需要手动释放,当这段内存不再被引用的时候,这段内存会被调用 free 函数来释放,free函数是作为自定义的释放函数传给智能指针的,如果是其他类型的对象,不需要传入释放函数,会默认调用类型的析构函数来释放。

shared_ptr<void> ptr(malloc(1024), free); // 传入指定的释放函数
//auto ptr = make_shared<void>(malloc(1024), free); // 等价
memset(ptr.get(), 0x00, 1024);

使用智能指针结合RAII管理动态内存,通过 RAII 将内存的申请和释放进行封装,再使用智能指针管理封装后的类对象。这样实现对内存的自动管理,可以像使用 C# 或 Java 一样使用内存,无需担心内存的释放问题。结合上面定义的 MemBlock 类:

auto mem = make_shared<MemBlock>(1024);
memset(mem->ptr(), 0x00, 1024);
// 使用内存后无需主动释放

内存的申请释放通过MemBlock类的构造函数和析构函数实现,MemBlock 类的对象 mem 使用智能指针管理,不再使用内存的时候,mem 的引用计数变为0,自动被释放析构,同时 free 掉其拥有的内存。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐