本文分析.Net垃圾回收机制,带你了解相关的代龄和算法,垃圾收集器在本质上就是负责跟踪所有对象被引用到的地方,关注对象不再被引用的情况,回收相应的内存。在.NET平台中同样如此,有效的提高.NET垃圾回收性能,能够提高程序执行效率。
其实垃圾收集并不是伴随Java出现的,早在1958年,图林奖得主John发明的Lisp语言就已经提供了GC的功能,这是GC的第一次出现,是思想的一次闪光!而后,1984年Dave Ungar发明的Small talk语言第一次正式采用了GC机制。.Net的垃圾回收机制是个很大的话题,如果你没接触过类似C++那样的语言,就很难理解GC是一个多么重要、令人兴奋的东西:
1.提高软件系统的内聚。
2.降低编程复杂度,使程序员不必分散精力去处理析构。
3.不妨碍设计师进行系统抽象。
4.减少由于内存运用不当产生的Bug。
5.成功的将内存管理工作从程序的编写时,脱离至运行时,使不可预估的管理漏洞变为可预估的。
1.算法
垃圾收集器的本质,就是跟踪所有被引用到的对象,整理对象不再被引用的对象,回收相应的内存。这听起来类似于一种叫做“引用计数(Reference Counting)”的算法,然而这种算法需要遍历所有对象,并维护它们的引用情况,所以效率较低些,并且在出现“环引用”时很容易造成内存泄露。所以.Net中采用了一种叫做“标记与清除(Mark Sweep)”算法来完成上述任务。“标记与清除”算法,顾名思义,这种算法有两个本领:
“标记”本领——垃圾的识别:从应用程序的root出发,利用相互引用关系,遍历其在Heap上动态分配的所有对象,没有被引用的对象不被标记,即成为垃圾;存活的对象被标记,即维护成了一张“根-对象可达图”。其实,CLR会把对象关系看做“树图”,无疑,了解数据结构的同学都知道,有了“树图”的概念,会加快遍历对象的速度。
检测、标记对象引用,是一件很有意思的事情,有很多方法可以做到,但是只有一种是效率最优的,.Net中是利用栈来完成的,在不断的入栈与出栈中完成检测:先在树图中选择一个需要检测的对象,将该对象的所有引用压栈,如此反复直到栈变空为止。栈变空意味着已经遍历了这个局部根(或者说是树图中的节点)能够到达的所有对象。树图节点范围包括局部变量(实际上局部变量会很快被回收,因为它的作用域很明显、很好控制)、寄存器、静态变量,这些元素都要重复这个操作。一旦完成,便逐个对象地检查内存,没有标记的对象变成了垃圾。
“清除”本领——回收内存:启用Compact算法,对内存中存活的对象进行移动,修改它们的指针,使之在内存中连续,这样空闲的内存也就连续了,这就解决了内存碎片问题,当再次为新对象分配内存时,CLR不必在充满碎片的内存中寻找适合新对象的内存空间,所以分配速度会大大提高。
但是大对象(large object heap)除外,GC不会移动一个内存中巨无霸,因为它知道现在的CPU不便宜。通常,大对象具有很长的生存期,当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,移动大对象所带来的开销超过了整理这部分堆所能提高的性能。
Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很重要的。
2.代龄(Generation)
代龄就是对Heap中的对象按照存在时间长短进行分代,最短的分在第0代,最长的分在第2代,第2代中的对象往往是比较大的。Generation的层级与FrameWork版本有关,可以通过调用GC.MaxGeneration得知。
通常,GC会优先收集那些最近分配的对象(第0代),这与操作系统经典内存换页算法“最近最少使用”算法如出一辙。但是,这并不代表GC只收集最近分配的对象,通常,.Net GC将堆空间按对象的生存期长短分成3代:新分配的对象在第0代(0代空间最大长度通常为256K),按地址顺序分配,它们通常是一些局部变量;第1代(1代空间最大长度通常为2 MB)是经过0代垃圾收集后仍然驻留在内存中的对象,它们通常是一些如表单,按钮等对象;第2代是经历过几次垃圾收集后仍然驻留在内存中的对象,它们通常是一些应用程序对象。
当内存吃紧时(例如0代对象充满),GC便被调入执行引擎--也就是CLR--开始对第0代的空间进行标记与压缩工作、回收工作,这通常小于1毫秒。如果回收后内存依然吃紧,那么GC会继续回收第1代(回收操作通常小于10毫秒)、第2代,当然GC有时并不是按照第0、1、2代的顺序收集垃圾的,这取决于运行时的情况,或是手动调用GC.Collect(i)指定回收的代。当对第2代回收后任然无法获得足够的内存,那么系统就会抛出OutOfMemoryException异常,当经过几次GC过后,0代中的某个对象仍然存在,那么它将被移动到第1代。同理,第1、2代也按同样的逻辑运行。
这里还要说的是,GC Heap中代的数量与容量,都是可变的(这由一个“策略引擎”控制,在第二节中,会介绍到“策略引擎”), 以下代码结合Windbg可以说明这个问题,以下代码中,可以通过单击按钮“button1”,不断的分配内存,而后获得对象“a”的代龄情况,并且在Form加载时也会获得“a”的代龄。
public partial class Form1 : Form
{
private string a = new string('a',1);
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
a = new string('a', 900000);
label1.Text = GC.GetGeneration(a).ToString();
}
private void Form1_Load(object sender, EventArgs e)
{
label1.Text = GC.GetGeneration(a).ToString();
}
}
程序刚加载时,“a”的代龄为第0代,通过windbg我们还获得了以下信息:
可以看出,GC堆被分成了两个段,三代,每代起始地址十进制差值为12,点击数次“button1”按钮后,“a”的代龄升为第2代,通过windbg我们又获得了以下信息:
这里要注意一个很关键的地方,就是各代的起始(generation x starts at)十进制地址差值不再是12,0代与1代差为98904,1代与2代差为107908,这说明代的大小随程序运行在改变,并且GC heap的大小也有变化。
class Program
{
static void Main()
{
Timer t = new Timer(Timercall, "现在时间是",0,2000);
Console.ReadKey();
}
static void Timercall(object obj)
{
Console.WriteLine(obj.ToString()+DateTime.Now);
Console.WriteLine(GC.GetGeneration(obj)); //0,0,0.。。。。
//GC.Collect();//这里obj的代就会是0,1,2,2,2.。。。。,为什么?你懂的。。
}
}
CLR垃圾回收器根据所占空间大小划分对象。大对象和小对象的处理方式有很大区别。比如内存碎片整理 ------ 在内存中移动大对象的成本是昂贵的,让我们研究一下垃圾回收器是如何处理大对象的,大对象对程序性能有哪些潜在的影响。
大对象堆和垃圾回收
在.Net 1.0和2.0中,如果一个对象的大小超过85000(本机测试是84988)byte,就认为这是一个大对象。这个数字是根据性能优化的经验得到的。当一个对象申请内存大小达到这个阀值,它就会被分配到大对象堆上。这意味着什么呢?要理解这个,我们需要理解.Net垃圾回收机制。
static void Main()
{
byte[] bytes = new byte[84988];//84988是2,84987为0,跟机器配置什么的有关吗?
Console.WriteLine(GC.GetGeneration(bytes));
Console.ReadKey();
}
如大多人所知道的,.Net GC是按照“代”来回收的。程序中的对象共有3代,0代、1代和2代,0代是最年轻的对象,2代对象存活的时间最长。GC按代回收垃圾也是出于性能考虑的;通常的对象都会在0代是被回收。例如,在一个asp.net程序中,和每一个请求相关的对象都应该在请求结束时回收掉。而没有被回收的对象会成为1代对象;也就是说1代对象是常驻内存对象和马上消亡对象之间的一个缓冲区。
从代的角度看,大对象属于2代对象,因为只有在2代回收时才会处理大对象。当某代垃圾回收执行时,会同时执行更年轻代的垃圾回收。比如:当1代垃圾回收时会同时回收1代和0代的对象,当2代垃圾回收时会执行1代和0代的回收.
代是垃圾回收器区分内存区域的逻辑视图。从物理存储角度看,对象分配在不同的托管堆上。一个托管堆(managed heap)是垃圾回收器从操作系统申请的内存区(通过调用windows api VirtualAlloc)。当CLR载入内存之后,会初始化两个托管堆,一个大对象堆(LOH –large object heap)和一个小对象对(SOH – small object heap)。
内存分配请求就是将托管对象放到对应的托管堆上。如果对象的大小小于85000byte,它会被放置在SOH;否则会被放在LOH上。
对于SOH,对象在执行一次垃圾回收之后,会进入到下一代。也就是说如果在第一次执行垃圾回收时,存活下来的对象会进入第二代,如果在第2次垃圾回收之后该对象仍然没有被当作垃圾回收掉,它就会成为2代对象;2代对象就是最老的对象不会在提升代数。
当触发垃圾回收时,垃圾回收器会在小对象堆做碎片整理,将存活下来的对象移动到一起。而对于大对象堆,由于移动内存的开销很大,CLR团队选择只是清除它们,将回收掉的对象组成一个列表,以便满足下次有大对象申请使用内存,相邻的垃圾对象会被合并成一块空闲的内存块。
需要时时留意的是,直到.Net 4.0中也不会对大对象堆做碎片整理操作,将来也许会做。因此如果你要分配大对象并不想他们被移动,你可以使用fixed语句。
如下小对象堆SOH的回收示意图