基于.NET平台的开发语言中,最让开发人员爽的一点就是垃圾回收处理机制,在编码过程中,终于可以解放你的双手来关注更重要的事情。很多的资料中在讲到.NET中的垃圾回收机制时都说“CLR在合适的时候进行垃圾回收”,但什么时候才是“合适的时候”?内存又是如何分配的?CLR是如何对内存进行回收的?这一章我们来讨论有关垃圾回收的相关内容。
第一节 垃圾回收机制
早期的C/C++开发中,一个对象的生命周期大概像这样:计算对象大小——查找可用内存——初始化对象——使用对象——摧毁对象。如果在上面的过程中,开发人员忘记了“摧毁对象”这一步骤,则很有可能导致内存泄露!这是一个非常可怕的事情!幸好,CLR的开发人员为我们解决了这一问题,在.NET Framework中引入了垃圾回收机制,使得开发人员不需要再过多地关注内存释放的问题,CLR会在合适的时候进行执行垃圾回收来释放不再使用的内存。这里就像一个邪恶的男人所说的话:给我一个女人,我能创造一个民族!其实一个新世界你都可以去创造,前提是要有一个足够大的星球内存来容纳你的子孙!CLR就是这么认为的。
在激活一个进程时,CLR会先保留一块连续的内存,在主线程启动过程中,可能会初始化一系列对象,CLR先计算对象大小及其开销所占用的字节数,接着会在连续的内存块中为这些对象分配内存,这些对象被配置在第0代内存,在构造第0代内存的时候会分配一个默认大小的内存,随着程序的运行,可能会初始化更多的对象,CLR发现第0代内存不能装载更多的新生对象,此时CLR会启动垃圾回收器对第0代内存进行回收,不再使用的对象所占用的内存会被释放,接着把0代对象提升为第1代,然后把新生对象配置在第0代内存区中。CLR使用了3个阶段的代,每次新分配的对象都会被配置在第0代内存中,最老的对象在第2代内存中,每次为新对象分配内存时,都可能会进行垃圾回收以释放内存,很显然CLR认为“内存永远也使用不完”,很显然CLR为我们自动管理了内存垃圾,很显然CLR的这个“认为”在我们开发人员看来是不成立的,我们从以下几个方面来解读垃圾回收机制。
第二节 内存分配
垃圾回收是对引用类型而言的。
CLR要求引用类型的对象从托管堆中分配内存的,值类型是从栈中分配内存。在C#中通常使用new操作符来创建一个对象,编译器将会在IL中生成newobj指令,执行一个newobj指令会有以下过程:(在前一节中我们已经知道,在一个进程启动时会先保留一个连续的内存块)先计算类型及其基类型的字段所需要的字节数A,再计算类型对象的指针和一个同步索引块共8或16个字节,到此总共需要(A+8或18)字节的内存,CLR会检查当前进程区是否有足够的内存来容纳(A+8或16)个字节的对象,如果有,则将新对象放其中,否则CLR进行垃圾回收,释放不再使用的内存来容纳新的对象,在整个进程的生命周期中,CLR会维护一个指针P,它一直指向当前进程所分配的最后一个对象内存的结尾处而不会跑出当前进程内存区边界,如图:
每次计算新的将要创建的对象所需要的字节数时,CLR都是通过P加上新的需要的字节数进行检查可用内存区,如果超出了地址末尾,则表示当前的托管堆已经被用完,准备进行垃圾回收了。由于进程拥有一个独立连续的内存区,所以CLR能保证创建的新对象基本上都是紧挨着放置的。
第三节 代
当托管堆的内存被用完,新生的对象无处放置时,CLR就要开始进行垃圾回收了,随着程序的持续运行,托管堆可能越来越大,如果要对整个托管堆进行垃圾回收(下面会讲到如何回收),势必会严重影响性能,因为有时可能仅仅需要数十个字节就能容纳新的对象,有时候可能要对可达的对象进行搬迁,为了小范围有目的性地进行垃圾回收,CLR使用了“代”概念来优化垃圾回收器,代是垃圾回收机制使用的一个逻辑技术,也是一种算法,它把托管堆中的内存分为3个代(截止到目前.NET Framework4.0有3个代:0、1、2)。
进程在初始化时,CLR为托管堆初始化为包含0个对象的一块内存区域,新添加到堆中的对象为第0代对象,CLR在初始化第0代内存区时会分配一个默认的配额,假设为512K,不同的.NET框架和版本,可能这个配额不相同。假设进程及其线程初始化完成后分配了4个对象,如下图:
这4个对象占据了512K的内存,程序继续运行,当再分配第5个对象Obj5的时候,发现第0代已无可用内存,此时CLR会启动垃圾回收器进行垃圾回收,假如上面的Obj3已经无效,此是Obj3的内存会被释放出来,接着搬迁Obj4对象到Obj3的位置(在Obj2的内存地址末尾处),存活下来的对象Obj1、Obj2和Obj4会被提升为第1代对象,第1代的内存区域根据程序运行的情况,CLR可能会为其分配20M(也可能是其他值)大小的内存区,第0代内存暂时为空,接着将Obj5分配到第0代内存区,如下:
程序继续运行,并又新分配了4个对象Obj6-Obj9,且此时Obj2和Obj5都不再使用,即为不可达对象,此时需要再创建一个新对象Obj10,但发现第0代的512K内存已经用完,所以CLR再一次启动垃圾回收器进行垃圾回收,这一次垃圾回收器会认为第0代的新对象生命周期短,所以先对第0代进行回收,并将存活对象提升到第1代中,垃圾回收器发现此时第1代中的对象远远小于20M,所以放弃对第1代的回收,程序继续运行,分配N多的新对象,当把第0代的对象提升到第1代,而第1代对象超20M时,则会对第1代的对象进行回收,第1代存活的对象被提升为第2代,第0代存活的对象被提升为第1代,如下图:
每一次垃圾回收的过程,垃圾回收器会根据实际使用情况自动调整第0、1、2代的默认配额大小,比如可能将第2代调整为200M,几分钟过后可能将其调整为120M,也有可能是1024M,程序继续运行,当对3个全部进行了垃圾回收且重新调整配额后,可用内存还不足以放置新对象,CLR就会抛出OutOfMemoryException异常,此时活神仙也无法施救了。原来CLR认为“内存永远也使用不完”也是有条件的啊!
第四节 垃圾回收过程
托管堆中的一个对象,当线程中有变量对其引用则为可达对象,否则为不可达对象。
在一次垃圾回收过程开始时,垃圾回收器会认为堆中的所有对象都是垃圾。
第一步是标记对象,垃圾回收器沿着线程栈上行检查所有根,静态字段、方法参数、活动中的局部变量以及寄存器指向的对象等都是根,当发现有根引用了托管堆中的对象A时,垃圾回收器会对此对象A进行标记,在标记A时,如果检测到对象A内又引用了另一个对象B,则也对B进行标记,对一个根检测完毕后会接着检测下一个根,执行同样的标记过程,代码中很有可能多个对象中引用了同一个对象C,垃圾回收器只要检测到对象C已经被标记过,则不再对对象C内所引用的对象进行检测,以防止无限循环标记。有标记的对象就是可达对象,未标记的对象就是不可达对象。
第二步是搬迁对象压缩堆,垃圾回收器遍历堆中的所有对象来寻找未标记的对象,因为未标记的对象是垃圾对象,可以进行回收,如果发现对象较小,则忽略,否则会先释放这些垃圾对象所占的内存,再把可达对象搬迁到这里以压缩堆,在搬迁可达对象之后,所有指向这些对象的变量将无效,接着垃圾回收器要重新遍历应用程序的所有根来修改它们的引用。在这个过程中如果各个线程正在执行,很可能导致变量引用到无效的对象地址,所以整个进程的正在执行托管代码的线程是被挂起的。
其实在垃圾回收器准备开始一次回收时,正在执行托管代码的所有线程都必须被挂起,挂起时,CLR会记录每个线程的指令指针以确定线程当前执行到哪里以便将来在垃圾回收结束后进行恢复。如果一个线程的指令指针恰好到达了一个安全点,则可以挂起该线程,否则CLR会尝试劫持该线程,如果还未到达安全点,则等待几百毫秒后CLR会尝试再一次劫持该线程,有可能经过多次尝试,最终挂起该线程,当当前进程的所有执行托管代码的线程都挂起后,垃圾回收器就可以开始工作了。(有关线程劫持可查找相关资料)。垃圾回收器回收完毕后,CLR恢复所有线程,程序继续运行。可见,垃圾回收对性能影响之巨大!
第五节 大对象
在创建新对象时,任何大于等于85000字节的对象都被认为是大对象,这些对象的内存是从大对象堆中分配的,大对象总是被认为是第2代对象,要尽量避免分配大对象来减少性能损伤,为了提高性能,垃圾回收器不对大对象进行搬迁压缩,只在回收第2代内存时进行回收。
第六节 手工进行回收
一般的情况下,CLR会智能地在必要的时候更行垃圾回收,但我们也可以在我们愿意的情况下手动启动垃圾回收器,System.GC类提供了重载版本的静态方法来启动垃圾回收器:
//对所有代进行垃圾回收。
GC.Collect();
//对指定的代进行垃圾回收。
GC.Collect(int generation);
//强制在 System.GCCollectionMode 值所指定的时间对零代到指定代进行垃圾回收。
GC.Collect(int generation, GCCollectionMode mode);
在上一节中我们已经知道,每一次垃圾回收过程都会导致性能损伤,所以我们尽量避免调用这3个方法进行垃圾回收,当然必要的时候也可以调用。
不仅仅以上谈到几种情况下会启动垃圾回收器,当CLR接到Windwos发出内存告急通知时也会启动垃圾回收、CLR卸载AppDomain时也会启动垃圾回收。