.NET中的垃圾回收 cloud(翻译)

关键字:GC 出处:http://www.codeproject.com/dotnet/garbagecollection.asp

导言
关于垃圾回收
垃圾回收算法
    应用程序根(Application Roots)
实现
    阶段I: 标记(Mark)
    阶段II: 整理(Compact)
终结(Finalization)
垃圾回收性能的优化
    弱引用(WeakReference)
    代(Generations)
垃圾回收相关的神话

导言

微软声称.NET是一种革命性的编程技术。许多要素使它成为大多数开发人员的首选。本文我们将要讨论一下.NET Framework中一个很主要的优势——内存和资源管理的便捷性。

关于垃圾回收器

每个程序都会使用一定次序的资源,或内存缓冲区,或网络连接,或数据库资源等等。实际上,在面向对象环境中,每种类型都被看作是程序的某些有效资源。为了使用这些资源,必须分配部分内存来描述这种类型。

资源访问按一下步骤进行:

  1. 为类型分配内存来描述资源。
  2. 初始化资源,把资源设置成初始化状态,使资源可用。
  3. 通过访问类型实例的成员来使用资源(按需求重复)。
  4. 销毁资源状态以清除资源。
  5. 释放内存。

.NET中的垃圾回收器(GC)完全彻底帮助开发人员从追踪内存的使用和确定什么时候释放内中解脱出来。

Microsoft? .NET CLR (公共语言运行库)要求所有资源都从托管堆中进行分配。你无需释放托管堆中的对象——当应用程序不再需要这些对象时,对象将被自动释放。

内存不是无限的。垃圾回收器需要执行回收以便释放内存。垃圾回收器的优化引擎会对已做的分配选择最好的回收时间(准确标准由微软提供)。当垃圾回收器执行回收时,它先找出托管堆中不再被应用程序使用的对象然后执行相应操作收回这些对象的内存空间。

然而,为了进行自动内存管理,GC必须知道根的位子。也就是说,它应该知道一个对象什么时候不再被应用程序使用了。在.NET中,GC是通过一个称之为元数据的东西了解到这些的。.NET中使用的每种数据类型都通过元数据来描述它。在元数据的帮助下,CLR知道内存中每个对象的布局,在垃圾回收的整理阶段给GC提供帮助。没有这些信息,GC将不会知道一个对象在哪儿结束和下一个从哪儿开始。

垃圾回收算法

应用程序根(Application Roots)

每个应用程序都有一套根(Roots)。根标识存储位置,这个位置或者指向一个托管堆的对象,或者指向一个空对象(null)。

比如:

  • 一个应用程序中所有全局和静态对象指针。
  • 一个线程堆栈中所有局部变量/参数对象指针。
  • 托管堆中所有CPU登记的对象指针。
  • FReachable队列中的对象指针。

活动根的表由JIT编译器和CLR维护,并且对于垃圾回收器的算法是访问。

实现

.NET中的垃圾回收是用跟踪回收实现的,确切的说CLR实现了标记(Mark)/整理(Copact)回收器。

这个方法有以下两个阶段组成:

阶段I:标记(Mark)

找到可以被收回的内存。

当GC开始运行时,它假设堆中的所有对象都是垃圾。换句话说,它假设应用程序的根没有指向堆中的的任何对象。

阶段I中包含下列步骤:

  1. GC识别存活对象的引用或应用程序根。
  2. 从根开始遍历,建一张可以从根遍历的所有对象的图。
  3. 如果GC准备尝试添加一个已经在图中的对象,它就停止这条路径的遍历。这样做有两个目的,第一个是极大的优化性能,因为它不会遍历一套对象一次以上。第二是防止当有对象的循环连接列表时而发生死循环,因此循环被有效的控制了。

一旦所有的根都被检查完后,垃圾回收器的图中包含了所有可以从应用程序根遍历到的对象。任何不再图中的对象都不能被应用程序访问到,也就是所谓的垃圾。

阶段II:整理(Compact)

把所有存活的对象移到堆的末端,空出堆顶端的空间。

阶段II包含下列步骤:

  1. 现在GC线性地遍历堆,寻找邻近的垃圾对象块(现在被认为是空闲空间)。
  2. 然后GC往下移动内存中的非垃圾对象,去掉堆中的所有空隙。
  3. 移动内存中的对象导致对象指针失效。因此GC需要修改应用程序的根使对象的指针指向新的位置。
  4. 另外,如果对象包含一个指向其它对象的指针,GC也会负责纠正这些指针。

在所有垃圾被标识完以后,所有的非垃圾对象也被整理,并且所有非垃圾对象的指针也被修正,最后一个非垃圾对象后的指针指向下一个被添加对象的位置。

终结(Finalization)

.NET Framework的垃圾回收器能暗中追踪由应用程序创建的对象的生命周期,但是当它遇到对象包装了非托管资源(比如文件、窗口或网络连接等)时却无能为力。

一旦应用程序不再使用那些非托管资源时需要显示地释放它们。.NET Framework为对象提供了终结(Finalize)方法:在垃圾回收器收回这个对象的内存时,必须执行对象的这个方法来清除它的非托管资源。由于缺省的Finalize方法什么都没做,如果需要显示清除资源必须覆盖这个方法。

如果一把Finalize方法当作只是C++中析构函数另外一个名字那也不足为怪。虽然它们都被赋予了释放对象占有的资源的任务,但是它们还是有很不相同的语义。在C++中,当对象推出作用域时析构函数会立刻被调用,而Finalize方法是在起动垃圾回收清除对象时才被调用的。

.NET中,由于终结器(Finalizer)的存在使得垃圾回收的工作变得更加复杂了,因为它在释放对象前增加了许多额外的操作。

无论什么时候,在堆上分配一个含有Finalize方法的新对象时,都会有一个指向这个对象的指针被添加到一个称为Finalization队列的内部数据结构当中。当对象不能再次被遍历到时,GC就认为这个对象是垃圾。GC首先扫描Finalization队列查找这些对象的指针,当指针被找到时,把它从Finalization队列中去掉并添加到另外一个名为FReachable队列的内部数据结构中,使这个对象不再是垃圾的一部分。这时,GC完成了确定垃圾。然后整理(Compact)可收回的内存,由专门的线程负责清空FReachable队列并执行对象的Finalize方法。

第二次垃圾回收器被触发的时候,它把被终结(Finalize)的对象看作真正的垃圾,然后简单的释放它们的内存。

由此可知当一个对象需要终结时,它先死,然后存活(复活),然后再次并且最终地死去。推荐避免使用Finalize方法,除非有需要。Finalize方法会增加内存压力,因为直到两次垃圾回收被启动时,对象占用的内存和资源才会得到释放。因此你无法控制两次Finalize方法执行的顺序,它可能会导致无法预料的后果。

弱引用(WeakReference)

弱引用(WeakReference)是提高性能的一种方式,用于减少托管堆中大对象的压力。

当一个根指向一个对象时,它被称为这个对象的一个强引用并且这个对象不能被回收,因为应用程序能遍历到这个对象。

当一个对象有一个指向它的弱引用(WeakReference)时,基本上是指如果有内存请求并且GC启动时,这个对象可以被回收,当应用程序再次尝试去访问这个对象时,访问将会失败。另一方面,为了能访问一个被弱引用(WeakReference)的对象,应用程序必须获得一个对这个对象的强引用。如果应用程序在垃圾回收器回收这个对象之前获得了它的强引用,GC将不能回收这个对象,因为有这个对象的强引用存在。

托管堆包含两个管理弱引用(WeakReference)的内部数据结构:短弱引用表和长弱引用表。

两种类型的弱引用:

  • 短弱引用不追踪复苏。
    也就是说,一个有短弱引用的对象会被立即收回,而不用等到运行Finalize方法。

  • 长弱引用追踪复苏。
    也就是说,只有当长弱引用表中的对象的存储空间可收回的时候GC才回收这个对象。如果对象有Finalize方法,是在Finalize方法被调用了之后并且对象不能复活了。

这两个表简单的存放着分配在托管堆中对象的指针。最初,两个表均为空。当你创建一个弱引用(WeakReference)对象时,对象不从托管堆中分配。而是在一个弱引用表中分配一个空的存储位置;短弱引用使用短弱引用表,长弱引用使用长弱引用表。

让我们看一个例子,看看GC运行时会发生些什么。下面的图(图1和图2)显示了GC运行前和运行后所有内部数据结构的状态。

GC运行前
图1:GC运行前

GC运行后
图2:GC运行后

以下是GC运行时进行的操作:

  1. GC为所有可遍历的对象建一张图。在上面的例子中,图包含对象B,C,E,G。
  2. GC扫描短弱引用表。如果表中指针指向的对象不在图中,那么这个指针标识的是一个不可遍历的对象,短弱引用表中的这个位置置为null。在上面的例子中,对象D的位置置为null,因为它不是图的一部分。
  3. GC扫描Finalization队列。如果队列中的指针所指的对象不在图中,那么这个指针标识一个不可遍历的对象,指针从Finalization队列中移到FReachable队列中。这时,对象被认为是可遍历的,所以加到图中。在上面的例子中,对象A,D,F是不包含在图中但看作是可遍历的对象,因为它们属于Finalization队列。进而Finalization队列被清空。
  4. GC扫描长弱引用表。如果表中的指针指的对象不在图中(现在图包括FReachable队列中指针所指的对象),那么指针标识一个不可遍历的对象,所在位置置为null。由于对象C和F都包含在图中,都不置null。
  5. GC整理(Compact)内存,挤出不可遍历对象留下的空隙。在上面的例子中,对象H是唯一从堆中删除的对象,它所分配的内存被收回。

代(Generations)

由于垃圾回收要在停止整个程序的情况下才能完成,它们可能会在程序执行期间进行任意长时间的中断。GC也有可能中断为满足实时系统的需求而要求及时响应的事件。

GC中有一个特征叫代(Generations),就是专门为提高性能而设计的。一个多代的GC是通过对观察用各种语言编写的大部分程序而得到两个事实进行仔细分析而得到的:

  1. 新创建的对象拥有更短的生命周期。
  2. 越老的对象,存活的越久。

多代的回收器通过对象的年龄把它分成若干组,并且年轻的对象比年老的对象回收的更频繁。初始化时,托管堆不含任何对象。所有新的对象都被添加到第0代堆中,直到堆装满了并触发垃圾回收。由于大部分对象存活的时间很短,只有一小部分年轻的对象在第一次回收时存活下来。一旦一个对象在第一次回收后存活下来后,它就被提升到第1代。在垃圾回收后可以说新的对象都在第0代堆中。只有当第0代的堆装满时垃圾回收才会再次被触发。所有第0代存活下来的对象被整理提升到第1代中。然后第0代不含任何对象了,但是所有新的对象都进入了第0代。

因此,作为当前代中“成熟”(存活于多代回收器)的对象,它们都会被移到下一级更老的代中。第2代是CLR的GC所支持的最大的代。以后回收时,第2代存活的对象将只是简单的停留在第2代。

因此,把堆划分成对象的代并且回收和整理更年轻的代中的对象提高了垃圾回收算法的效率,因为从堆中收回了大量的有意义的空间,同时比起回收器检查所有代中的所有对象要快得多。

一个能执行多代回收的GC,每次回收都要确保(至少要尽可能)所需时间小于某个最大时间,以帮助为实时环境能做一些配套的实时操作,同时也防止出现让用户明显感觉到的中断现象。

垃圾回收相关神话

GC显然比手工内存管理要慢

对应解释:
不是一定的。现代垃圾回收器看起来运行时和手工存储分配(malloc/free或new/delete)一样快。在一些特殊的程序中,垃圾回收运行的可能不如为用户专门设计的自定义内存分配那么快。但从另一方面说,为了使手工内存管理正确的工作而添加的额外代吗(比如说,显示的引用计数)常常比GC所做的要昂贵的多。

GC会使程序中断

对应解释:
由于垃圾回收器在查找和回收垃圾对象时通常停止整个应用程序,他们可能会导致中断时间过长而让用户觉察到。但是通过现在优化计数,这些可以感觉到的中断完全可以避免。

手工内存管理不会导致中断

对应解释:
手工内存管理并不能确保性能。它可能由于大量的分配或释放内存工作而导致中断。

使用GC的程序很大并且臃肿;GC不适合小的程序或系统

对应解释:
尽管在复杂的系统中使用GC很有优势,也没有理由认为GC在其它尺寸的程序中会引入什么大的开销。

我已经听说了GC会使用两次大量的内存

对应解释:
对于原来的GC这可能是个事实,但并不是垃圾回收器都是这样的。用于GC的数据结构比那些手工内存管理的要大的多。


luco (2004-1-10 22:17:45)

对weak reference能否再说清楚点?.NET & Java 都有weak reference的概念, 有很详细的文章吗?

luco (2004-1-10 22:16:08)

CLR or JVM 的优化,可以提高GC的performance.

Contributors: FHL