GC:.net framework中的自动内存管理--part 2 (翻译)

Garbage Collection Part 2: Automatic Memory Management in the Microsoft .NET Framework

 

GC:.net framework中的自动内存管理

 

Jeffrey Richter

 

本文假设你已熟悉C和C++

概要:本文的第一部分已经讲过了GC算法是如何工作的,当GC决定释放资源时是如何正确的回收内存的,以及如何强制释放
一个free的内存。这部分将总结了强对象引用和弱对象引用是如何解决管理内存的大对象问题,同时解释了对象的分代以及
它们如何提高性能。另外,还讲述了用于控制GC的一些方法和属性,监控回收性能的资源,还包括多线程程序中的GC。

上个月,我介绍了GC环境的用途是为了简化程序员的内存管理。同时我也讨论了CLR使用的传统算法,以及算法内部机制。
同时,我还解释程序员如何通过引入Finalize,Close或者Dispose方法来显式的管理和清除资源。这个月,我会继续讨论CLR
中的垃圾回收。

 

首先我会先探索一个叫弱引用的功能,你可以用于降低托管堆分配大对象时内存压力。接着,我会讨论GC是如何使用”代“概念
来增强GC性能的。最后,我会总结一些GC提供的其它性能优化方法,例如多线程回收和CLR提供的用于监测GC实时操作的性能计数器。

 

弱引用

当一个根指针指向一个对象时,该对象由于程序可以访问而不能被回收。当一个根指针指向一个对象时,我们称该对象为强引用对象。
然而,GC同时还支持弱引用。弱应用允许GC去回收该对象,也运行运行程序访问该对象。这怎么实现的呢?这一切都归结于时间问题。

当GC正在运行时存在一个弱引用对象,那么对象就会被回收,而后程序就无法访问该对象。另外,要访问一个弱应用对象,程序
必须包含一个对该对象强引用。如果程序在GC回收前包含该对象的强引用,那么GC就无法回收该资源。我知道你听起来很困惑,
我们可以通过下面Figure 1的代码段来理清这个问题:

Figure 1 Strong and Weak References 
Void Method() {
   Object o = new Object();    // 创建一个对象的强引用
   // Create a strong reference to a short WeakReference object.
   // The WeakReference object tracks the Object.
   WeakReference wr = new WeakReference(o);
   o = null;    // Remove the strong reference to the object
   o = wr.Target;
   if (o == null) {
      // A GC occurred and Object was reclaimed.
   } else {
      // a GC did not occur and we can successfully access the Object 
      // using o
   }
}


为什么你可能会使用弱引用呢?因为如果有一些很容易创建的数据结构,但是确需要暂用大量的内存。例如,你可能会要你的程序
获取用户硬盘的所有路径很文件名。你可以很轻易的构建这样的文档树来反映这些信息,你会选择将这些信息放在内存中而不是
直接访问用户硬盘。这个过程很大程度上提高你应用程序的性能。

 

问题是,这个树需要大量的内存资源。如过用户访问程序的其它地方,那么这个树就没必要放在内存中浪费空间了。
你可以删除这个树,但是当用户回到程序的第一部分,你就必须重构这个树。弱应用可以简单高效用于处理该场景。

 

当用户从第一部分转换到程序其它部分时,你可以创建一个对这个树的弱引用,然后删除所有的强引用。如果程序的其它部分不需要
占用很大内存,那么GC就不会回收该资源。当用户返回程序的第一部分时候,程序会尝试获知一个这个树的强应用,如果成功,则程
序无需再访问用户的硬盘。

 

弱引用类型提供了两个构造函数:

WeakReference(Object target);
WeakReference(Object target, Boolean trackResurrection);

 

参数是标识需要弱引用的对象。参数trackResurrection标识,弱引用对象在调用完Finalize方法后是否应该在追踪它。通常会传递false,
第一个构造函数标识的就是软件一个弱引用类型且不需要追踪是否重生。(关于重生的概念,你可以参考文章的第一部分)

 

为了方便,对于不需要追踪重生的弱引用,我们称为短弱引用,如果需要跟踪重生的我们称为长弱应用。如果一个对象没有提供Finalize方法,
那么短和长弱应用则是一样的。强烈建议避免使用长弱应用。长弱应用可以在对象调用了finalize方法后重生,到时对象的状态不可预测。

 

一旦你创建了一个弱应用类型的对象,通常你还需要将强引用类型的对象设置成null。如果存在强应用,那么对象就不会被GC回收。

 

如果要重新使用对象,那么你必须将弱应用重新指向给一个强引用。你只要简单的将若引用对象的Target属性重新指向到程序的根对象。
如果Target属性返回为null,那么对象就给回收了。如果对象没有返回为null,那么就可以制定一个强引用对象到该对象,那么代码就
可以维护该对象。因为存在着一个强应用,那么对象就不可回收。

 

弱引用的内部原理

在上面的讨论中,很明显弱应用跟其它类型表现得不太一样。通常,如果你的程序根存在一个对该对象的引用,该对象又引用了另一个
对象,那么两个对象就是可访问的,且GC不可以重新分配这两个对象的内存。但是,如果你的程序根集存在一个弱引用对象,那么该
弱引用指向的对象不被认为是可达的,可能被回收。

 

为了更好的理解弱引用是如何工作的,我们在来看看托管对的内部。托管堆包含了两个内部数据结构母的是用于管理弱引用对象:
短弱引用表和长弱引用表。这两个表包含了指向托管对对象的指针。

 

初始化时候,两个表都是空的。当你创建一个弱应用对象,对象不会从托管对分配资源,而是从弱引用表中分配一个空的槽,
短弱引用类型使用短弱引用类型表,而长弱引用类型则使用长弱引用类型表。

 

一旦发现一个空槽,该槽的则就会被设置成为你想追踪对象的地址--也就是从WeakReference构造函数传递进去的对象指针。
实例化操作返回的就是该槽的地址。显然,两个弱引用表并不是程序根集合的一部分或者GC无法重新分配表中的对象指针。

现在,我们来看看GC运行时会发生什么情况:

 

1. GC建立一个可到达对象的图,文章第一部分 已经讨论过GC是如何实现。

2. GC搜索短弱引用表,如果表中指针指向的对象不是对象图的一部分,那么指针指向的是一个不可访问的对象,而该槽也会被
设置为null。

3. GC搜索finalization队列,如果队列中的指针指向的对象不是对象图的内容,那么指针表示的是一个不可访问对象,该指针
从finalization队列移动到freachable队列。在这点上,对象被添加到对象图中因为对象被认为是可访问的。

4. GC遍历长引用类型表,如果弱引用对象的指针不是图中的一部分(而在freachable队列包含了该对象的指针),那么指针
表示的是一个不可达的对象,同时该槽被设置为null

5. GC压缩内存,压缩那些unreachable对象留下来的空间。

 

一旦你了解了GC的运行逻辑,那么理解弱引用是如何工作就很容易了。访问弱引用的Target属性时,会使得系统反正的弱应用表中
的槽。如果该槽为null,那么对象就已经给回收了。

 

短弱引用是不会追踪重生的。这意味着,GC一旦认为对象是不可达的时候,则将短弱引用表中的指针设置成为null。如果对象具有
Finalize方法,这个方法还没被调用时,则这个对象还存在。如果程序访问弱引用类型的Target属性,则返回null,即使对象还存在。

 

一个长弱引用会追踪重生,这意味着当对象的存储空间被回收时,那么长弱引用表的指针则会设置成null。如果对象使用了Finalize
方法,那么Finalize会被调用,对象不再重生。

 

当我第一次接触GC环境时候,我对它的性能有很多担心。毕竟我做C/C++开发已经超过15年,我了解分配和释放内存块的开销。
当然,每一个版本的windows和每个版本的C运行时都会优化堆算法的来提高性能。

当然,像windows和C运行时开发者一样,GC的开发者也一直优化GC来提高性能。GC中的一个叫“代”的特性就是完全为了提高性能而存在的。
一个代垃圾回收(也叫ephemeral garbage collector)具有下列特点:

    越新的对象,生命周期越短
    越老的对象,生命周期越长
    新对象之间具有较强的练习,经常会在周围被访问到。
    压缩对的一个区域比压缩整个堆要快
   
当然,很多文章已经通过大量的程序展示过这些特性是正确的。因此,我们讨论这些特性是如何影响GC的实现的。

 

当初始化时候,托管对没有包含对象。添加到堆的对象被称为第0代,如fiure 2所示。简单的所,第0代的对象是GC从为检测到的新对象。

 
现在如果在有对象要添加到堆时,托管堆就会满,此时GC就必须执行。当GC分析堆时候,他会建立垃圾(紫色的部分)和非垃圾的对象图。
任何存活的对象被压缩到堆的左边。这些对象存活为一个集合,是更老的,现在我们称它们为第1代。(如Figure 3)

如果在有新的对象添加到堆中,这些新的,年轻的对象会放到第0代中。如果第0代填满后,GC开始运行。这次,第一代存活的对象会
压缩成为第2代(如Figure 4)。所有第0代的对象会被压缩成为第1代。此时第0代又有空间,新的对象又可以存放到第0代中。

现在GC运行时最高只支持到第二代。未来当回收时,任何第二代存活的对象还是会待在第二代。

 

代GC性能优化

就像我先前提到的,代回收机制是为了提高性能。当堆满以后回收开始执行。GC可以选择只检查第0代而忽略其它更高的代。
毕竟,越新的对象,那么它的生命周期就会越短。因此,回收和压缩第0代对象一般可以回收较多的空间而且会比回收和检测所有代要快的多。


这是GC代概念提供的最简单的优化措施。代级的回收不去遍历托管堆所有对象使得GC获得更好的性能。如果根或一个对象引用了老一代的对象
那么GC会忽略这些老对象的内部应用,从而降低了建立可到达对象图的时间。当然,也有可能是老对象引用了新的对象。因此当这些对象被检测到,
回收器会利用系统的写监听支持(有win32的Kernel32.dll中的GetWriteWatch方法提供)这个支持可以让回收器知道那个对象在上次回收过后被写过。
这些特定的老对想可以检测他们是否引用了新对象。

 

如果回收第0代资源不能提供足够的资源,那么回收期会尝试从第1和0代回收。如果还是不够,那么回收器会从第2,1和0代回收资源。
回首器决定回收哪一代的是uanfa是微软不断在优化的部分。

 

大部分堆(想C运行时堆)一大发现空的控件就会分配给对象。因此,如果我连续创建多个对象,很有可能这些对象地址被一些字节数分开了。
然而,在托管对中,连续分配几个对象可以保证对象在内存中是连续的。

 

就像之前所说的,新对象一般直接都有较强的练习,同时也经常会被反问到。由于新对象被分配到连续的内存空间,你可以通过它
引用地址来获得更好性能。更特别的情况是,有很有可能所有的对象被分配到CPU的缓冲区。你的程序可以已极快的速度访问这些对象
因为CPU会做大量的工作强制访问RAM来保证缓存命中率。

 

微软的性能测试人员表明托管堆分配比其它使用win32的HeapAlloc函数的传统内存分配方式要快。这些测试人员同时还表明在奔腾200Mhz的机器
GC回收第0代资源的时候,少于1毫秒。而微软的目标也就是使GC的时间比普通页错误花更少时间。


直接控制System.GC

System.GC类允许你的应用程序直接控制垃圾回收。对于初学者,你可以通过 GC.MaxGeneration属性来查询GC支持的最大代。当前,GC.MaxGeneration
返回的是2.

 

同时也可以通过调用下面两个方法的一个强制GC去回收资源


 void GC.Collect(Int32 Generation
 void GC.Collect()

 

第一个方法允许你指定回收的代。你可以传递包含0到GC.MaxGeneration的参数。传0表示回收第0代,传1则标识回收1,0代,传2则
回收2,1,0代。不代参数的Collect函数标识强制回收所有代,等同于:


GC.Collect(GC.MaxGeneration);

 

大多数情况下,你应该尽量避免使用Collect函数。最好是让GC根据需求去自动运行。但是,即使程序比运行时更清楚它的动作,你也可以
明确的指定强制回收。例如,当用户保存好他所有的数据文件后,强制你的应用程序去做一次完整回收是有意义的。我猜想,浏览器应该会在
页面卸载的时候去执行一次完整回收。你可能在你程序需要执行长时间操作的时候执行一次回收;这样做可以隐藏GC回收呃时间和阻止当
用户操作应用程序的时候去回收资源。

 

GC类还提供了WaitForPendingFinalizes方法。这个方法只是简单的挂起调用线程,直到freachable队列被清空,才调用对象的Finalize方法。
在大多程序中,一半不会去调用这个函数。

 

最后,GC还提供了两个方法,用于判断对象是在哪个代中:
Int32 GetGeneration(Object obj)
Int32 GetGeneration(WeakReference wr)

 

第一个函数需要一个对象的引用做为参数,而第二个函数则需要一个若引用对象。当然,返回值是在0到GC.MaxGeneration之间的证书。

Figure 5的代码段会帮你理解代是如何工作的,它也展示了如何使用我刚刚讨论的GC方法。

Figure 5 GC Methods Demonstration 
private static void GenerationDemo() {
    // Let's see how many generations the GCH supports (we know it's 2)
    Display("Maximum GC generations: " + GC.MaxGeneration);
    // Create a new BaseObj in the heap
    GenObj obj = new GenObj("Generation");
    // Since this object is newly created, it should be in generation 0
    obj.DisplayGeneration();    // Displays 0
    // Performing a garbage collection promotes the object's generation
    Collect();
    obj.DisplayGeneration();    // Displays 1
    Collect();
    obj.DisplayGeneration();    // Displays 2
    Collect();
    obj.DisplayGeneration();    // Displays 2   (max generation)
    obj = null;         // Destroy the strong reference to this object
    Collect(0);         // Collect objects in generation 0
    WaitForPendingFinalizers();    // We should see nothing
    Collect(1);         // Collect objects in generation 1
    WaitForPendingFinalizers();    // We should see nothing
    Collect(2);         // Same as Collect()
    WaitForPendingFinalizers();    // Now, we should see the Finalize 
                                   // method run
    Display(-1, "Demo stop: Understanding Generations.", 0);
}


 

多线程程序的性能

在先前的章节中,我解释了GC算法和优化措施。但是,之前的讨论都是基于一个大前提:只有一个线程在运行。在现实世界,
很可能是多个线程在访问托管第或至少操作托管堆中的对象分配。当一个线程启动一个回收,其它线程则不能访问
其它对象(包括它本身栈中的对象引用),因为回收器可能会移动这些对象,改变内存的地址。

 

因此,当GC要启动一个回收时,所有在操作托管代码的线程必须挂起。运行时具有一些不同的机制来安全的挂起这些线程
以保证回收能顺利完成。使用多个机制的原因是为了保证线程尽可能的运行长时间,同时尽量的减少其它负载。我不想深入
的探讨这个entity,但可以说,微软已经做了很多工作,来减少回收垃圾涉及的开销。微软还会不断的修改这些机制以保证
更高效的垃圾回收。

 

下面的段落介绍了当程序使用多线程时,GC提供的一些机制:

 

完全中断代码  当回收开始时,回收器会中断所有程序线程。接着回收器会决定线程中哪里中断并使用JIT编译器提供的表格
回首器会表名线程在哪里停止的,代码正在访问哪些对象应用,以及那些对象应用的位置(在变量,CPU 寄存器等等)

 

劫持 回收期可以修改线程栈一反问特定函数的地址指针。当当前的执行函数返回时,特定的函数就会执行,同时挂起线程。
修改线程的执行顺序我们叫做线程劫持。当回收完成后,线程会白唤醒,然后返回到原来调用的函数中。

 

安全点 JIT编译器编译一个函数时,它可以插入一个特定的方法用来检测GC是否正在等待。如果是,则线程挂起,GC运行,
接着线程又重新运行。编译器插入调用这些方法的位置称为:GC安全点。

注意,线程劫持允许GC发生时候线程执行非托管代码之后再继续执行原有代码。这不是一个问题,因为非托管代码并不
访问托管对的对象,除非是固定对象而且没有包含其它对象引用。固定对象是一种GC无法在内存中移动的对象。
如果线程中非托管代码中返回到托管代码,那么这个线程就被劫持和挂起,直到GC完成回收。

除了我刚刚提到的机制,GC还提供了一些改进来加强多线程程序对象分配和回收的性能。

 

同步分配 在多处理器系统中,托管对中的第0代被分割成为多个内存区域,每个线程一个区。这就允许多线程同时分配内存,而不需要
对托管堆使用独占访问措施。

 

可扩展回收  在多处理器系统中运行这服务器的执行引擎(MSCorSvr.dll),托管堆被分成多个部分,一个CPU一部分,当回收初始化时候
每个CPU具有一个线程,每个线程同时回收他们所有的部分。工作站的执行引擎(MSCorSvr.dll)不支持该功能。

 

回收大对象

这里还有个性能优化措施你需要注意的。大对象(20K或以上)被分配到一个特殊的大对象堆中。该堆中的对象像其它小对象一样可以
被finalized和释放。但是,大对象不会被压缩,因为压缩20K大小的内存块会浪费大量的CPU时间。

注意,这些机制对于你的程序代码来说是透明的。对于你,开发者,就好像只有一个托管对,这些机制只是为了当初的提高应用程序的性能。

 

监听垃圾回收

微软运行时团队提供了一系列的性能计数器,提供了许多运行时操作的实时统计数据。你可以通过Windows 2000中的System Monitor Activx
来查看这些统计信息。访问System Monitor control只需运行PerfMon.exe,点击工具栏的+按钮,弹出如Figure 6窗口

监控运行时的垃圾回收,选择COM+ Memory Performace对象(windows 2003是.NET CLR Memory),接着,你可以从示例列表选择一个特定的程序。
这时候,System Monitor就会绘制选择的实时统计数据。 Figure 7描述了每个计数器的功能。

 

总结

 

意思就是所有关于GC的内容。上个月,我讲了资源如何分配的背景知识,自动回收是如何工作的,如何使用finalization功能来运行对象自动清除,
以及重生功能可以重新访问一个对象。这个月,我解释了如何引入弱引用对象和强引用对象,将对象分类为代的性能好处,以及你可以通过
System.GC手工控制回收资源。我同时还提到了GC在多线程程序中提供的性能优化措施,当对象超过了20k时会发生什么,最后,如何使用windows 2000
中的System Monitor来监控垃圾回收的性能。通过这些只是,你应该可以简化你的内存管理已经提高你应用程序的性能。

 

posted @ 2010-08-31 14:40  Chris Cheung  阅读(967)  评论(0编辑  收藏  举报