大学IT网 - 最懂大学生的IT学习网站! QQ资料交流群:367606806
当前位置:大学IT网 > C#技巧 > 自动内存管理(垃圾回收机制)

自动内存管理(垃圾回收机制)

关键词:内存管理垃圾回收机制CLR  阅读(769) 赞(20)

[摘要]本文是对CLR自动内存管理(垃圾回收机制)的讲解,对学习C#编程技术有所帮助,与大家分享。

目录

  • 理解垃圾回收平台的基本工作原理

  • 垃圾回收算法
  • 垃圾回收与调试

  • 使用终结操作来释放本地资源
  • 对托管资源使用终结操作

  • 是什么导致Finalize方法被调用

  • 终结操作揭秘

  • Dispose模式:强制对象清理资源

  • 使用实现了Dispose模式的类型

  • C#的using语句

  • 手动监视和控制对象的生存期

  • 对象复活

  • 线程劫持

  • 大对象

一、理解垃圾回收平台的基本工作原理

  1. 值类型(含所有枚举类型)、集合类型、String、Attribute、Delegate和Event所代表的资源无需执行特殊的清理操作。
  2. 如果一个类型代表着或包装着一个非托管资源或者本地资源(比如数据库连接、套接字、mutex、位图等),那么在对象的内存准备回收时,必须执行资源清理代码。
  3. CLR要求所有的资源都从托管堆分配。
  4. 进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初没有对应的物理存储空间。这个地址空间就是托管堆。托管堆还维护着一个指针,可以称为NextObjPtr。它指向下一个对象在堆中的分配位置。刚开始时,NextObjPtr设为保留地址空间的基地址。IL指令使用newobj创建一个对象。newobj指令将导致CLR执行以下步骤:
    1. 计算类型(及其所有基类型)所需要的字节数。
    2. 加上对象的额外开销的字节数——“类型对象指针”和“同步块索引”。

    3. CLR检查保留区域是否能分配出相应的字节数。如果托管堆有足够的可用空间,对象将被放入。注意对象这在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造函数(为this参数传递NextObjPtr),IL指令newobj将返回对象的地址。就在地址返回之前,NextObjPtr指针的值会加上对象占据的字节数,这样就会得到一个新的NextObjPtr值,它指向下一个对象放入托管堆时的地址。
  5. 托管堆之所以能这么做,是因为它做了一个相当大胆的假设——地址空间和存储是无限的。这个假设显然是荒谬的。所以,托管堆必须通过某种机制来允许它做这样的假设。这种机制就是垃圾回收。
  6. 对象不断的被创建,NextObjPtr也在不断的增加,如果NextObjPtr超过了地址空间的末尾,表明托管堆已满,就必须强制执行一次垃圾回收。

二、 垃圾回收算法

  1. 每个应用程序都包含一组根。每个根都是一个存储位置,其中包含指向引用类型对象的指针。该指针要么引用托管堆中的一个对象,要么为null。只有引用类型的变量才会被认为是根;值类型的变量永远不被认为是根。
  2. 垃圾回收开始执行时,它假设堆中所有对象都是垃圾。
    1. 第一个阶段为标记阶段。这个阶段,垃圾回收器沿着线程栈向上检查所有根。如果发现一个根引用了一个对象,就进行”标记”。该标记具有传递性。标记好根和它的字段引用的对象之后,垃圾回收器会检查下一个根,并继续标记对象。如果垃圾回收期试图标记先前已经标记了的根,就会停止沿着这个路径走下去。检查好所有根之后,堆中将包含一组已标记和未标记的对象。已标记的对象是通过应用程序的代码可以到达的对象,而未标记的对象是不可达的。不可达的对象就是垃圾,它们的内存是可以回收的。
    2. 第二个阶段为压缩(可以理解成"内存碎片整理")阶段。在这个阶段中,垃圾回收器线性遍历堆,以寻找未标记对象的连续内存块。如果这个内存块较小,垃圾回收器会忽略它们。反之,垃圾回收器会把非垃圾的对象移动到这里已压缩堆,其实在这是内存碎片整理或许更会适用。自然的,包含那些”指向这些对象的指针”的变量和CPU寄存器现在都会变得无效。所以,垃圾回收器必须重新访问应用程序的所有根,并修改它们来指向对象的新内存位置。堆内存压缩之后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾回收对象之后的位置。
  3. 所以,垃圾回收器会造成显著的损失,这是使用托管堆的主要缺点。当然,垃圾回收只在第0代满的时候才会发生。在此之前,托管堆性能远远高于C运行时堆。

三、垃圾回收与调试

  1. 当JIT编译器将方法的IL代码编译成本地代码时,JIT编译器会检查两点:定义方法的程序集在编译时没有优化;进行当前在一个调试器中执行。如果这两点都成立,JIT编译器在生成方法的内部根表时,会将变量的生存期手动延长至方法结束。

四、使用终结操作来释放本地资源

  1. 终结是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。
  2. 任何包装了本地资源的类型都必须支持终结操作。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收期判断一个对象是垃圾时,会调用对象的Finalize方法。
  3. C#团队认为,Finalize方法是编程语言中需要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。
Internal sealed class SomeType {

     ~SomeType(){

         //这里的代码会进入Finalize方法

    }

}

  5. 编译上述代码,会发现C#编译器实际是在模块的元数据中生成一个名为Finalize的protected override方法。方法主体被放到try块中,finally块放入了一个对base.Finalize的调用。

  6.实现Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。

五、对托管资源使用终结操作

  1. 永远不要对托管资源使用终结操作,这是有一种非常好的编程习惯。因为对托管资源使用终结操作是一种非常高级的编码方式,只有极少数情况下才会用到。
  2. 设计一个类型时,处于以下几个性能原因,应避免使用Finalize方法:
    1. 可终结的对象要花费更长的时间来分配,因为指向它们的指针必须先放到终结列表中。("终结列表"在第七节会说到)
    2. 可终结对象会被提升到较老的一代,这会增加内存压力,并在垃圾回收器判定为垃圾时,阻止回收。除此之外,对该对象直接或间接引用的对象都会提升到较老的一代。("代"在第十三节会说到)
    3. 可终结的对象会导致应用程序运行缓慢,因为每个对象在进行回收时,需要对它们进行额外操作。
  3. 我们无法控制Finalize方法何时运行。CLR不保证各个Finalize的调用顺序。

六、是什么导致Finalize方法被调用

  1. 第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前导致调用Finalize方法最常见的一种方式。("代"在第十三节会说到)
  2. 代码显式调用System.GC的静态方法Collect 代码可以显式请求CLR执行即时垃圾回收操作。
  3. Windows内存不足 当Windows报告内存不足时,CLR会强制执行垃圾回收。
  4. CLR卸载AppDomain 一个ApppDomain被卸载时,CLR认为该AppDomain不存在任何根,因此会对所有代的对象执行垃圾回收。
  5. CLR关闭 一个进程结束时,CLR就会关闭。CLR关闭会认为进程中不存在 任何根,因此会调用托管堆中所有的Finalize方法,最后由Windows回收内存。

七、终结操作揭秘

  1. 应用程序创建一个新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器调用之前,会将一个指向该对象的指针放到一个终结列表(finalization list)中。
  2. 终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象之前,会先调用对象的Finalize方法。
  3. 下图1展示了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被创建时,系统检测到这些对象的类型定义来了Finalize方法,所有指向这些对象的指针要添加到终结列表中。

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

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

八、Dispose模式:强制对象清理资源

  1. Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄漏。但是,Finalize方法的问题在于,他的调用时间不能保证。另外,由于他不是公共方法,所以类的用户不能显式调用它。
  2. 类型为了提供显式进行资源清理的能力,提供了Dispose模式。
  3. 所有定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。

九、使用实现了Dispose模式的类型

  1. 调用Dispose或Close只是为了能在一个确定的时间强迫对象执行清理;这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即使一个对象已完成了清理,仍然可在它上面调用方法,但会抛出ObjectDisposedException异常。
  2. 建议只有在以下两种情况下才调用Dispose或Close:
    1. a) 确定必须清理资源
    2. b) 确定可以安全的调用Dispose或Close,并希望将对象从终结列表中删除,禁止对象提升到下一代,从而提升性能。

十、C#的using语句

  1. 如果决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally中。这样可以保证清理代码得到执行。
  2. Using语句就是一种对第1点进行简化的语法。

十一、手动监视和控制对象的生存期

  1. CLR为每一个AppDomain都提供了一个GC句柄表。该表允许应用程序监视对象的生存期,或手动控制对象的生存期。
  2. 在一个AppDomain创建之初,该句柄表是空的。句柄表中的每个记录项都包含以下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。
  3. 为了在这个表中添加或删除记录项,应用程序要使用如下所示的System.Runtime.InteropServices.GCHandle类型。

十二、对象复活

  1. 前面说过,需要终结的一个对象被认为死亡时,垃圾回收器会强制是该对象重生,使它的Finalize方法得以调用。Finalize方法调用之后,对象才真正的死亡。
  2. 需要终结的一个对象会经历死亡、重生、在死亡的”三部曲”。一个死亡的对象重生的过程称为重生。
  3. 复活一般不是一件好事,应避免写代码来利用CLR这个”功能”。

十三、代

  1. 代是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能。
  2. 一个基于代的垃圾回收器做出了以下几点假设:
    1. 对象越新,生存期越短。
    2. 对象越老,生存期越长。
    3. 回收堆的一部分,速度快于回收整个堆。
  3. 代的工作原理:
    1. 托管堆在初始化时不包含任何对象。添加到堆的对象称为第0代对象。第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。图4展示了一个新启动的应用程序,它分配了5个对象。过会儿,对象C和E将变得不可达。
    2. CLR初始化时,它会为第0代对象选择一个预算容量,假定为256K(实际容量可能有所不同)。所以,如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假定对象A到E刚好占用256K内存。对象F分配时,垃圾回收器必须启动。垃圾回收器判定对象C和E为垃圾,因为会压缩(内存碎片整理)对象D,使其与对象B相邻。之所以第0代的预算容量为256K,是因为所有这些对象都能装入CPU的L2缓存,使之压缩(内存碎片整理)能以非常快的速度完成。在垃圾回收中存活的对象(A、B和D)被认为是第1代对象。第1代对象已经经历垃圾回收的一次检查。此时的对如图5所示。
    3. 一次垃圾回收后,第0代就不包含任何对象了。和前面一样,新对象会分配到第0代中。在图6中,应用程序继续运行,并新分配了对象F到对象K。另外,随着应用程序继续运行,对象B、H和J变得不可达,它们的内存将在某一个回收。
    4. 现在,假定分配新对象L会造成第0代超过256KB的预算。由于第0代达到预算,所以必须启动垃圾回收器。开始一次垃圾回收时,垃圾回收器必须决定检查哪些代。
    5. 前面说过,当CLR初始化时,他为第0代对象选择了一个预算。同样的,它还必须为第1代选择一个预算。假定为第1代选择的预算为2MB。
    6. 垃圾回收开始时,垃圾回收器还会检查第1代占据了多少内存。由于在本例中。第一代占据的内存远远小于2MB,所以垃圾回收器只检查第0代。因为此时垃圾回收器只检查第0代,忽略第1代,所以大大加快了垃圾回收器的速度。但是,对性能最大的提升就是现在不必遍历整个托管堆。如果一个对象引用了一个老对象,垃圾回收器就可以忽略那个老对象的所有内部引用,从而能更快的构造好可达对象的图。
    7. 如图7所示,所有幸存下来的第0代对象变成了第1代的一部分。由于垃圾回收器没有检查第1代,所以对象B的内存并没有被回收,即使它在上次垃圾回收时变得不可达。在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。
    8. 假定程序继续运行,并分配对象L到对象O。另外,在运行过程中,应用程序停止使用对象G,I,M,是它们变得不可达。此时的托管堆如图8所示。
    9. 假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中所有对象占据的内存仍小于2MB,所以垃圾回收器再次决定只回收第0代,忽略第1代不可达的垃圾(对象B和G)。回收后,堆的情况如图9所示。
    10. 从图9中可以看到,第1代正在缓慢增长。假定第1代的增长导致它所有对象占据的内存刚好达到2MB。这时,随着应用程序的运行,并分配了对象P到对S,使第0代对象达到了它的预算容量。这是的堆如图10所示。
    11. 应用程序试图分配对象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。

十四、 线程劫持

  1. 前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。
  2. 在现实开发中,经常会出现多个线程同时访问托管堆的情况,或至少会有多个线程同时操作堆中的对象。一个线程引发垃圾回收时,其它线程绝对不能访问任何线程,因为垃圾回收器可能移动这些对象,更改它们的内存位置。
  3. CLR想要进行垃圾回收时,会立即挂起执行托管代码中的所有线程,正在执行非托管代码的线程不会挂起。然后,CLR检查每个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。
  4. 如果线程的指令指针恰好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。如果线程指令指针不在表中标记的偏移位置,则表明该线程不在安全点,CLR也就不会开始垃圾回收。在这种情况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。然后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。
  5. 然而,线程有时长时间执行当前所在方法。所以,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程的指令指针。如果线程已抵达一个安全点,垃圾回收就可以开始了。但是,如果线程还没有抵达一个安全点,CLR就检查是否调用了另一个方法。如果是,CLR再一次修改线程栈,以便从最近执行的一个方法返回之后劫持线程。然后,CLR恢复线程,进行下一次劫持尝试。
  6. 所有线程都抵达安全点或被劫持之后,垃圾回收才能使用。垃圾回收完之后,所有线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。
  7. 实际应用中,CLR大多数时候都是通过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之所以如此,原因是JIT生成表需要大量内存,会增大工作集,进而严重影响性能。

十五、大对象

  1. 任何85000字节或更大的对象都被自动视为大对象。
  2. 大对象从一个特殊的大对象堆中分配。这个堆中采取和前面小对象一样的方式终结和释放。但是,大对象永远不压缩(内存碎片整理),因为在堆中下移850000字节的内存块会浪费太多CPU时间。
  3. 大对象总是被认为是第2代的一部分,所以只能为需要长时间存活的资源创建大对象。如果分配短时间存活的大对象,将导致第2代被更频繁地回收,进而会损害性能。


相关评论