C# 学习笔记:垃圾回收

关于C#的垃圾回收,C#与其他更底层的C/C++相比,引入了GC,即垃圾收集器。

我们要在堆中创建一个类型,常常要使用new关键字来创建一个在托管堆上的实例,那么我们一个实例既然生出来了,总得要把它们收拾起来,在C++中,需要程序员手动管理内存,通过指针去动态管理。而在dotNet下C#和JVM下java则引入了垃圾收集器,我们平常写的代码生成的实例都是由它来帮我们“擦屁股”的。

dotNet使用CLR(Common Language Runtime)来管理内存,CLR是用于内存管理,程序集加载,安全性,异常处理,线程同步的一种运行环境,许多微软的语言都是面向CLR的(这个是个巨坑我以后慢慢讲)。

GC的意思即为:垃圾收集器。这个垃圾是对内存而言的,在dotNet程序运行时,程序创建出来的对象会被CLR跟踪,然后整理出哪些对象丧失了引用,然后在一个时机后进行清除。

GC算是个C#在内存方面的入口,我整理了一下关于GC的知识点的结构,做了个图,这篇博客就按照这张图的知识点来讲:


GC垃圾收集的步骤

就如同我们拿扫把要扫垃圾一样,总要有个步骤,我们首先要知道,要扫哪个屋子(哪块内存),屋子里哪些是垃圾,我要如何扫掉它。

对于托管堆中的中某一个对象实例,如果不存在任何引用指向它,它就是GC要清除的对象

标记压缩算法  Mark-Sweep-Compact

标记压缩算法步骤分别是:线程挂起——确定roots位置生成“图表”——标记可到达对象——清除无标记对象——压缩托管堆——修复指针。

1.线程挂起:

C#中在进行GC之前,所有的线程都会被挂起等待,除了触发垃圾回收所用的线程以外。可以想象成一个人打扫屋子的时候其他屋子里的人都不能轻举妄动。

2.确定根对象root位置生成存在引用的对象的“树图”:

root是CLR在Heap之外能找到的入口点,GC搜索的root根对象由实时 (JIT) 编译器堆栈查看器提供,还包括全局对象、静态变量、函数调用参数、CPU寄存器的对象指针、析构队列都会被检查。

通过root来遍历所有的对象,然后生成一张树图表。对于堆中的对象来说,引用关系是错综复杂的,可能存在交叉引用循环引用,而GC能很好的检测这些关系,不会将这些复杂的网状引用整体删除

通过图表,GC将托管堆内存中对象分为两类:

  • Reachable Objects:根据对象引用关系,通过root可以到达的对象。
  • Unreachable Objects:根据对象引用关系,通过root找不到但是在堆中存在的对象。

3.标记树图上的对象(Mark):

将Reachable Object(可到达对象)打上标记,Unreachable Object则不作处理。 

4.清理剩下没有被标记的对象(Sweep)

5.压缩托管堆(Compact):

托管堆空间是连续的,但是我们回收一部分对象后堆内存变得不连续,GC将移动剩下的对象来来让它们重新从托管堆基地址开始连续排列,剩余出了一段连续的堆内存。避免了内存碎片的产生,提高接下来托管堆的分配速率。

在GC中,剩下的较大的对象(large object heap)则不会进行压缩,而且,较为庞大而复杂的对象往往生存周期都比较长,移动它们所带来的开销往往大于我们收集垃圾带来的开销,所以当一个较大的对象被创建的时候,所保存的区域也是较为特殊的。

6.修复指针:

我们在压缩托管堆对象时将它们在栈中移动了位置,例如我们移动了D,那么同样的,栈中对于这个对象D的引用、CPU寄存器的指针、托管堆其他对象对D的引用都要相应的修复,来与压缩前一致。

分代算法

GC除了使用标记压缩算法进行垃圾收集,还使用了分代的原则。

分代算法有一条标准:越晚创建的对象越有可能被弃用,因此,GC将托管堆对象进行分代。将对象按照生命周期,分为Gen 1Gen 2Gen 3三个代龄。一般来说,对象存活的时间越久,代龄越高。

对不同的代龄的对象,可以采取不同的回收策略与算法,相较于代龄高的对象,代龄低的对象回收处理的力度更大。

这样可以争取在较短的时间间隔和较小的内存区域中以较低成本将大量新创建的不再使用的局部对象回收掉。

分代算法的诞生前提是:

  • 大量新创建的对象声明周期较短,较老对象声明周期更长。
  • 对部分内存回收要比全部内存回收更快
  • 新创建的对象关联程度要更强,由于托管堆是连续地分配对象的,关联度较强有利于提高CPU cache命中率。

CLR初始化后,第一批被创建的对象被称为0代对象,CLR会为0代对象设定一个容量限制,当创建的对象数量和大小超过设定的限制时,GC就会开始工作,工作范围是0代所处的内存区域,称为Gen 1。在这次GC之后幸存对象将被列为Gen 1而保存在第一代区域里面。之后,后续的新创建的对象仍被认为是第0代对象。第0代对象再次被填满时,在0代对象区会进行新一轮的GC,在这轮清晰中幸存的对象又被放入第一代对象。但第一代对象区也有不堪重负的时候,这个时候GC会扩大范围,将Gen 0和Gen 1都进行清洗,清洗完成后幸存的对象放入Gen 2区域。下面这张图就说明了这个情况:

对于托管堆代龄区域Gen1、Gen2、Gen3这三个代龄区域的GC我们总结一下,如下文:

  • 如果Gen 0的内存达到阈值,将触发Gen 0 GC,0代GC之后的Gen 0中被标记的对象进入Gen 1
  • 如果Gen 1的内存达到阈值,将触发Gen 1 GC,它会连同Gen 0一起GC,两个代区的幸存对象进入Gen 3。
  • 如果Gen 2的内存达到阈值,将触发Gen 2 GC,它是一锅端,连同前面的Gen 1和Gen 0一起回收,也称为Full GC,由于Gen 2往往里面都是一些“历经两次GC存活的遗老遗少”,占用的内存往往较为庞大,所以Gen 2 GC耗费的成本很高。

在dotNet运行期间,Gen 2、Gen 1、Gen 0的GC频率大致为:1:10:100。Gen 0是最惨的,不管哪一代发生GC它都要被检查。

由此可见,即使有些符合垃圾定义的对象,如果在之前的GC中还存在引用,存活下来进入了Gen1或者Gen 2,只要这两个代龄区域没有满,那么GC也是管不了它们的。

GC的软肋

GC上文介绍了GC的两个算法,虽然看起来很智能,啥都考虑到了,但它也有一些很关键的缺点:

  • GC并不是实时而且触发条件清晰的,我们很难知道啥时候GC开动了,这样就会造成性能上的瓶颈和不确定性。这个在上文中有所体现,我们不知道什么时候哪个代龄区会满。
  • GC并不是“万能”的,它只对托管资源有用,对非托管资源没用。

那么,什么是托管资源什么是非托管资源呢?

托管资源:顾名思义,即我们线程栈和托管堆中的值类型和引用类型变量,这些资源堆和栈都帮我安排好了。

  • 线程栈中的资源随着线程栈先进后出,在退出作用域以前自然而然的被销毁掉了。相当于栈帮我们做好了一切工作。
  • 托管堆中的资源受到GC的管辖,这在我们上文中已经有体现。

非托管资源 例如文件,窗口或网络连接这些包装操作系统资源的对象,例如以下的各种资源

ApplicationContext, Brush, Component, ComponentDesigner, Container, Context, Cursor, FileStream, Font, Icon, Image, Matrix, Object, OdbcDataReader, OleDBDataReader, Pen, Regex, Socket, StreamWriter, Timer, Tooltip, 文件句柄, GDI资源等等

虽然GC可以跟踪封装非托管资源的对象的生存期,但GC不理解具体如何清理这些资源

对于非托管资源,GC也提供了方法来释放他们:Finalize析构函数IDisposable接口。


Finalize析构函数

C++中析构函数和构造函数形影不离,但是在C#中析构函数却不是很普遍,我很少能在类中写析构函数,但是对于内存中的非托管资源来说,析构函数非常的重要。

在C++中,常用析构函数释放内存资源,当对象超出声明周期立马调用析构函数。但是在C#中有了GC,情况就发生了改变:

析构函数不能被手动调用。在GC触发的时候才会由GC调用(如果这个类有析构函数的话)

所以C#析构函数的官方定义即:在垃圾回收将某一对象回收前允许该对象尝试释放资源并执行其他清理操作的函数

它的声明格式仍然和C++类似:

class ExampleClass
{
    ~ExampleClass()
    {
         ........
    }
}

当我们调用一个类的析构函数后,默认调用了它的基类的析构函数,最终直到继承链上最后一个类。

C#不是所有类都是Object的派生类吗?自然Object里也有析构函数,当我们调用了子类的析构函数,在子类逻辑执行完后自动地调用了父类的析构函数,直到调用到Object的析构函数。

我们写一个例子,就能看到析构函数的作用了:

    class Program
    {
        static void Main()
        {
            Third third = new Third();

            for (int i = 0; i < 3; i++)
            {
                Console.WriteLine("过去了一秒。。。。。");
                Thread.Sleep(1000);
            }
        }
    }
    class First
    {
        public First()
        {
            Console.WriteLine("第一层类已创建");
        }
        ~First()
        {
            Console.WriteLine("第一层类已销毁");
        }
    }
    class Second:First
    {
        public Second()
        {
            Console.WriteLine("第二层类已创建");
        }
        ~Second()
        {
            Console.WriteLine("第二层类已销毁");
        }
    }
    class Third:Second
    {
        public Third()
        {
            Console.WriteLine("第三层类已创建");
        }
        ~Third()
        {
            Console.WriteLine("第三层类已销毁");
        }
    }

 

我们可以看出,当线程结束后,栈上的Third类对象引用被销毁,GC也开始调用Third对象实例的析构函数,即我们输出看到的样子。

但是Finalize又是什么东西呢,实际上,在C#编译器中析构函数被隐式的转为了以下代码:

        protected override void  Finalize()
        {
            try
            {
                .....
            }
            catch
            {

            }
            finally
            {
                base.Finalize();
            }
        }

这就是Finalize的由来,当然我们是不能这样去写析构函数的,只能用与C++类似的符号~ 类名来写析构函数,如果我们在类中直接重写Finalize函数就会报错:

同样的,基类的析构函数在子类中也不能碰:

GC调用析构函数实现:Finalization Queue(终结队列) 、Freachable Queue(复生队列)

C#中一般是不写析构函数的,当我们写的类没有明确指定析构函数时,GC将自动将其回收。

但是对于存在析构函数的类,GC会对其如下操作:

  1. 当使用new操作符在托管堆上分配空间时,GC会在Finalization Queue(终结队列)中添加一个指向该对象的指针。
  2. GC开动后,在没有标记的“垃圾”里面搜索存在被Finalization Queue指针指向的对象。
  3. 若存在这样的对象,则将该对象的指针移至Freachable Queue(复生队列)中。
  4. Freachable Queue中一旦添加了指针,就会在新线程中触发所指对象的析构函数,然后再让该指针出队。(注意,此时GC所在的线程已经开始“清洗”了,二者同步发生)。
  5. 该指针出队后,指针指向的对象在下一次被GC检查到时才会被回收(上一次GC它被拉出来搞析构了)。

将有Finalize的类对象指针移至Freachable Queue的过程称为“对象的复生”。我们可以理解成Freachable Queue将对象“救活了”,只有当该对象的Finalize方法(析构函数)执行完毕后才会在下一次GC周期中死去。

注意:

对于析构函数我们需要注意以下几点:

Finalize析构函数常常用来释放非托管资源。而且是由GC调用。

Finalize析构函数的代价非常大,而且每次写析构函数都其实浪费了性能,因为该对象的生存周期被延后,每次GC肯定都会对Gen 0进行检查,但是Gen 1和Gen 2就未必了。

Finalization的代价:需要Finalization的对象可能比不需要Finalization的对象在内存中停留额外9个GC周期。如果此时它还没有被Finalize,就变成第2代对象,从而在内存中停留更长时间。一个带有Finalize函数的对象至少会经历两次垃圾回收。

1.结构体是没有析构函数的,原因很简单,结构体在线程栈中。你不能拿托管堆的剑斩线程栈的官。

2.GC执行析构函数的准确时间不确定,不保证在特定的时间内释放资源,我们使用析构器是无法知道啥时候GC是被调用的(除非你的程序运行结束),有可能很长的一段时间里都没触发GC。所以尽量用它来清理非托管资源而不是托管资源。

3.调用GC的线程不会去保证各个没有继承关系的对象的Finalize方法的调用顺序。这可能带来依赖性的问题。例如一个析构函数里面调用了对象A,但是对象A可能已经被销毁了。

4.不要写一个空析构函数,一如上文所讲,对于存在Finalize的类对象GC都会生成一个指针保存到Finalize Queue中,然后在Freachable Queue中执行操作,那么我们一个析构函数没有任何逻辑,会无端地耗费系统调用它的资源。

5.虽然Finalize函数不能以非析构函数的形式写,但是可以不声明override来声明一个Finalize函数,我们下面的例子可以见得:

我们删除上面那个例子的所有构造函数,仅仅留下析构函数,在Third的类中写一个Finalize函数,此时VS只会警告而不会报错:

    class Program
    {
        static void Main()
        {
            Third third = new Third();
            for (int i = 0; i < 3; i++)
            {
                Console.WriteLine("过了一秒钟");
                Thread.Sleep(1000);
            }
        }
    }
    class First
    {
        ~First()
        {
            Console.WriteLine("第一层类已销毁");
        }
    }
    class Second:First
    {
        ~Second()
        {
            Console.WriteLine("第二层类已销毁");
        }
    }
    class Third:Second
    {
        public void Finalize()
        {
            Console.WriteLine("第三层类已销毁");
        }
    }

出人意料的是,Third自己的析构函数没有被调用,但是它的父类的析构函数都会被调用,这可以算是一个C#的bug,当我们这样写的时候会出现一行警告:

只有当我们另外显示声明了标准格式的析构函数,这个时候才会报错。那这样就会激起一些人的野心了:既然有这样的public的方法还会触发父类的析构,岂不是表明用户可以绕过GC来调用析构函数嘛?但如果我们在上面的代码中显式调用Third的析构函数,结果是:

我们可以看到,这样的Finalize只是一个普通的成员函数了,它不存在和析构和GC有任何交集,一个类你不写析构他的父类的析构在GC的时候还是要调用的,我们不可能手动的去触发析构。


IDisposable接口

析构函数虽然可以解决非托管类型的自动清理问题,但它还是与GC息息相关。

我们不知道什么时候GC会去调用,所以也不知道如何准时准确的清理资源,还会产生性能上的诸多问题。

而IDisposable接口让我们可以显式的执行非托管资源的清理。

IDisposable接口为实现接口的资源类提供Dispose。我们可以将Dispose看成是公共的约定,所以程序员可以直接调用该方法来释放非托管资源占用的内存。

我们在清理非托管资源的时候,手动调用接口的Dispose方法就行。

    class Program
    {
        static void Main()
        {
            IDisposable test = new ExampleClass();

            test.Dispose();
        }
    }
    class ExampleClass : IDisposable
    {
        void IDisposable.Dispose()
        {
            //此处是清理非托管资源的逻辑
            GC.SuppressFinalize(this);
        }
        ~ExampleClass()
        {
            //此处是清理非托管资源的逻辑
        }
    }

关于IDisposable与Finalize析构函数我们需要注意:

  • 在代码中尽量使用IDisposable接口来清理非托管资源而将Finalize析构函数作为在未能调用 Dispose 方法的情况下充当兜底的防护措施来清理非托管资源。
  • 在Dispose执行完了后,应该调用 GC.SuppressFinalize();表示这里不需要再次执行Finalize析构函数,以免造成性能的浪费。SuppressFinalize形参即为Object对象。

System.GC API

C#中的GC并非在在代码中毫无体现,C#为我们准备了GC的相关API:

GC里面内置的功能不少,这里可以查看它们。我们这里着重写它三个比较重要的方法:

方法名称功能
GC.Collect()手动强制对所有代都进行垃圾回收
GC.Collect(int)手动强制从0代到int代进行垃圾回收
GC.SuppressFinalize(Object)要求GC不执行该对象的Finalize函数  
GC.KeepAlive(Object)引用指定对象在KeepAlive方法执行前都不会受到GC的清除。

这几个方法是GC里面比较常用的,我们写一个例子看看:

    class Program
    {
        static void Main()
        {
            GCCollect collect = new GCCollect();
            Console.WriteLine("GC最大代龄是" + GC.MaxGeneration);

            Console.WriteLine("collect开始造垃圾===============================");
            collect.MakeSomeGarbage();

            //使用GetGeneration可以获得一个对象当前的代龄
            Console.WriteLine("现在我们的Collect对象代龄是" + GC.GetGeneration(collect));
            //使用GetTotalMemory可知当前GC当前认为要分配的字节数,里面的boolean用于是否立即返回
            Console.WriteLine("collect实际上占用了多少内存" + GC.GetTotalMemory(false));


            Console.WriteLine("GC启动===============================");
            GC.Collect(0);

            Console.WriteLine("现在我们的Collect对象代龄是" + GC.GetGeneration(collect));

            Console.WriteLine("collect实际上占用了多少内存" + GC.GetTotalMemory(false));


            Console.WriteLine("GC启动===============================");
            GC.Collect(2);

            Console.WriteLine("现在我们的Collect对象代龄是" + GC.GetGeneration(collect));

            Console.WriteLine("collect实际上占用了多少内存" + GC.GetTotalMemory(false));
        }
    }
    class GCCollect
    {
        private const long maxGarbageCum = 1000;
        public void MakeSomeGarbage()
        {
            GarbageClass garbage;
            for (int i = 0; i < maxGarbageCum; i++)
            {
                garbage = new GarbageClass(i);
            }
        }
    }
    class GarbageClass
    {
        private int i;
        public GarbageClass(int t)
        {
            i = t;
        }
    }

我们创建一个类,里面有一个造垃圾函数,然后执行两次强制GC,我们可以看到效果:

最开始的内存是46396,GC之后清理了没有引用的实例,变成了39684,再次清理后变成了39608,占用的内存在逐步减小。并且在每一次GC之后,Collect的代龄都增加了。

在使用System.GC的功能时我们要注意:

GC.Collect只是尝试进行未引用的对象的垃圾回收,并不保证能回收指定代龄中所有无法访问的内存。

虽然提供了GC的强制垃圾回收,但是并不推荐我们自己手动执行垃圾回收。除非我们非常明确什么时候会产生大量的垃圾。

GC在执行前,会判断上一次收集对象的数量,如果释放的内存很多,那么就会尽快执行第二次GC。如果频繁的回收但是释放内存不多时,就会减慢GC的频率。

并且由于GC会将当前线程挂起,会延缓当前线程。

所以尽量不要使用GC.Collect来破坏GC的执行策略。


弱引用 WeakRenference

在GC中除了使用GC的API以外,我们可以创建弱引用类型来让对象来适配GC。

弱引用:允许应用程序访问该对象,也允许GC来回收该对象。

我们上文讲过,GC将会回收堆内存中不存在引用的对象。但这些描述中的“引用”都是强引用,强引用可以理解为直接引用,在代码中非常普遍,即:

object t = new object();

这里的t就是一个object类型实例的直接引用 。

那么弱引用,即为使用WeakReference包装的引用。弱引用的常用的API有:

成员功能
isAlive 获取当前对象是否已经被垃圾回收的指示(bool)
Target获取当前弱引用所引用的对象(Object)
WeakRenference构造函数默认形参为(Object,bool)

我们写一个例子:

    class Program
    {
        static void Main()
        {
            WeakReference weak = new WeakReference(new LoseTest("矿泉水"));
            if(weak.IsAlive)
            {
                LoseTest test = weak.Target as LoseTest;
                Console.WriteLine("当前弱引用的字段是" + test.InstanceName);
            }
        }
    }
    class LoseTest
    {
        public string InstanceName;
        public LoseTest(string name)
        {
            InstanceName = name;
        }
    }

我们的对象实例可以用weak来包装它,由于GC随时可能将弱引用对象回收,所以每次调用弱引用的时候都需要使用弱引用的isAlive字段进行判断是否弱引用还存在。它输出的结果是:

由于存在Finalize函数的对象在进行GC时流程有所变化,所以C#也将弱引用根据Finalize的有无适配为长弱引用和短弱引用:

短弱引用与长弱引用

弱引用的构造函数参数列表第一个值为要包装成弱引用的对象。第二个值为trackResurrection,表明弱类型是否跟踪对象复活状态。如果为true,即长弱引用。若为false,即为短弱引用。它的默认值是false。

短弱引用:也称为简短弱引用。若设定trackResurrection为false,即短弱引用

当GC回收短弱引用时,GC会将这个短弱引用的Target置为null,即此时弱引用与包装的对象失去联系。

长弱引用:也称为完整弱引用。

长弱引用对象如果存在Finalize函数。和其他强引用对象一样,GC时它会进入Freachable Queue队列,即“从死亡边缘拉回来”,继续存活。但此时长弱引用将继续跟踪这个复活后的对象。即弱引用的Target仍然为我们构造弱引用时表明的对象。即二者仍然关联。

长弱引用对象如果不存在Finalize函数,就会按照短弱引用的规则办事,在GC时与所包装的对象失去联系。Target=null。

关于弱引用我们需要注意的:

  • 弱引用适用于占用较大内存,但可以在透过GC对其进行回收时轻松地予以重建的对象。
  • 由于弱引用受到GC的管辖,但GC的触发时间并不唯一,所以弱引用的类型在使用时需要先使用isAlive来判断它是否还存在。
  • 我们使用GC.Collect并不保证能清除弱引用,所以在手动强制GC时并不一定会将弱引用删除,GC.Collect()本身就是不可靠的用法。
  • 我们无法假定什么时候弱引用会被回收,正如我们无法假定GC什么时候会启动一样。

这篇文章写了两天,其实就是当这些知识点的“二道贩子”,但是即使是二道贩子当起来也是不轻松的。我其实每次看到专业文章精力都不太集中,但我觉得看图来了解知识结构可能比较便捷。当然我写的这些也只是皮毛,真正有用的来自于实战。这篇文章比较长,如果您完整的看完了这篇文章,可以看看这最开始划定的结构:

上面的文章把这些知识点讲完了,最后总结一下我们为什么要用GC:

  1. 提高了软件开发抽象度。
  2. 减少程序员在内存上的操心。
  3. 减少了人为管理内存不当带来的bug
  4. 使内存管理更加高效。

参考文档: 

https://www.cnblogs.com/nele/p/5673215.html

https://docs.microsoft.com/zh-tw/dotnet/api/system.gc?view=netframework-4.8

https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值