垃圾回收机制使程序员不需要再关注什么时候释放内存,释放内存这件事儿完全由GC做了,为程序员省去了不少的麻烦。但是,作为一个"不拖控件"的.Net程序员很有必要理解垃圾回收是如何工作的。下面我们来看下.Net是如何分配和管理托管内存的,之后再一步一步描述垃圾回收器工作的算法。
让我们想一下,每一个程序都要使用内存资源:例如屏幕显示,网络连接,数据库资源等等。实际上,在一个面向对象环境中,每一种类型都需要占用一点内存资源来存放他的数据,对象需要按照如下的步骤使用内存:
1. 为类型分配内存空间
2. 初始化内存,将内存设置为可用状态
3. 存取对象的成员
4. 销毁对象,使内存变成清空状态
5. 释放内存
这种貌似简单的内存使用模式导致过很多的程序问题,有时候程序员可能会忘记释放不再使用的对象,有时候又会试图访问已经释放的对象。这两种bug通常都有一定的隐藏性,不容易发现,他们不像逻辑错误,发现了就可以修改掉。他们可能会在程序运行一段时间之后内存泄漏导致意外的崩溃。事实上,有很多工具可以帮助开发人员检测内存问题,比如:任务管理器,System Monitor AcitvieX Control, 以及Rational的Purify。
而GC可以完全不需要开发人员去关注什么时候释放内存。然而,垃圾回收器并不是可以管理内存中的所有资源。有些资源垃圾回收器不知道该如何回收他们,这部分资源就需要开发人员自己写代码实现回收。在.Net framework中,开发人员通常会把清理这类资源的代码写到Close、Dispose或者Finalize方法中,稍后我们会看下Finalize方法,这个方法垃圾回收器会自动调用。
不过,有很多对象是不需要自己实现释放资源的代码的,比如:Rectangle,清空它只需要清空它的left,right,width,height字段就可以了,这垃圾回收器完全可以做。下面让我们来看下内存是如何分配给对象使用的。
对象分配:
.Net clr把所有的引用对象都分配到托管堆上。这一点很像c-runtime堆,不过你不需要关注什么时候释放对象,对象会在不用时自动释放。这样,就出现一个问题,垃圾回收器是怎么知道一个对象不再使用该回收了呢?我们稍后解释这个问题。
现在有几种垃圾回收算法,每一种算法都为一种特定的环境做了性能优化,这篇文章我们关注的是clr的垃圾回收算法。让我们从一个基础概念谈起。
当一个进程初始化之后,运行时会保留一段连续的空白内存空间,这块内存空间就是托管堆。托管堆会记录一个指针,我们叫它NextObjPtr,这个指针指向下一个对象的分配地址,最初的时候,这个指针指向托管堆的起始位置。
应用程序使用new操作符创建一个新对象,这个操作符首先要确认托管堆剩余空间能放得下这个对象,如果能放得下,就把NextObjPtr指针指向这个对象,然后调用对象的构造函数,new操作符返回对象的地址。
图1托管堆
这时候,NextObjPtr指向托管堆上下一个对象分配的位置,图1显示一个托管堆中有三个对象A、B和C。下一个对象会放在NextObjPtr指向的位置(紧挨着C对象)
现在让我们再看一下c-runtime堆如何分配内存。在c-runtime堆,分配内存需要遍历一个链表的数据结构,直到找到一个足够大的内存块,这个内存块有可能会被拆分,拆分后链表中的指针要指向剩余内存空间,要确保链表的完好。对于托管堆,分配一个对象只是修改NextObjPtr指针的指向,这个速度是非常快的。事实上,在托管堆上分配一个对象和在线程栈上分配内存的速度很接近。
到目前为止,托管堆上分配内存的速度似乎比在c-runtime堆上的更快,实现上也更简单一些。当然,托管堆获得这个优势是因为做了一个假设:地址空间是无限的。很显然这个假设是错误的。必须有一种机制保证这个假设成立。这个机制就是垃圾回收器。让我们看下它如何工作。
当应用程序调用new操作符创建对象时,有可能已经没有内存来存放这个对象了。托管堆可以检测到NextObjPtr指向的空间是否超过了堆的大小,如果超过了就说明托管堆满了,就需要做一次垃圾回收了。
在现实中,在0代堆满了之后就会触发一次垃圾回收。“代”是垃圾回收器提升性能的一种实现机制。“代”的意思是:新创建的对象是年轻一代,而在回收操作发生之前没有被回收掉的对象是较老的对象。将对象分成几代可以允许垃圾回收器只回收某一代的对象,而不是回收所有对象。
总结下垃圾回收机制,这时候需要考虑2个问题:
1.我们在创建对象的时候这个对象到底怎么创建的?
2.垃圾回收,回收这个对象的时候对象又发生了什么变化?
首先,对象在内存中是联系存放的,这种联系存放方式给.NET带来非常大的好处,它是提高.Net的效率根本保障,.Net是托管程序,是由虚拟机进行即使编译的,但是又是为什么.Net可以执行那么快,有2个保障,一是技术编译的功能,是以模块化进行编译,编译完了以后会储存起来,再用就不用编译了,二是它的对象在内存中的存放方式是连续存放,不会因为碎片而造成的损耗,碎片越多,找起来越麻烦,效率越低。这是垃圾回收算法做的一些事情。 、
我们举一个生活中的小例子,有点像在家吃饭一样,一般情况下,菜全部上齐,桌子肯定放不下,我们肯定都是把开胃菜都先上开,最后一道鱼盘子很大,但是放不下,我们把吃完的都撤掉,相当于垃圾回收,这时候还是放不下,这时候需要从新排下顺序,这时候就有足够的空间放下来了。我们总结下,这里有2个动作:
1.撤去盘子(相当于回收对象)
2.重新排列
在垃圾回收里面,引入了非常重要的概念"代",垃圾回收,总是回收第0代的对象。我们可以把代形容成3个容器,分别表示第零代,第一代,第二代,这3个容器一个比一个大,每次创建的新对象,首先放在第零代里,如果第零代满了以后,我们开始遍历第零代空间里的所有对象,看看有没有引用,如果没有引用,就给这个对象加一个标记,遍历完成以后,我们将没有指向的对象保存在这个空间里面, 把有用的扔到第一代里,然后第零代就空下来了,然后再满了再往第一代里面丢,如果第一代也满了,就查看第一代里有没有引用,有引用的往第二代里扔,这时候第一代也又空了,又可以继续放了,一般情况下第二代满不了,如果第二代满了就会报出一个异常,就是我们常见的内存溢出。