目录
1、理解垃圾回收平台的基本工做原理
- 值类型(含全部枚举类型)、集合类型、String、Attribute、Delegate和Event所表明的资源无需执行特殊的清理操做。
- 若是一个类型表明着或包装着一个非托管资源或者本地资源(好比数据库链接、套接字、mutex、位图等),那么在对象的内存准备回收时,必须执行资源清理代码。
- CLR要求全部的资源都从托管堆分配。
- 进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初没有对应的物理存储空间。这个地址空间就是托管堆。托管堆还维护着一个指针,能够称为NextObjPtr。它指向下一个对象在堆中的分配位置。刚开始时,NextObjPtr设为保留地址空间的基地址。IL指令使用newobj建立一个对象。newobj指令将致使CLR执行如下步骤:
- 计算类型(及其全部基类型)所须要的字节数。
-
加上对象的额外开销的字节数——“类型对象指针”和“同步块索引”。
- CLR检查保留区域是否能分配出相应的字节数。若是托管堆有足够的可用空间,对象将被放入。注意对象这在NextObjPtr指针指向的地址放入的,而且为它分配的字节会被清零。接着,调用类型的实例构造函数(为this参数传递NextObjPtr),IL指令newobj将返回对象的地址。就在地址返回以前,NextObjPtr指针的值会加上对象占据的字节数,这样就会获得一个新的NextObjPtr值,它指向下一个对象放入托管堆时的地址。
- 托管堆之因此能这么作,是由于它作了一个至关大胆的假设——地址空间和存储是无限的。这个假设显然是荒谬的。因此,托管堆必须经过某种机制来容许它作这样的假设。这种机制就是垃圾回收。
- 对象不断的被建立,NextObjPtr也在不断的增长,若是NextObjPtr超过了地址空间的末尾,代表托管堆已满,就必须强制执行一次垃圾回收。
2、 垃圾回收算法
- 每一个应用程序都包含一组根。每一个根都是一个存储位置,其中包含指向引用类型对象的指针。该指针要么引用托管堆中的一个对象,要么为null。只有引用类型的变量才会被认为是根;值类型的变量永远不被认为是根。
- 垃圾回收开始执行时,它假设堆中全部对象都是垃圾。
- 第一个阶段为标记阶段。这个阶段,垃圾回收器沿着线程栈向上检查全部根。若是发现一个根引用了一个对象,就进行”标记”。该标记具备传递性。标记好根和它的字段引用的对象以后,垃圾回收器会检查下一个根,并继续标记对象。若是垃圾回收期试图标记先前已经标记了的根,就会中止沿着这个路径走下去。检查好全部根以后,堆中将包含一组已标记和未标记的对象。已标记的对象是经过应用程序的代码能够到达的对象,而未标记的对象是不可达的。不可达的对象就是垃圾,它们的内存是能够回收的。
- 第二个阶段为压缩(能够理解成"内存碎片整理")阶段。在这个阶段中,垃圾回收器线性遍历堆,以寻找未标记对象的连续内存块。若是这个内存块较小,垃圾回收器会忽略它们。反之,垃圾回收器会把非垃圾的对象移动到这里已压缩堆,其实在这是内存碎片整理或许更会适用。天然的,包含那些”指向这些对象的指针”的变量和CPU寄存器如今都会变得无效。因此,垃圾回收器必须从新访问应用程序的全部根,并修改它们来指向对象的新内存位置。堆内存压缩以后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾回收对象以后的位置。
- 因此,垃圾回收器会形成显著的损失,这是使用托管堆的主要缺点。固然,垃圾回收只在第0代满的时候才会发生。在此以前,托管堆性能远远高于C运行时堆。
3、垃圾回收与调试
- 当JIT编译器将方法的IL代码编译成本地代码时,JIT编译器会检查两点:定义方法的程序集在编译时没有优化;进行当前在一个调试器中执行。若是这两点都成立,JIT编译器在生成方法的内部根表时,会将变量的生存期手动延长至方法结束。
4、使用终结操做来释放本地资源
- 终结是CLR提供的一种机制,容许对象在垃圾回收器回收其内存以前执行一些得体的清理工做。
- 任何包装了本地资源的类型都必须支持终结操做。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收期判断一个对象是垃圾时,会调用对象的Finalize方法。
- C#团队认为,Finalize方法是编程语言中须要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。
Internal sealed class SomeType {
~SomeType(){
//这里的代码会进入Finalize方法
}
}
5. 编译上述代码,会发现C#编译器实际是在模块的元数据中生成一个名为Finalize的protected override方法。方法主体被放到try块中,finally块放入了一个对base.Finalize的调用。
6.实现Finalize方法时,通常都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。
5、对托管资源使用终结操做
- 永远不要对托管资源使用终结操做,这是有一种很是好的编程习惯。由于对托管资源使用终结操做是一种很是高级的编码方式,只有极少数状况下才会用到。
- 设计一个类型时,处于如下几个性能缘由,应避免使用Finalize方法:
- 可终结的对象要花费更长的时间来分配,由于指向它们的指针必须先放到终结列表中。("终结列表"在第七节会说到)
- 可终结对象会被提高到较老的一代,这会增长内存压力,并在垃圾回收器断定为垃圾时,阻止回收。除此以外,对该对象直接或间接引用的对象都会提高到较老的一代。("代"在第十三节会说到)
- 可终结的对象会致使应用程序运行缓慢,由于每一个对象在进行回收时,须要对它们进行额外操做。
- 咱们没法控制Finalize方法什么时候运行。CLR不保证各个Finalize的调用顺序。
6、是什么致使Finalize方法被调用
- 第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前致使调用Finalize方法最多见的一种方式。("代"在第十三节会说到)
- 代码显式调用System.GC的静态方法Collect 代码能够显式请求CLR执行即时垃圾回收操做。
- Windows内存不足 当Windows报告内存不足时,CLR会强制执行垃圾回收。
- CLR卸载AppDomain 一个ApppDomain被卸载时,CLR认为该AppDomain不存在任何根,所以会对全部代的对象执行垃圾回收。
- CLR关闭 一个进程结束时,CLR就会关闭。CLR关闭会认为进程中不存在 任何根,所以会调用托管堆中全部的Finalize方法,最后由Windows回收内存。
7、终结操做揭秘
- 应用程序建立一个新对象时,new操做符会从堆中分配内存。若是对象的类型定义了Finalize方法,那么在该类型的实例构造器调用以前,会将一个指向该对象的指针放到一个终结列表(finalization list)中。
- 终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象以前,会先调用对象的Finalize方法。
- 下图1展现了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被建立时,系统检测到这些对象的类型定义来了Finalize方法,全部指向这些对象的指针要添加到终结列表中。

- 垃圾回收开始时,对象B,E,G,H,I和J被断定为垃圾。垃圾回收器扫描终结列表以查找指向这些对象的指针。找到一个指针后,该指针会从终结列表中移除,并追加到freachable队列中。freachable队列(发音是“F-reachable”)是垃圾回收器的内部数据结构。Freachable队列中的每一个指针都表明其Finalize方法已准备好调用的一个对象。图2展现了回收完毕后托管堆的状况。

- 从图2中咱们能够看出B,E和H已经从托管堆中回收了,由于它们没有Finalize方法,而E,I,J则暂时没有被回收,由于它们的Finalize方法还未调用。
- 一个特殊的高优先级的CLR线程负责调用Finalize方法。使用专用的线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。当队列中有记录项时,该线程就会被唤醒,将每一项从freachable队列中移除,并调用每一项的 Finalize方法。
- 若是一个对象在freachable队列中,那么意味这该对象是可达的,不是垃圾。
- 本来,当对象不可达时,垃圾回收器将把该对象当成垃圾回收了,但是当对象进入freachable队列时,有奇迹般的”复活”了。而后,垃圾回收器压缩(内存脆片整理)可回收的内存,特殊的CLR线程将清空freachable队列,并调用其中每一个对象的Finalize方法。
- 垃圾回收器下一次回收时,发现已终结的对象成为真正的垃圾,由于应用程序的根再也不指向它,freachhable队列也再也不指向它。因此,这些对象的内存会直接回收。
- 整个过程当中,可终结对象须要执行两次垃圾回收器才能释放它们占用的内存。可在实际开发中,因为对象可能被提高到较老的一代,因此可能要求不止两次进行垃圾回收。图3展现了第二次垃圾回收后托管堆中的状况。

8、Dispose模式:强制对象清理资源
- Finalize方法很是有用,由于它确保了当托管对象的内存被释放时,本地资源不会泄漏。可是,Finalize方法的问题在于,他的调用时间不能保证。另外,因为他不是公共方法,因此类的用户不能显式调用它。
- 类型为了提供显式进行资源清理的能力,提供了Dispose模式。
- 全部定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。
9、使用实现了Dispose模式的类型
- 调用Dispose或Close只是为了能在一个肯定的时间强迫对象执行清理;这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即便一个对象已完成了清理,仍然可在它上面调用方法,但会抛出ObjectDisposedException异常。
- 建议只有在如下两种状况下才调用Dispose或Close:
- a) 肯定必须清理资源
- b) 肯定能够安全的调用Dispose或Close,并但愿将对象从终结列表中删除,禁止对象提高到下一代,从而提高性能。
10、C#的using语句
- 若是决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally中。这样能够保证清理代码获得执行。
- Using语句就是一种对第1点进行简化的语法。
11、手动监视和控制对象的生存期
- CLR为每个AppDomain都提供了一个GC句柄表。该表容许应用程序监视对象的生存期,或手动控制对象的生存期。
- 在一个AppDomain建立之初,该句柄表是空的。句柄表中的每一个记录项都包含如下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。
- 为了在这个表中添加或删除记录项,应用程序要使用以下所示的System.Runtime.InteropServices.GCHandle类型。
12、对象复活
- 前面说过,须要终结的一个对象被认为死亡时,垃圾回收器会强制是该对象重生,使它的Finalize方法得以调用。Finalize方法调用以后,对象才真正的死亡。
- 须要终结的一个对象会经历死亡、重生、在死亡的”三部曲”。一个死亡的对象重生的过程称为重生。
- 复活通常不是一件好事,应避免写代码来利用CLR这个”功能”。
十3、代
- 代是CLR垃圾回收器采用的一种机制,它惟一的目的就是提高应用程序的性能。
- 一个基于代的垃圾回收器作出了如下几点假设:
- 对象越新,生存期越短。
- 对象越老,生存期越长。
- 回收堆的一部分,速度快于回收整个堆。
- 代的工做原理:
- 托管堆在初始化时不包含任何对象。添加到堆的对象称为第0代对象。第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。图4展现了一个新启动的应用程序,它分配了5个对象。过会儿,对象C和E将变得不可达。

- CLR初始化时,它会为第0代对象选择一个预算容量,假定为256K(实际容量可能有所不一样)。因此,若是分配一个新对象形成第0代超过预算,就必须启动一次垃圾回收。假定对象A到E恰好占用256K内存。对象F分配时,垃圾回收器必须启动。垃圾回收器断定对象C和E为垃圾,由于会压缩(内存碎片整理)对象D,使其与对象B相邻。之因此第0代的预算容量为256K,是由于全部这些对象都能装入CPU的L2缓存,使之压缩(内存碎片整理)能以很是快的速度完成。在垃圾回收中存活的对象(A、B和D)被认为是第1代对象。第1代对象已经经历垃圾回收的一次检查。此时的对如图5所示。

- 一次垃圾回收后,第0代就不包含任何对象了。和前面同样,新对象会分配到第0代中。在图6中,应用程序继续运行,并新分配了对象F到对象K。另外,随着应用程序继续运行,对象B、H和J变得不可达,它们的内存将在某一个回收。

- 如今,假定分配新对象L会形成第0代超过256KB的预算。因为第0代达到预算,因此必须启动垃圾回收器。开始一次垃圾回收时,垃圾回收器必须决定检查哪些代。
- 前面说过,当CLR初始化时,他为第0代对象选择了一个预算。一样的,它还必须为第1代选择一个预算。假定为第1代选择的预算为2MB。
- 垃圾回收开始时,垃圾回收器还会检查第1代占据了多少内存。因为在本例中。第一代占据的内存远远小于2MB,因此垃圾回收器只检查第0代。由于此时垃圾回收器只检查第0代,忽略第1代,因此大大加快了垃圾回收器的速度。可是,对性能最大的提高就是如今没必要遍历整个托管堆。若是一个对象引用了一个老对象,垃圾回收器就能够忽略那个老对象的全部内部引用,从而能更快的构造好可达对象的图。
- 如图7所示,全部幸存下来的第0代对象变成了第1代的一部分。因为垃圾回收器没有检查第1代,因此对象B的内存并无被回收,即便它在上次垃圾回收时变得不可达。在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。

- 假定程序继续运行,并分配对象L到对象O。另外,在运行过程当中,应用程序中止使用对象G,I,M,是它们变得不可达。此时的托管堆如图8所示。

- 假设分配对象P致使第0代超过预算,垃圾回收发生。因为第1代中全部对象占据的内存仍小于2MB,因此垃圾回收器再次决定只回收第0代,忽略第1代不可达的垃圾(对象B和G)。回收后,堆的状况如图9所示。

- 从图9中能够看到,第1代正在缓慢增加。假定第1代的增加致使它全部对象占据的内存恰好达到2MB。这时,随着应用程序的运行,并分配了对象P到对S,使第0代对象达到了它的预算容量。这是的堆如图10所示。

- 应用程序试图分配对象T时,因为第0代已满,因此必须开始垃圾回收。可是,此次垃圾回收器发现第1代占据的内存超过了2MB。因此垃圾回收器此次决定检查第1代和第0代中的全部对象。两代都被回收以后,托管堆状况如图11所示。

4. 像前面同样,垃圾回收后,第0代的幸存者被提高到了第1代,第1代的幸存者被提高到了第2代,第0代再次空出来,准备迎接新对象的到来。第2代中的对象会通过2次或更屡次的检查。只有在第1代到达预算容量是才会检查第1代中的对象。而对此以前,通常已经对第0代进行了好几回垃圾回收。
5. CLR的托管堆只支持三代:第0代、第1代和第2代。第0代的预算约为256KB,第1代的预算约为2MB,第2代的预算容量约为10MB。
十4、 线程劫持
- 前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。
- 在现实开发中,常常会出现多个线程同时访问托管堆的状况,或至少会有多个线程同时操做堆中的对象。一个线程引起垃圾回收时,其它线程绝对不能访问任何线程,由于垃圾回收器可能移动这些对象,更改它们的内存位置。
- CLR想要进行垃圾回收时,会当即挂起执行托管代码中的全部线程,正在执行非托管代码的线程不会挂起。而后,CLR检查每一个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。
- 若是线程的指令指针刚好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。若是线程指令指针不在表中标记的偏移位置,则代表该线程不在安全点,CLR也就不会开始垃圾回收。在这种状况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。而后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。
- 然而,线程有时长时间执行当前所在方法。因此,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程的指令指针。若是线程已抵达一个安全点,垃圾回收就能够开始了。可是,若是线程尚未抵达一个安全点,CLR就检查是否调用了另外一个方法。若是是,CLR再一次修改线程栈,以便从最近执行的一个方法返回以后劫持线程。而后,CLR恢复线程,进行下一次劫持尝试。
- 全部线程都抵达安全点或被劫持以后,垃圾回收才能使用。垃圾回收完以后,全部线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。
- 实际应用中,CLR大多数时候都是经过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之因此如此,缘由是JIT生成表须要大量内存,会增大工做集,进而严重影响性能。
十5、大对象
- 任何85000字节或更大的对象都被自动视为大对象。
- 大对象从一个特殊的大对象堆中分配。这个堆中采起和前面小对象同样的方式终结和释放。可是,大对象永远不压缩(内存碎片整理),由于在堆中下移850000字节的内存块会浪费太多CPU时间。
- 大对象老是被认为是第2代的一部分,因此只能为须要长时间存活的资源建立大对象。若是分配短期存活的大对象,将致使第2代被更频繁地回收,进而会损害性能。