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