C++内存管理阅读笔记

      前几天微博名人陈立人分享了一篇c++内存管理的博文,原文在此处,今天拜读了一下学到了新的东西也复习了好多旧知识,上面有些东西是《高质量C++/C编程》上的。文章写的很好,做个简单笔记,谁让自己没有过目不忘的本事呢,今天在微博上看到有人在讨论程序员的写作能力,深表赞同,写作能帮助思考,这是我研究生阶段的收获,读了那么多paper但没有好好总结,到头来还得自己再翻一遍,要引以为戒,告诫自己写作不要拖。

1:C++内存管理详解

1.1内存分配方式:

C++中内存分为5个区

  • 堆:new分配
  • 自由存储区:malloc分配和堆十分相似,但用free来结束自己的生命。
  • 全局 /静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
  • 常量区

明确区分堆栈:

  • 1、管理方式不同:系统,程序员;
  • 2、空间大小不同:栈有限,堆较大;
  • 3、能否产生碎片不同:堆会出现碎片,栈不会;
  • 4、生长方向不同:栈向下,堆向上;
  • 5、分配方式不同:堆动态分配,没有静态,栈有两种静态和动态,静态分配是编译器完成的,例如局部变量的分配,栈的动态分配是由alloca函数进行分配,与堆的动态分配主要区别是栈动态分配由编译器负责释放,无需我们手工实现;
  • 6、分配效率不同,栈高,堆低,栈由计算机底层支持,有专门的寄存器和指令,堆是 C/C++库函数提供,需要按照算法来搜索空间;

1.2控制C++的内存分配方式。

一个防止堆破碎的通用方法是从不同固定大小的内存池中分配不同类型的对象。对每个类重载new和delete就提供了这样的控制。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

1.2.1重载全局的new和delete操作符

1.2.2为单个的类重载 new[ ]和delete[ ]

       必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。

C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。

1.2.3常见的内存错误及其对策

  • 1:内存分配未成功,却使用了它。
  • 2:内存分配虽然成功,但是尚未初始化就引用它。
  • 3:内存分配成功并且已经初始化,但操作越过了内存的边界。
  • 4:忘记了释放内存,造成内存泄露。
  • 5:释放了内存却继续使用它。

对策:

  • 【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
  • 【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
  • 【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。
  • 【规则4】动态内存的申请与释放必须配对,防止内存泄漏。
  • 【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

1.2.3malloc/free和new/delete同于不同

同:

都用来动态申请内存

不同:

1:malloc/free是库函数非操作符不在编译器控制范围内,new/delete是操作符编译器控制。

2:new/delete,调用构造析构函数。

联系:很多C++需要调用C程序,所以不能废除malloc/free;

1.2.4内存耗尽该怎么办

(1)判断指针是否为NULL,如果是则马上用return语句终止本函数

(2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行

(3)为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。最好用exit(1)将程序杀死,因为内存耗尽程序已经无药可就了。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。

杂项复习知识:

如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

资源的定义是:"任何在你的程序中获得并在此后释放的东西?quot;内存是一个相当明显的资源的例子。它需要用new来获得,用delete来释放。

对于32位以上的应用程序,“内存耗尽”错误处理程序毫无用处。

看一下linux下的多线程编程。

C++健壮性和资源管理

下面是资源封装的一个经典例子

写程序验证。

复习了深拷贝,浅拷贝,

C++内存回收这块讲的很好,摘抄如下

3:C++内存呢回收

3.1基本概念

三种内存对象

Type tt ,result ; //生成两个栈对象

tt = fun(tt); //函数返回时,生成的是一个临时对象object_copy2

上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

tt = object_copy2 ; //调用赋值运算符

看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于“大”对象最好用const引用传递代替按值进行函数参数传递了。

3.2 三种内存对象的比较

        栈对象优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。

接下来看看static对象

首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C。

其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择

接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

3.3 使用栈对象的意外收获

前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

3.4 禁止产生堆对象

上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

#include <stdlib.h> //需要用到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
	private:
	Resource* ptr ;//指向被封装的资源
	... ... //其它数据成员
	void* operator new(size_t size) //非严格实现,仅作示意之用
	{
		return malloc(size) ;
	}
	void operator delete(void* pp) //非严格实现,仅作示意之用
	{
		free(pp) ;
	}
	public:
	NoHashObject()
	{
		//此处可以获得需要封装的资源,并让ptr指针指向该资源
		ptr = new Resource() ;
	}

	~NoHashObject()

	{
		delete ptr ; //释放封装的资源
	}
}; 

NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:

NoHashObject* fp = new NoHashObject() ; //编译期错误!

delete fp ;

上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为“暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

void main(void)
{
	char* temp = new char[sizeof(NoHashObject)] ;
	//强制类型转换,现在ptr是一个指向NoHashObject对象的指针
	NoHashObject* obj_ptr = (NoHashObject*)temp ;
	temp = NULL ; //防止通过temp指针修改NoHashObject对象
	//再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
	Resource* rp = (Resource*)obj_ptr ;
	//初始化obj_ptr指向的NoHashObject对象的ptr成员
	rp = new Resource() ;
	//现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了
	... ...
	delete rp ;//释放资源
	temp = (char*)obj_ptr ;
	obj_ptr = NULL ;//防止悬挂指针产生
	delete [] temp ;//释放NoHashObject对象所占的堆空间。
} 

    上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

Resource* rp = (Resource*)obj_ptr ;

但是,并不一定所有的编译器都是如此。

既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

3.5 禁止产生栈对象

前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了

这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。

为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

class NoStackObject
{
	protected:
	~NoStackObject() { }
	public:

	void destroy()
	{
		delete this ;//调用保护析构函数
	}
}; 

接着,可以像这样使用NoStackObject类:

NoStackObject* hash_ptr = new NoStackObject() ;

... ... //对hash_ptr指向的对象进行操作

hash_ptr->destroy() ;

呵呵,是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为private或protected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现.)让我们来看看:

class NoStackObject
{
	protected:
	NoStackObject() { }
	~NoStackObject() { }
	public:
	static NoStackObject* creatInstance()
	{
		return new NoStackObject() ;//调用保护的构造函数
	}
	void destroy()
	{
		delete this ;//调用保护的析构函数
	}
};

现在可以这样使用NoStackObject类了:

NoStackObject* hash_ptr = NoStackObject::creatInstance() ;

... ... //对hash_ptr指向的对象进行操作

hash_ptr->destroy() ;

hash_ptr = NULL ; //防止使用悬挂指针

现在感觉是不是好多了,生成对象和释放对象的操作一致了。

3.5 浅议C++ 中的垃圾回收方法

许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后,很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。

其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为我们可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。

而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是,看似不可能的事情实际上实现起来却并不复杂。首先,通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样,回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次,如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。

垃圾回收的时候,只需要扫描 bss 段, data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。

如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。如果有兴趣的话,可以搜索一下网上已有的关于垃圾回收的论文和实现了的库,开拓视野对一个程序员尤为重要。

posted @ 2012-12-26 12:15  foreverlearn  阅读(414)  评论(0编辑  收藏  举报