zoukankan      html  css  js  c++  java
  • 【C#进阶系列】21 托管堆和垃圾回收

    托管堆基础

    一般创建一个对象就是通过调用IL指令newobj分配内存,然后初始化内存,也就是实例构造器时做这个事。

    然后在使用完对象后,摧毁资源的状态以进行清理,然后由垃圾回收器来释放内存。

    托管堆除了能避免错误使用已经被释放的内存,也会减少内存泄漏,大多数类型都无需资源清理,垃圾回收器会自动释放资源。

    当然也有需要立即清理的,比如一些包含了本机资源的类型(如文件、套接字和数据库连接等),可在这些类中调用一个Dispose方法。(当然有的类对这个方法封装了一下,可能是别的名字比如断开数据库连接的close)

    在托管堆上分配资源

    CLR要求所有对象都从托管堆分配。

    进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还维护一个指针,即NextObjPtr。该指针指向下一个对象在堆中的分配未知。

    一个区域被非垃圾对象填满后,CLR会分配更多的区域,这个过程不断重复,直到整个进程地址空间都被填满。32位进程最多分配1.5G,64位进程最多分配8TB。

    当创建一个对象时,首先会计算该对象类型字段(包括基类)所需字节数,加上对象的开销所需的字节数(即类型对象指针和同步块索引)。

    然后CLR会检查区域中是否有分配对象所需字节数大小的内存。如果托管堆有,那么就在NextObjPtr指向的地址放入对象,且NextObjPtr会加上对象占用的字节数得到新值,即下个对象放入时的地址。

    通过垃圾回收器(GC)回收资源

    CLR在创建对象时发现没有足够内存分配该对象,那么就会执行垃圾回收。

    CLR在进行垃圾回收时,首先会暂停所有线程,

    标记阶段:然后CLR会遍历堆中所有的引用对象,将同步块索引字段中的一位设为0,表示所有对象都要删除。然后检查所有的活动根(即所有引用类型的字段以及方法的参数和局部变量),查看它们引用了哪些对象。任何根引用了堆上的对象,那么CLR就标记那个对象,将同步块索引字段中的位设为1,如果对象已经被标记为1了,那么就不再重新检查对象的字段。标记为1的也就是被引用的对象,称为可达的,标记为0的就是不可达的。此时CLR就知道了哪些对象可以删除,哪些对象不能删除。

    压缩阶段:CLR对堆中已标记的对象进行搬移内存位置(且对象所有的根的引用也自然会跟着变动),使得被标记的对象紧密相连,即占用连续的内存空间。这样不仅减小了应用程序的工作集,从而提升了访问性能,还得到了大量的未占用内存空间,并且解决了内存碎片化的问题。

    最后,恢复所有线程。

    静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地往集合添加数据项。静态字段使集合对象一直存活,而集合对象使所有数据项一直存活。因此应该尽量避免使用静态字段。(或者参照前面的玩法,当我们不用静态变量的时候,可以立马置为null,那么垃圾就会被回收)。

    有一个神奇的垃圾回收特例——Timer。原因是它会每隔一段时间去调用回调函数,但是根据之前学的垃圾回收玩法可以知道当Timer的变量离开了作用域,且没有其它函数引用了Timer对象,那么在垃圾回收时Timer就会被回收掉。也就不会去执行回调函数了。(所以说慎用Timer,这里有这么一个大坑)

    代:提升性能

    CLR的GC是基于代的垃圾回收器。它对代码做了如下假设:

    • 对象越新,生存期越短
    • 对象越老,生存期越长
    • 回收堆的一部分,速度快于回收整个堆

    第0代:新添加到堆的对象称为第0代对象,垃圾回收器从未检查过它们。

    第1代:第0代对象经过一次垃圾回收,但是并没有被当做垃圾释放掉,那么就会在压缩阶段一起放入第1代对象区域。

    第2代:第1代对象又经过了一次垃圾回收,但是并没有被当做垃圾释放掉,那么就会在压缩阶段一起放入第2代对象区域。没有第3代,第2代放着的就是经过了2次和2次以上垃圾回收的对象。

    第0代内存区域满了就会进行垃圾回收,此时不仅会回收第0代的区域,还会去判断第1代区域是否也满了,满了也回收第1代,不满的话即时第1代里面有不可达的对象,那么也不会回收第1代。

    CLR初始化时,会为这三代分别选择内存预算,以此判断什么时候该回收了。但是CLR的垃圾回收器是自调节的。

    也就是说

    如果垃圾回收器发现第0代回收后存活下来的对象很少,那么就会减少第0代的预算,这样的话垃圾回收就会发生得更频繁了,然而垃圾回收器每次做的事更少了,这减小了工作集。如果没有一个存活的话,连压缩都免了。

    如果垃圾回收器发现第0代回收后存活下来的对象很多,那么就会加大第0代的预算,这样的话垃圾回收就会发生得不频繁了,然而垃圾回收器每次回收的内存要多得多。(如果没有回收到足够的内存,那么垃圾回收器会执行一次完整回收,如果还是没有足够内存,那么就会抛出OutOfMemoryException异常)。

    上面是用第0代举例,第1、2代也如是。 

    垃圾回收触发条件

    CLR在检测第0代超过预算时会触发一次GC,这是GC最常见的触发条件,还有其它的触发如下:

    • 代码显示调用System.GC的静态Collect方法
    • Windows报告低内存情况
    • CLR正在卸载AppDomain
    • CLR正在关闭

    大对象

    CLR将对象分为大对象和小对象,以85000字节为界限。

    大对象不是在小对象的地址空间分配,而是在进程地址空间的其它地方分配。

    目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。(可能会造成空间碎片)

    大对象总是第2代,所以只能为需要长时间存活的资源生成大对象,否则若短时间存活的大对象放在第二代中,因为之前讲到一次回收过多内存,就会将代的预算减少,导致更频繁回收第2代,会损害性能。

    垃圾回收模式

    CLR启动时会选择一个GC模式,进程终止前该模式不会改变:

    • 工作站模式
      • 该模式针对客户端应用程序优化GC。GC造成延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。在该模式下,GC假定机器上运行的其它应用程序都不会消耗太多的CPU资源。 
    • 服务器模式
      • 该模式针对服务器端应用程序优化GC。被优化的主要是吞吐量和资源利用。GC假定机器上没有运行其它应用程序,并假定机器上所有的CPU都可以用来辅助完成GC。该模式造成托管堆被分为几个区域(section),每个CPU一个。开始垃圾回收时,垃圾回收器在每个CPU上都运行一个特殊线程,每个线程和其他线程并发回收它自己的区域。对于工作者线程(worker thread)行为一致的服务器应用程序,并发回收能很好地进行。这个功能要求应用程序在多CPU计算机上运行,使线程能真正地同时工作吗,从而获得性能上的提升。

    应用程序默认以工作站GC模式运行。寄宿了CLR的服务器应用程序(比如ASP.NET和Sql Server)可请求CLR加载“服务器”GC,但如果是单处理器计算机上运行,CLR将总是使用工作站GC模式。

    独立应用程序可在配置文件中,加上下面配置项告诉CLR使用服务器模式:

    <configuration>
      <runtime>
        <gcServer enabled="true"/>
      </runtime>
    </configuration>

    除了这两种模式,GC还支持两种子模式:并发(默认)和非并发。

    在并发模式下,GC有一个额外线程,能在运行时并发标记对象。

    而由另一个线程去判断是否压缩对象,GC可以更倾向于决定不压缩,有利于增强性能,但会增大应用程序工作集。使用并发垃圾回收器,消耗的内存比非并发更多。

    加上以下配置项告诉CLR使用非并发模式:

    <configuration>
      <runtime>
        <gcConcurrent enabled="false"/>
      </runtime>
    </configuration>

    使用需要特殊清理的类型

    大多数类型只需要内存就可以了,然而有的类型还需要本机资源。比如System.IO.FileStream类型需要打开一个文件(本机资源)并保存文件的句柄。

    包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源(GC对它一无所知)的泄漏,所以CLR提供了称为终结的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。

    任何包装了本机资源(文件,网络连接,套接字,互斥体)的类型都支持终结。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后GC会从托管堆回收对象。

    C#的语法,跟析构函数差不多,但是所代表的意义不同

     public class Troy {
            ~Troy() {
                //这里的代码就是垃圾回收前执行的代码,这段代码会被放在一个try块中,而finally部分放的是base.Finalize
            }
        }

    这个语法最后在IL代码里还是生成一个叫Finalize的方法。

    被视为垃圾的对象在垃圾回收完毕后才调用Finalize方法,所以这些对象的内存不是马上被回收,因为Finalize方法可能要执行访问字段的代码。

    可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长。这增大了内存耗用,所以应尽量避免终结。

    终结的内部原理

    在创建新对象的时候,会在堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放入到一个终结列表里。

    终结列表是一个由垃圾回收器控制的内部数据结构,列表的每一项都指向一个个对象——回收该对象的内存前应调用它的Finalize方法。

    在每次要回收垃圾对象时标记阶段走完都会去扫描终结列表,如果存在垃圾对象的引用,该引用被移除终结列表,并附加到freachable队列。(此时对象将不再被认为是垃圾,不能回收其内存,被称为对象复活了)

    freachable队列也是垃圾回收器的一个内部数据结构,队列中的每个引用所指向的对象都已经准备好调用Finalize方法了。

    CLR用一个特殊的、高优先级的专用线程调用Finalize方法来避免死锁。

    如果freachable队列为空,那么此线程睡眠,一旦不为空,此线程会被唤醒,将每一项都从队列中移除,并且同时调用每个对象的Finalize方法。

    然后进入压缩阶段,将这些复活的对象提升到下一代。

    然后清空freachable队列,并执行每个对象的Finalize方法。

    到了下次执行垃圾回收时,因为终结列表已经没有这些对象的指针了,所以现在它们被认为是真正的垃圾了,也就会被释放。

    整个过程中,执行了两次垃圾回收才释放掉内存,在实际的过程中,由于对象可能被提升至另一代,所以可能要求不止进行两次垃圾回收。

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

    CLR为每个AppDomain都提供了一个GC句柄表,允许应用程序监视和手动控制对象的生存期。这个就太6了,感觉用不到,用得到的时候回来再看吧。

    PS:

    最近两章效率真是慢,一方面因为双休没看书和一些突发状况,另一方面也是因为已经开始了CLR的核心机制之旅,里面的很多东西确实没听过,感觉难度开始增大了。

    在此过程中键盘莫名其妙坏了,并且两次关机废了写了一半的博客。今天才发现原来强行关机后再开机,浏览器中写了一半的博客是可以恢复的。

  • 相关阅读:
    防火墙iptables 设置
    CentOS 6.5系统中安装配置MySQL数据库
    判断服务是否开启,应用是否安装,并安装应用
    判断是移动端还是PC端
    二维码的生成细节和原理
    onclick 常用手册
    PHP json_encode函数中需要注意的地方
    利用PHP SOAP扩展实现简单Web Services
    Symfony2学习笔记之事件分配器
    听 Fabien Potencier 谈Symfony2 之 《What is Symfony2 ?》
  • 原文地址:https://www.cnblogs.com/vvjiang/p/5393669.html
Copyright © 2011-2022 走看看