欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

.NET垃圾回收 – 原理浅析

程序员文章站 2022-04-12 20:29:31
在开发.NET程序过程中,由于CLR中的垃圾回收(garbage collection)机制会管理已分配的对象,所以程序员就可以不用关注对象什么时候释放内存空间了。但是,了解垃圾回...
在开发.NET程序过程中,由于CLR中的垃圾回收(garbage collection)机制会管理已分配的对象,所以程序员就可以不用关注对象什么时候释放内存空间了。但是,了解垃圾回收机制还是很有必要的,下面我们就看看.NET垃圾回收机制的相关内容。

 

创建对象

在C#中,我们可以通过new关键字创建一个引用类型的对象,比如下面一条语句。New关键字创建了一个Student类型的对象,这个新建的对象会被存放在托管堆中,而这个对象的引用会存放在调用栈中。(对于引用类型可以查看,C#中值类型和引用类型)

 

Student s1 = new Student();

在C#中,当上面的Student对象被创建后,程序员就可以不用关心这个对象什么时候被销毁了,垃圾回收器将会在该对象不再需要时将其销毁。

 

当一个进程初始化后,CLR就保留一块连续的内存空间,这段连续的内存空间就是我们说的托管堆。.NET垃圾回收器会管理并清理托管堆,它会在必要的时候压缩空的内存块来实现优化,为了辅助垃圾回收器的这一行为,托管堆保存着一个指针,这个指针准确地只是下一个对象将被分配的位置,被称为下一个对象的指针(NextObjPtr)。为了下面介绍垃圾回收机制,我们先详细看看new关键字都做了什么。

 

new关键字

当C#编译器遇到new关键字时,它会在方法的实现中加入一条CIL newobj命令,下面是通过ILSpy看到的IL代码。

 

IL_0001: newobj instance void GCTest.Student::.ctor()

其实,newobj指令就是告诉CLR去执行下列操作:

 

计算新建对象所需要的内存总数

检查托管堆,确保有足够的空间来存放新建的对象

如果空间足够,调用类型的构造函数,将对象存放在NextObjPtr指向的内存地址

如果空间不够,就会执行一次垃圾回收来清理托管堆(如果空间依然不够,就会报出OutofMemoryException)

最后,移动NextObjPtr指向托管堆下一个可用地址,然后将对象引用返回给调用者

按照上面的分析,当我们创建两个Student对象的时候,托管堆就应该跟下图一致,NextObjPtr指向托管堆新的可用地址。

 

 

 

托管堆的大小不是无限制的,如果我们一直使用new关键字来创建新的对象,托管堆就可能被耗尽,这时托管堆可以检测到NextObjPtr指向的空间超过了托管堆的地址空间,就需要做一次垃圾回收了,垃圾回收器会从托管堆中删除不可访问的对象

 

应用程序的根

垃圾回收器是如何确定一个对象不再需要,可以被安全的销毁?

 

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

 

全局对象和静态对象的引用

应用程序代码库中局部对象的引用

传递进一个方法的对象参数的引用

等待被终结(finalize,后面介绍)对象的引用

任何引用对象的CPU寄存器

垃圾回收可以分为两个步骤:

 

标记对象

压缩托管堆

下面结合应用程序的根的概念,我们来看看垃圾回收这两个步骤。

 

标记对象

在垃圾回收的过程中,垃圾回收器会认为托管堆中的所有对象都是垃圾,然后垃圾回收器会检查所有的根。为此,CLR会建立一个对象图,代表托管堆上所有可达对象。

 

 

 

假设托管堆中有A-G七个对象,垃圾回收过程中垃圾回收器会检查所有的对象是否有活动根。这个例子的垃圾回收过程可以描述如下(灰色表示不可达对象):

 

当发现有根引用了托管堆中的对象A时,垃圾回收器会对此对象A进行标记

对一个根检测完毕后会接着检测下一个根,执行步骤一种同样的标记过程,标记对象B,在标记B时,检测到对象B内又引用了另一个对象E,则也对E进行标记;由于E引用了G,同样的方式G也会被标记

重复步骤二,检测Globales根,这次标记对象D

代码中很有可能多个对象中引用了同一个对象E,垃圾回收器只要检测到对象E已经被标记过,则不再对对象E内所引用的对象进行检测,这样做有两个目的:一是提高性能,二是避免无限循环。

 

所有的根对象都检查完之后,有标记的对象就是可达对象,未标记的对象就是不可达对象。

 

压缩托管堆

继续上面的例子,垃圾回收器将销毁所有未被标记的对象,释放这些垃圾对象所占的内存,再把可达对象移动到这里以压缩堆。

 

注意,在移动可达对象之后,所有引用这些对象的变量将无效,接着垃圾回收器要重新遍历应用程序的所有根来修改它们的引用。在这个过程中如果各个线程正在执行,很可能导致变量引用到无效的对象地址,所以整个进程的正在执行托管代码的线程是被挂起的。

 

 

 

经过了垃圾回收之后,所有的非垃圾对象被移动到一起,并且所有的非垃圾对象的指针也被修改成移动后的内存地址,NextObjPtr指向最后一个非垃圾对象的后面。

 

对象的代

当CLR试图寻找不可达对象的时候,它需要遍历托管堆上的对象。随着程序的持续运行,托管堆可能越来越大,如果要对整个托管堆进行垃圾回收,势必会严重影响性能。所以,为了优化这个过程,CLR中使用了"代"的概念,托管堆上的每一个对象都被指定属于某个"代"(generation)。

 

"代"这个概念的基本思想就是,一个对象在托管堆上存在的时间越长,那么它就更可能应该保留。托管堆中的对象可以被分为0、1、2三个代:

 

0代:从没有被标记为回收的新分配的对象

1代:在上一次垃圾回收中没有被回收的对象

2代:在一次以上的垃圾回收后仍然没有被回收的对象

下面还是通过一个例子看看代这个概念(灰色代表不可达对象):

 

在程序初始化时,托管堆上没有对象,这时候新添到托管堆上的对象是的代是0,这些对象从来没有经过垃圾回收器检查。假设现在托管堆上有A-G七个对象,托管堆空间将要耗尽。

 

 

如果现在需要更多的托管堆空间来存放新建的对象(H、I、J),CLR就会触发一次垃圾回收。垃圾回收器就会检查所有的0代对象,所有的不可达对象都会被清理,所有没有被回收掉的对象就成为了1代对象。

 

 

假设现在需要更多的托管堆空间来存放新建的对象(K、L、M),CLR会再触发一次垃圾回收。垃圾回收器会先检查所有的0代对象,但是仍需要更多的空间,那么垃圾回收器会继续检查所有 的1代对象,整理出足够的空间。这时,没有被回收的1代对象将成为2代对象。2代对象是目前垃圾回收器的最高代,当再次垃圾回收时,没有回收的对象的代数依然保持2。

 

 

通过前面的描述可以看到,分代可以避免每次垃圾回收都遍历整个托管堆,这样可以提高垃圾回收的性能。

 

System.GC

.NET类库中提供了System.GC类型,通过该类型的一些静态方法,可以通过编程的方式与垃圾回收器进行交互。

 

看一个简单的例子:

 

 

class Student

{

    public int Id { get; set; }

    public string Name { get; set; }

    public int Age { get; set; }

    public string Gender { get; set; }

}

 

class Program

{

    static void Main(string[] args)

    {

        Console.WriteLine("Estimated bytes on heap: {0}", GC.GetTotalMemory(false));

 

        Console.WriteLine("This OS has {0} object generations", GC.MaxGeneration);

 

        Student s = new Student { Id = 1, Name = "Will", Age = 28, Gender = "Male"};

        Console.WriteLine(s.ToString());

 

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        GC.Collect();

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        GC.Collect();

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        Console.Read();

    }

}

 

 

 

 

从这个输出,我们也可以验证代的概念,每次垃圾清理后,如果一个对象没有被清理,那么它的代就会提高。

 

强制垃圾回收

由于托管堆上的对象由垃圾管理器帮我们管理,所有我们不需要关心托管堆上对象的销毁以及内存空间的回收。

 

但是,有些特殊的情况下,我们可能需要通过GC.Collect()强制垃圾回收:

 

应用程序将要进入一段代码,这段代码不希望被可能的垃圾回收中断

应用程序刚刚分配非常多的对象,程序想在使用完这些对象后尽快的回收内存空间

在使用强制垃圾回收时,建议同时调用"GC.WaitForPendingFinalizers();",这样可以确定在程序继续执行之前,所有的可终结对象都必须执行必要的清除工作。但是要注意,GC.WaitForPendingFinalizers()会在回收过程中挂起调用的线程。

 

 

static void Main(string[] args)

{

    ……

    GC.Collect();

    GC.WaitForPendingFinalizers();

    ……

}

 

每一次垃圾回收过程都会损耗性能,所以要尽量避免通过GC.Collect()进行强制垃圾回收,除非遇到了真的需要强制垃圾回收的情况。

 

总结

本文介绍了.NET垃圾回收机制的基本工作过程,垃圾回收器通过遍历托管堆上的对象进行标记,然后清除所有的不可达对象;在托管堆上的对象都被设置了一个代,通过了代这个概念,垃圾回收的性能得到了优化。