菜鸟之旅——.NET垃圾回收机制

  .NET的垃圾回收机制是一个非常强大的功能,尽管我们很少主动使用,但它一直在默默的在后台运行,我们仍需要意识到它的存在,了解它,做出更高效的.NET应用程序;下面我分享一下我对于垃圾回收机制(GC)的学习心得。

GC的必要性

  我们知道程序会需要向内存堆使用new请求内存,然后将请求的内存初始化并使用,使用完毕之后,变清理资源和释放内存,等待别的程序来请求使用;对内存资源的管理方式,现在存在这么几种管理方式:

  1、手动管理:C、C++

  2、计数管理:COM

  3、自动管理:.NET、JAVA、PHP

  现在的高级语言基本上都实现了自动管理内存,这是因为手动管理内存会因为人为的原因产生以下问题:

  1、开发人员忘记释放请求的内存,造成内存泄漏,若是内存泄露过多,则可能会造成内存溢出,导致程序无法运行;

  2、应用程序访问已释放的内存,造成数据读取错误。

  由此可见,手动去管理堆里面的内存可靠程度,会因开发人员的不同而不同,在C++因指针而出现的问题可不少;而且易出现Bug等乱七八糟的问题,影响系统稳定性,所以自动化管理内存是必要的。

GC的工作原理

 通用概念

  回收时机

  当应用程序分配新的对象,GC的代的预算大小已经达到阈值,比如GC的第0代已满;

  代码主动显式调用System.GC.Collect();

  其他特殊情况,比如,windows报告内存不足、CLR卸载AppDomain、CLR关闭,甚至某些极端情况下系统参数设置改变也可能导致GC回收。

  应用程序根

  应用程序根(application root):根(root)就是一个存储位置其中保存着对托管堆上一个对象的引用,根可以属性下面任何一个类别

  • 全局对象和静态对象的引用
  • 应用程序代码库中局部对象的引用
  • 传递进一个方法的对象参数的引用
  • 等待被终结(finalize,后面介绍)对象的引用
  • 任何引用对象的CPU寄存器

  代

  垃圾回收器将托管堆(heap)里面的对象划分为3个代(一般为3代),可以使用GC.MaxGeneration()方法来进行查询当前系统所支持的最大代数:

  1、G0 小对象(Size<85000Byte):新分配的小于85000字节的对象

  2、G1:在GC中幸存下来的G0对象

  3、G2:大对象(Size>=85000Byte);在GC中幸存下来的G1对象

  当一个对象被new的时候,它的代为0,经过一次回收之后,若该对象没有被回收,则代上升,变为1,若每次回收都幸存下来,则代都会上升,最大代为操作系统所支持的最大代。

  因为将对象以代划分,并且可以单独回收某一个世代,避免回收整个托管堆,提升性能。一个基于代的垃圾回收器有一下特点:

  1、对象越新,生存期越短;

  2、对象越老,生存期越长;

  3、回收堆的一部分,速度快于回收整个堆。

 工作过程

  标记对象

  在垃圾回收的第一步就是标记对象:垃圾回收器会认为托管堆中的所有对象都是垃圾,然后垃圾回收器会去检查所有的应用程序根,遍历每个根所引用到的对象,将其标记为活动的(live ),所有的根对象都检查完之后,有标记的对象就是可达对象,未标记的对象就是不可达对象,不可达对象就是回收的目标。

  弱引用对象则不在考虑范围之内,所以一定会被回收掉的。

  销毁对象,释放内存

  在经过第一步的对象筛选之后,回收没有被引用的对象,就是不可达对象,GC调用对象默认的终结器Finalize(),销毁对象之后,将内存也释放掉。

  同时,还存在引用的对象,就是可达对象的世代变为下一个世代。

  压缩堆内存

  经过第二步的销毁对象和释放内存之后,幸存下来的对象在堆中的排列可能是不连续的,这时在堆中存在非常多的内存碎片,程序在new对象的时候都是请求一段连续的内存,则内存碎片可能就无法再次利用(虽然没有被使用),造成内存资源的浪费,所以垃圾回收的最后一步就是压缩内存:将垃圾回收后幸存的对象移动到一起,并且将各个对象的引用更新到对象新的位置上,保证对象引用的正确性。

  注:从这里看得出,在压缩堆内存的时候,所有相关线程必须暂停,因为压缩时不能保证对象引用的正确性,所以在垃圾回收的时候,GC会劫持所有相关线程,在回收完毕之后,被劫持的线程才会正常工作,所以垃圾回收势必会影响一定的性能,所以慎用System.GC.Collect()。

 Finalize()与Dispose()

  上面说到,GC在回收对象的时候是调用对象的终结器Finalize()来实现的,那么,就简单的总结一下Finalize()与Dispose()吧:

  1、调用者:

    Finalize只能由GC调用

    Dispose由开发人员显示调用,也可以使用use区块,在程序离开区块使自动调用Dispose方法

  2、调用时机:

    Finalize由于是GC调用的,所以调用时机是垃圾回收的时候调用,时机不确定

    Dispose由于是显示调用,所以调用时机是确定的,在调用方法的时候就调用了

  3、目的:

    这里的目的主要说是Dispose出现的目的;

    首先是.NET存在托管资源和非托管资源,一般来说,非托管资源数量有限,比较珍贵,在使用完毕之后,希望能够释放掉,那么将释放非托管资源的方法写到终结器Finalize里面也是可以的,但是由于Finalize的调用时机不确定,导致释放资源不及时,那么有限的非托管资源很快就被占用完毕,所以,为了能够及时的释放掉这类资源,我们需要能够显示调用的方法,这就是Dispose。

    Finalize主要是为了GC释放托管资源和销毁对象,释放内存

    Dispose主要是为了释放托管和非托管资源和销毁对象,释放内存

  注:不必担心资源的重复释放问题,就算是重复释放,.NET也做好了相应措施来处理,不会抛出异常。

    下面贴一个MSDN推荐的标准的Dispose实现方式

    class Class : IDisposable
    {
        // 标识:是否释放托管资源
        private bool disposed = false;

        // 显示调用的方法
        public void Dispose()
        {
            Dispose(true);
            // 将对象从垃圾回收器链表中移除,
            // 从而在垃圾回收器工作时,只释放托管资源,而不执行此对象的析构函数
            GC.SuppressFinalize(this);
        }

        // 受保护的释放资源方法
        protected virtual void Dispose(bool disposing)
        {
            if (!this.disposed)
            {
                if (disposing)
                {
                    // 此处写释放托管资源的方法
                }
                disposed = true;

                // 此处写释放非托管资源的方法
            }
        }
        ~Class()
        {
            // 这里是防止忘记显示调用Dispose(),在GC进行垃圾回收的时候进行释放非托管资源
            Dispose(false);
        }
    }
View Code

 

总结

  GC所带来的便利是不言而喻的,但是这是付出一定的系统性能来实现的:在垃圾回收的时候GC会劫持所有相关的线程,并且会有一定的时空开销,所以在平时开发过程中注意一些良好的开发习惯可能会对GC有一些积极的影响。

  1、尽量不要new很大的对象,大对象(>=85000Byte)直接归为G2代,GC回收算法从来不对大对象堆(LOH)进行内存压缩整理,移动大对象将会消耗更多的CPU时间,也更容易造成内存碎片。这里也可以将大对象或者生命周期长的对象进行池化。

  2、不要频繁的new生命周期短的小对象,这可能会导致频繁的垃圾回收,这里可以考虑使用结构体放在栈中来代替,或者也可以使用对象池化来优化。

  3、不推荐使用对象池化的解决方案,它比较笨重和容易出错,设计一个高性能稳定的对象池并不容易。

  4、降低对象之间的纵向深度,GC在回收过程中,会先顺着根来进行对象遍历和标记,减少深度可以加快遍历速度;若系统中各个类之间的关系错综复杂,那么考虑一下设计方案是否合理。

  当然注意的地方还有不少,最后贴一篇博客,这里介绍了如何编写高性能的.NET代码,其中的GC介绍非常详细:

  [翻译]【目录】编写高性能 .NET 代码

   

posted on 2018-02-28 17:27  愉悦的绅士  阅读(1097)  评论(3编辑  收藏  举报