原作者: Jeffrey Richter
上部分的翻译位置:[经典文章翻译]垃圾收集: 在Microsoft .NET Framework中的自动化内存管理 - 第一部分
概述:
这份系列文档共分为两部分, 在第一部分中, 我们解释了垃圾收集算法是如何工作的, 在垃圾收集器决定释放资源内存的时候资源是如何恰当地被释放的, 还解释了如何强制一个对象在它被释放的时候执行清理动作. 这个系列的结论将会解释帮助管理大对象内存的strong object reference和weak object reference, 还有object generation.以及它们是如何帮助提高性能的. 另外, 还将介绍控制垃圾收集的方法和属性的使用, 监控垃圾收集性能的资源, 对多线程应用程序的垃圾收集也有涉及.
正文:
上个月, 我描述了垃圾收集环境设计的初衷: 为开发人员简化内存管理. 我还讨论了被通用语言运行时所使用的总体算法, 还有一些算法工作的内部细节. 另外, 我还解释了开发人员必须显式地通过实现Finalize, Close, 或Dispose 方法来处理资源管理, 资源清理. 这个月, 我会总结我的关于CLR垃圾收集的讨论.
我将要从探索一个调用weak reference的feature开始, 你可以使用这个特性来减轻在托管堆上放置大对象而产生的内存压力. 然后我会讨论使用generations作为一个性能增强的手段. 最后, 我会通过一些由垃圾收集提供的性能增强点来总结整个讨论, 这些点包括多线程回收, CLR暴露出来的供我们监控垃圾收集器实时行为的performance counter.
=================
当root指向一个对象的时候, 这个对象不能被回收, 因为应用程序的代码能访问到这个对象. Root指向指向一个对象, 这就叫做针对对象的强引用(strong reference). 然而, 垃圾收集器还支持weak reference. Weak Reference允许垃圾收集器回收对象, 但是他们还允许应用程序访问对象. 这怎么可能? 这一切都可归结为时机问题.
如果对对象的weak reference存在, 并且垃圾收集器在运行, 对象就会被回收. 再晚些时候, 应用程序如果试图访问这个对象, 那么访问就会失败. 换句话来说, 访问weakly reference对象, 应用程序必须保持一份strong reference. 如果应用程序在垃圾收集回收这个对象之前保持这份strong reference, 那么垃圾收集器就不能回收这个对象, 因为有指向它的strong reference存在. 我知道这听起来有点乱, 让我们通过在图表一里的代码来澄清一下吧.
Void Method() { Object o = new Object(); // Creates a strong reference to the // 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 } }
图表1:Strong and Weak References
为什么你要用weak reference呢? 好吧, 有些数据结构很容易创建, 但是需要很多内存. 比如说, 你也许有一个应用程序需要知道用户硬盘上所有的目录和文件. 你可以很容易地在你应用程序运行的时候创建出来一棵树来反映出这些信息, 你会引用内存中的这颗树而不是真正地区访问用户的硬盘. 这个过程会很大程度上地提高你应用程序的性能.
问题在于, 这棵树可能会非常大, 需要很多的内存. 如果用户开始访问你应用程序的另外一个部分, 这棵树就不会再需要了, 所以这浪费了宝贵的内存. 你可以删除这棵树, 但是如果用户回到你应用程序的第一部分, 你就需要重新构造这棵树. Weak reference能够让你比较容易地, 高效地处理这种情况.
当用户切换回到你应用程序的第一个部分的时候, 你可以创建一个针对这棵树的weak reference, 然后消灭掉所有的strong references. 如果应用程序另一半的内存负载很低, 那么垃圾收集器就不会回收这棵树的内存. 当用户切换回应用程序的第一部分的时候, 应用程序会试图保持一份这棵树的strong reference. 如果成功了, 应用程序就不必再遍历用户的硬盘了.
WeakReference提供了两个构造函数:
WeakReference(Object target);
WeakReference(Object target, Boolean trackResurrection);
参数target指定了WeakReference对象应该跟踪的对象. 参数trackResurrection 表明WeakReference 对象是否应该在它的Finalize方法调用之后仍跟踪目标对象. 通常情况下, 我们都会传false给trackResurrection参数, 第一个构造函数创建不会跟踪resurrection的WeakReference 对象. 关于resurrection的介绍, 请参考本系列的第一部分.
为了方便起见, 不跟踪resurrection的weak reference, 叫做short weak reference, 而跟踪resurrection的weak reference叫做long weak reference. 如果一个对象的类型没有提供Finalize方法, 那么short和long weak reference的行为完全一样. 强烈建议避免使用long weak reference. 因为long weak reference允许你在一个对象被finalize后, 在对象的状态无法预期的时候, resurrect这个对象.
一旦你创建了对一个对象的weak reference, 通常你就把指向这个对象的strong reference设置为了null. 如果任何strong reference还残留着, 那垃圾收集器就无法回收这个对象了.
为了再次使用这个对象, 你必须把weak reference转换为strong reference. 你可以简单地通过调用WeakReference对象的Target属性, 把它赋给你应用程序中的一个root. 如果Target属性返回null, 那么这个对象就是已经被回收了. 如果这个属性返回的不是null, 那么这个root将成为这个对象的strong reference, 你的代码也就可以操纵这个对象了. 只要strong reference存在, 对象就不能被回收.
====================
在前面的讨论中, WeakReference不能拥有像其他object那样的行为是很明显的了. 通常地, 如果你的应用程序有一个指向某对象的root, 并且那个对象引用了另外一个对象, 那么这两个对象都是代码可以访问到的(reachable), 垃圾收集器不能回收由这两个对象所拥有的内存. 然而, 如果应用程序有一个root指向一个WeakReference对象的话, 那么通过WeakReference所引用的对象就不被认为是可以访问到的了, 所以, 是可以回收的.
为了能够完全地理解weak reference是如何工作的, 让我们再一次看一看托管堆吧. 托管堆包含两个internal的数据结构, 这两个数据结构的唯一目的管理weak reference. 它们分别是:
这两张表很简单地包含指向托管堆内的对象的指针.
最开始的时候, 这两张表都是空的. 当你创建了WeakReference对象的时候, 一个对象并不会在托管堆上分配. 相反, 这两张表上的一条空槽(empty slot)会被定位到, short weak fererence使用short weak reference表, long weak reference使用long weak reference表.
一旦找到了一条empty slot, 那么这个slot的值就被设定为你想追踪的对象的地址, 然后这个对象的指针被传递给WeakReference的构造函数. new操作符返回的值是WeakReference表的slot的地址. 显然地, 两个weak reference表并不会被认为是application的root的一部分, 垃圾收集器也不能回收由这两张表指向的对象.
现在, 我们来陈列一下当垃圾收集器运行时, 都发生了些什么:
一旦你明白了垃圾收集过程的逻辑性, 那么理解weak reference是如何工作的就比较简单了. 访问WeakReference的Target属性会引发系统返回weak reference表的slot存放的地址. 如果这个slot中存放的是null, 那么这个对象就会被回收.
short weak reference并不会追踪resurrection. 这意味着垃圾收集器一旦决定某个对象是unreachable的时候, 它就会立即设置short weak reference表中的指针为null. 如果对象有Finalize方法, 那么这个方法还没被调用, 所以对象依然存在. 如果应用程序访问WeakReference对象的Target属性, 那么null就会被返回, 即使对象实际上还存在着.
Long Weak Reference追踪resurrection. 这意味着垃圾收集器会在对象的内存可以回收的时候, 设置long weak reference表中的指针为null. 如果对象有Finalize方法, 那么Finalize方法就已经被调用了, 对象也不可能再复活了(resurrected).
==============
在我刚刚开始在有垃圾收集的环境中工作的时候, 我对于性能的很多地方不满意. 毕竟, 我做C/C++程序员已经超过15年了, 我了解从堆上直接分配和释放内存块的开销. 当然了, 每个版本的Windows以及每个版本的C运行时都为了提高性能而修改堆算法.
嗯, 跟Windows和C运行时的开发者一样, GC的研发人员也在不断地修改垃圾收集器以提高它的性能. 有一个垃圾收集器的特性纯粹是为了提高性能而存在的, 这就是generation. 一个分generation的垃圾收集器(也叫做ephemeral garbage collector), 会进行如下的假设:
当然, 很多研究表明这些假设对于现存的很大一部分应用程序来说, 都是正确的. 所以, 让我们讨论一下这些假设是如何影响到现存的垃圾收集器的吧.
在初始化的时候, 托管堆中没有任何对象. 新添加到堆上的对象被分为generation 0, 正如你在图表2中看到的那样, generation 0对象是还没被垃圾收集器检查过的年轻对象.
图表2: Generation 0
现在, 如果更多的对象添加到了堆中, 这时堆满了, 那么垃圾收集一定会发生. 当垃圾收集器分析堆的时候, 它创建关于垃圾和非垃圾对象的一张图. 任何在垃圾收集过程后生存下来的对象都会被整理到堆的最下面(left-most)位置. 这些对象已经经历过一次收集并存活了下来, 他们是较老的对象了, 所以现在他们被认为是generation 1。(看图表3).
图表3: Generations 0,1,2
随着更多的对象添加到堆中, 这些新的, 年轻的对象被放在generation 0当中. 如果generation 0 又满了, 那么垃圾收集就要再次执行. 这一次, 所有在generation 1中的存活下来的对象会被整理一下然后把他们看做是generation 2(看图表4). 所有在generation 0中存活下来的对象会被整理一下, 然后把他们看做是generation 1。 Generation 0当前不包含对象, 但接下来所有的新对象都要添加到generation 0中.
图表4: Generations 0,1,2
当前, generation 2是runtime垃圾收集器所支持的最高等级的generation了. 在未来的收集发生的时候, 任何在generation 2中存活下来的对象会仍然继续留在generation 2中.
====================
如同我们早先开始时候提到的, generational垃圾收集能够提高性能. 当堆满了并且垃圾收集开始发生的时候, 垃圾收集器能够选择只检验在generation 0中的对象, 并忽略存在于其他更老的generations中的对象. 毕竟, 对象越新, 我们预计它的生命期就越短. 所以, 回收和整理generation 0的对象更容易从堆中回收为数不少的内存空间, 而且比让回收器检验所有generations中的对象要更快.
这是可以从generational GC中获得的最简单的优化了. 一个generational的回收器可以通过不去遍历托管堆中的每一个对象来提供更多的优化. 如果一个root或object引用到了一个存在于老一些generation的中的对象, 垃圾收集器会忽略任何older object中的内部的引用, 这样可以减少用于构建reachable objects的图所需要的时间. 当然了, 老的对象引用新的对象这样的情况也是有可能的. 所以, 当这些老对象被分析的时候, 垃圾收集器可以采用操作系统支持的write-watch 特性(这项特性由在Kernel32.dll中的GetWriteWatch函数提供). 这项支持允许收集器知道哪个old objects(如果有的话)在上一次回收后被写修改了. 这些特定的写修改了的对象的reference可以被检查一下, 用于查看他们是否有引用到新的对象.
如果回收generation 0并不足以提供我们需要的内存的量, 那么收集器会尝试从generation 1和0中进行回收. 如果这样做也还不够, 那么垃圾收集器会从所有的generation中(包括二代, 一代, 和零代)进行回收. 至于收集器使用的用于确定需要从哪一个generation进行回收的精确算法, 微软是不会停止改进的.
大多数的堆(比如C运行时的堆)会在它们一旦找到空闲控件的时候立即分配的. 所以, 如果我连续不断地创建几个对象, 我分配的这些对象的地址空间可能会被成兆规模的地址分隔开. 然而, 如果在托管堆中, 连续地分配几个对象会保证他们在内存中拥有连续的地址空间.
我们之前就陈述过这样一个假设: 更新一点的对象之间趋向于拥有密切的关联关系, 并且会在差不多的相同的时间被频繁访问. 既然新对象被分配在连续的地址空间, 那么你就能获得由于引用相近地址而带来的性能提升. 更具体一点, 所有的这些对象更可能都存在于CPU的缓存当中. 你的应用程序会以惊人的速度访问这些对象, 因为CPU进行各种操作的时候不会有缓存丢失的情况, 也就不需要去强制访问RAM.
微软的性能测试显示出托管堆的分配比通过Win32 HeapAlloc执行的堆分配要更快. 这些测试还显示出在200Mhz的奔腾CPU上, 执行一个对generation 0的完全回收仅需要不到一毫秒的时间. 微软的目标是使得GC执行这个操作的时间跟一个普同的内存页错误(page fault)的时间相近.
================
System.GC类型允许你的应用程序对垃圾收集器进行一些直接的控制. 对初学者来说, 你可以通过读取GC.MaxGeneration 属性来了解托管堆所支持的最大的generation. 目前, GC.MaxGeneration属性一直返回2。
可以通过下面列出的两个方法来强制垃圾收集器进行回收:
void GC.Collect(Int32 Generation) void GC.Collect()
第一个方法允许你指定需要回收的generation. 你可以传递从包括0到GC.MaxGeneration之间的整数. 传0会引发generation 0被回收; 传递1会引发generation 0,1 都被回收; 传递2的话, 那0,1,2都会被回收. 不带任何参数的Collect方法会强制GC回收所有的generation, 该方法的调用效果跟下面的一样:
GC.Collect(GC.MaxGeneration);
大多数情况下, 你应该避免调用任何的Collect方法; 最好是让垃圾收集器按照自己的需要来进行回收. 然而, 既然你的应用程序知道自己的行为比运行时知道的要多, 你可以通过显示地强制执行某些回收来帮帮忙. 比如说, 对你的应用程序来说, 在用户保存了他的数据文件之后强制执行一次回收是合情合理的. 群我觉得Internet浏览器在页面卸载后执行一次完全回收也说得过去. 或许你还想要在你的应用程序执行执行其他冗长的操作的时候强制回收一次. 这样做可以隐藏回收需要的处理时间, 防止在用户与你的系统交互的时候发生回收.
GC类型还提供了一个WaitForPendingFinalizers方法. 这个方法简单地暂停调用的线程, 直到处理freachable队列的线程清空了queue并调用了每个对象的Finalize方法. 在多数应用程序里, 可能你永远都不需要调用这个方法.
最后, 垃圾收集器提供了两个方法, 允许你确定一个对象现在处于哪个generation中:
Int32 GetGeneration(Object obj) Int32 GetGeneration(WeakReference wr)
第一个版本的GetGeneration 以一个对象的引用作为参数, 第二个版本使用WeakReference引用作为参数. 当然, 这里返回的值包括从0到GC.MaxGeneration之间的所有值.
.图表5中的代码可以帮助你理解generation是如何工作的. 它还展示了我们刚刚讨论过的垃圾收集的方法如何使用.
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); }
图表5: GC Methods Demonstration
==================
在前面的部分, 我解释了GC算法和优化. 然而, 我们在讨论的时候做了一个非常大的假设: 只有一个线程在运行. 在真实世界里, 多个线程访问托管堆, 或者至少是操纵托管堆中分配的对象这样的情况是非常有可能发生的. 当一个线程开启了一个回收的时候, 其他线程必须禁止访问任何对象(包括在它自己的栈上的对象), 因为垃圾收集器要移动这些对象, 修改它们的内存地址.
所以, 当垃圾收集器想要开始回收的时候, 所有托管代码的执行都必须被暂停. Runtime拥有一些不同的机制, 用来安全地暂停线程, 以保证回收可以进行. 为什么要有多种机制呢? 其目的是保持线程能够运行至尽可能长一点的时间, 和尽可能地降低开销. 我不打算深入到这里所有的细节, 但是我要说为了在执行回收的时候降低开销, 微软已经做了大量的工作. 微软将来会继续修改这些机制来帮助保证高效的内存回收.
下面的段落描述了应用程序有多个线程的时候, 垃圾收集器使用的一些机制:
注意, thread hijacking能够允许正在执行非托管代码的线程在垃圾收集发生的时候继续执行. 这对于不访问托管对象的非托管代码来说不成问题, 除非对象被pin住并且不包含对象引用. 垃圾收集器是不允许移动被pin住的object的. 如果一个线程当前正在执行返回托管代码的非托管代码, 那这个线程就会被hihacked, 和暂停, 知道GC结束.
除了我提到的机制之外, 垃圾收集器还提供了一些额外的改进, 用以加强在多线程场景下的对象分配和回收的性能.
回收大尺寸对象(Garbage-collecting Large Objects)
===============
还有一个你可能会注意的性能提升. Large object(大对象)(超过20,000字节, 或更大的对象)是被分配在一个特别的大对象堆上的(large object heap). 在这个对上的对象会像我们之前讨论那样的finalized和free. 然而, 大对象是不会被整理的, 因为移动20,000字节的内存块会浪费太多的CPU时间.
注意所有的这些价值对你的应用程序代码而言都是透明的. 对你, 一个开发人员来说, 看起来好像就只有一个托管堆, 这些机制的存在仅仅是为了帮助提高应用程序的性能.
==============
微软的runtime团队创建了一系列的性能计数器, 用于提供很多runtime操作的实时统计数据. 你可以通过Performance Monitor来查看这些数据. 见图表6.
图表6: Adding Proformance Counters
图表7描述了每一个计数器的作用.
Counter |
Description |
# Bytes in all Heaps |
包括generation 0, 1, 2 , 和大对象堆在内的总计字节数. 这指示着垃圾收集器用于存储已分配对象的内存的大小. |
# GC Handles |
当前的GC的句柄. |
# Gen 0 Collections |
Generation 0的对象集合的总数. |
# Gen 1 Collections |
Generation 1的对象集合的总数. |
# Gen 2 Collections |
Generation 2的对象(最老的对象)集合的总数. |
# Induced GC |
由于显式调用引起的GC运行的总次数(比如类库调用), 不是由于分配时内存不足而引起的回收的次数. |
# Pinned Objects |
Not yet implemented. |
# of Sink Blocks in use |
同步基元需要使用sink block. Sink Block数据属于一个对象, 并且是按需要创建的. |
# Total committed Bytes |
所有堆上committed的字节数. |
% Time in GC |
自从上次采样依赖, 垃圾收集的时间占上次采样到现在的总时间的百分比. |
Allocated Bytes/sec |
垃圾收集器每秒分配字节的速率. 这仅会在垃圾收集的时候被更新, 并不是在每次分配的时候更新. 因为这是一个速率, 所以在GC之间的时候, 它会是0。 |
Finalization Survivors |
在垃圾收集过程中, 由于它们的finalizer创建了对他们的引用而存活下来的对象的数量. |
Gen 0 heap size |
generation 0堆的总字节大小. |
Gen 0 Promoted Bytes/Sec |
从generation 0升级为generation 1的速度, 字节数每秒. 当对象从一次垃圾收集中存活下来后, 它就会被升级. |
Gen 1 heap size |
generation 1堆的总字节大小. |
Gen 1 Promoted Bytes/Sec |
从generation 1升级为generation 2的速度, 字节数每秒. 当对象从一次垃圾收集中存活下来后, 它就会被升级. 没有对象可以从generation 2升级了, 因为generation 2是最老的. |
Gen 2 heap size |
generation 2堆的总字节大小. |
Large Object Heap size |
大对象堆的总字节大小. |
Promoted Memory from Gen 0 |
从垃圾收集过程中存活了下来的内存的字节数, 从generation 0升级为generation 1的字节数. |
Promoted Memory from Gen 1 |
从垃圾收集过程中存活了下来的内存的字节数, 从generation 1升级为generation 2的字节数.
|
图表7: Counters to Monitor
==============
这就是关于垃圾收集的全部了. 上个月我提供了很多背景知识, 包括关于资源是如何分配的, 自动垃圾收集时如何工作的, 如何使用finalization特性来允许对象自己清理, 还有resurrection特性是如何能够恢复对对象访问. 这个月, 我介绍了weak reference和strong reference是如何实现的, 如何将对象按generation来分类以提高性能, 还有你如何可以通过System.GC来手动地控制垃圾收集. 我还覆盖了垃圾收集器在多线程下的用于提高性能的一些机制, 以及如果对象大于20,000字节怎么办, 最后还介绍了你如何可以通过Performance monitor的计数器来监控垃圾收集器的性能. 拥有这些信息, 你就能够简化内存管理和大幅提高你的应用程序的性能了.
原文地址:
Garbage Collectionâ€"Part 2: Automatic Memory Management in the Microsoft .NET Framework