浅析CLR的GC(垃圾回收器)
文章目录:
- 了解托管堆和GC
- GC高效的处理方式—代
- 特殊类型的清理
- 手动监控和控制对象生命周期
1、了解托管堆和GC
在面向对象环境中,每一个类型都代表了一种资源。我们要使用这些资源,就要为这些代表资源的类型分配内存。在C#中,我们一般使用new关键字来完成。访问资源包括以下几步:
-
- 使用new操作符为类型分配内存(这个过程调用了IL指令newobj)
- 初始化内存,设置资源的初始状态,来让这个资源可用(类型的实力构造器负责初始化类型状态)
- 访问类型成员使用资源
- 摧毁资源状态进行清理
- 释放内存
在C#中,我们的操作时基于CLR来完成的,我们所有对象都是从托管堆对来分配内存。当进程初始化(我们的程序)时,CLR会画出一个地址空间区域来作为托管堆。同时,CLR会维护一个指针NewObjPtr,这个指针指向下一个对象在托管堆中分配的地址。当这个区域被非垃圾的对象填满后,CLR会分配更多的区域,这个过程将一直重复,直至整个进程的地址空间都被填满。32位进程的地址空间为1.5GB,64位进程为8TB。(这里顺便提一下值类型的生命周期,值类型对象分配在线程栈上,当离开作于域时,自动销毁。)
C# new 操作符,会让CLR执行以下步骤:
-
- 计算类型字段的所需字节数(这里的字段是所有字段,包括基类继承的)
- 添加创建对象的额外所需字节数 (每一个对象被初始化时,会创建类型对象指针和同步块索引,32位程序为8字节,64位程序16字节)
- CLR检查区域中空间是否足够,假如足够,在NewObjPtr指针位置放入对象。这时候,对象分配的字节会被清零,然后调用类型的构造器(计算字节,NewObjPtr指针会指向旧的位置加上这个字节的位置,为下一个对象分配空间时候的位置),new操作符返回对象的引用。
(例如在托管堆已经由A,B的情况下新构建了一个C)
关于GC
当程序调用new操作符创建对象时候,假如没有足够的地址空间来分配该对象,CLR就会执行垃圾回收(调用GC)。CLR采用引用跟踪算法,这种算法只关心引用类型的变量,避免了类型循环导致对象不能被回收的问题。引用类型包括类的静态和实例字段,方法的局部变量和参数。所有的引用类型被称之为根。
CLR开始执行GC时候,会暂停进程中所有线程(防止CLR执行检查期间对象的状态被更改);然后CLR遍历托管堆中的所有对象,将同步块索引中字段的一位设置为0(标识所有对象都应该删除),然后CLR检查所有活动根,这些根引用了哪些对象。
任何一个根引用了堆的对象,CLR会将对象的同步块索引的位设置位1。然后再检查对象中的跟,标记它们引用的对象。假如在遍历过程中发现对象被标记,就跳过这个对象,不再重新检查这个对象的字段,这样避免了循环引用。
应用程序中所有的活动跟都检查完毕以后,这时候堆中的对象要么被标记了(称之为可达,由活动跟在引用),要么没有被标记(相应称为不可达)。CLR将不可达的对象内存回收。将可达对象进行内存整理,使对象内存在托管堆中是连续的(压缩过程中CLR要从每个根减去所引用对象在堆中的偏移字节数),如下图所示:
2、GC高效的处理方式—代
CLR的垃圾回收基于代。
同时GC回收垃圾时,做出了下面几点假设(可以先记下,从下文中体会):
- 对象越新,生存期越短
- 对象越来,生存期越长
- 回收堆一部分内存,比回收整个堆要快
解释下代:托管进程中有两种内存堆,分别是本机堆和托管堆,CLR在托管堆上面为.net 的所有对象分配内存(托管堆又称为GC堆)。托管堆又分为两种,小对象堆和大对象堆(LOH),小对象堆用来分配常用的资源对象内存(如类,数组等等),小对象堆的内存段进一步划分为3代,0代,1代,2代。(大对象堆用来分配一些大对象和非托管资源,我们后文中专门来解释)
托管堆初始化时,不包含任何对象,当我们声明一个对象时,这个对象称为第0代对象。也就是说,第0代对象就是那些新构造的对象,而且垃圾回收器没有检查过的对象。例如下图中,托管堆中分配了A,B,C,D,E5个对象,它们就是第0代对象。
接下来随着我们不停地分配对象,第0代的堆内存使用完毕,且这随着程序的流转,C和E变得不可达,当我们分配下一个内存F时,CLR就会执行一次垃圾回收。此时,C,E对象内存被回收掉,我的的ABD对象从第0代对象变为第1代对象。这时候,垃圾回收结束,第0代不包含任何对象。如下图所示:
接下来随着程序的运行,又在0代中分配了对象F G H I J K,1代对象中B变得不可达。接下来给对象L分配内存时内存不足,将执行垃圾回收。CLR会为第0代对象和第 1代对象选择预算,由于第一代中的占用内存远少于预算,所以垃圾回收期只检查第0代的对象(基越新的对象获得越短),因为第0代对象包含更多的垃圾可能性更大,可以回收更多的内存。忽略了第一代中的对象,所以加快了垃圾回收速度。
随着垃圾回收的不断进行,第1代的内存将不断增加,当第1代对象的内存增长到占用了占用了全部预算(0代给新对象分配内存就要进行GC),此时,会进行第1代的垃圾回收,幸存下来的对象被分配的第2代中去。托管堆只支持3代(0,1,2)。超过85000字节的对象称之为大对象,直接由第2代分配内存。
代给GC带来的性能提升主要体现在不必遍历托管堆中的每一个对象。如果根或者对象引用了老一代的某个对象,垃圾回收期就可以忽略老对象内部所有引用(CLR的特征,引用跟踪算法,同步索引块中的一位标识),在更短的时间内构造好可达对象图。假如老对象字段引用了新对象,则由JIT编辑器内部的一个机制(单独解释)让垃圾回收期跳过。微软官方性能测试,0代执行一次GC,花费时间不少过1毫秒。
- JIT的机制是在对象引用字段发生变化时候,设置一个对应位标志。这样,下一次GC回收资源内存时候,会知道上一次GC过后,哪些老对象被写入位标志,这样,只有位标志发生变化(也就是老对象字段发生变化)时候,才检查老对象是否引用第0代对象。
3、特殊类型的清理
特殊类型:大多数对象只要分配内存就可以使用。但是,还有部分对象需要分配本机资源(例如文件,网络连接,套接字,互斥体),我们称这部分对象为特殊类型的资源。
特殊类型的回收过程和特点:包含本机资源的类型被GC时,GC在回收内存之前,需要将本机资源终结(Finalization)。当CLR判定一个特殊类型的对象不可达时,对象将终结自己,释放包裹的本机资源,然后由GC回收其内存。
Object基类型定义了虚方法Finalize,GC判定对象时垃圾后,调用对象的Finalize方法,这个方法一般以析构函数的形式出现。(ILSpy 反编译后的析构函数代码为protected override Finalize)。
特殊类型注意事项:
- Finalize执行在GC之后,所以特殊类型的对象不是马上被GC回收,因为Finalize方法可能要访问对象字段。这可能使对象提升到另一级别的代,增加内存耗用。所以,尽量避免引用类型的字段定义为可终结对象。
- Finalize方法执行时无顺序的。所以不要在Finalize方法中访问定义了其他Finalize方法的类型,因为另一个类型对象可能已被终结。
- CLR用一个特殊的、高优先级专用线程调用Finalize方法避免死锁。
- 自定义包含了本机资源的托管类型时要继承自SafeHandle(派生自它保证本机资源在GC时被释放)。
控制包装了本机资源类型对象的生存期:
例如这里我们要往D盘的1.txt中写入一部分文本,然后写完后想把这个文件删除,此时就会报 “System.IO.IOException:“文件“d:\1.txt”正由另一进程使用,因此该进程无法访问此文件。”这样一个异常,这是因为本机资源未被释放(Finalize)。假如我们想控制包装本机资源的类型对象的生命周期,就要实现IDispose接口。(如果类型对象的其中一个字段实现了这个接口,那么这个类型也就实现了Dispose模式。)然后我们修改我们的代码,成功删除文件。
终结的内部实现原理:
包装了本机资源的对象被回收时,会调用Finalize方法。
包装了本机资源的对象创建的时候(定义了Finalize方法),在从堆中分配内存前,会将这个对象的指针添加到一个终结列表(由GC控制的内部数据结构)中。这个列表中的每一项,都指向一个定义了Finalize方法的对象,回收这些对象内存之前应该先调用它的Finalize方法(这里注意,虽然Object也定义了Finalize方法,但是CLR会忽略它,只有重写了Finalize方法的类型对象才会加入到终结列表)。如下图所示,C,E,F,I,J是定义了Finalize方法的类型对象,指向它们的指针被加入到终结列表中:
垃圾回收开始进行,B,E,G,H,I,J被判定为垃圾,这时候垃圾回收器会扫描终结列表来查找这些对象的引用(这里找到了E,I,J),然后把这些引用从终结列表中移除,附加到freachable队列(也是GC的一个内部数据结构)。在freachable队列中的每一个引用都代表即将进行Finalize调用的对象。经历过一轮GC后,堆内存如下所示:
CLR使用一个高优先级的,专用的线程来调用Finalize方法,这个线程避免潜在的线程同步问题。当freachable队列为空时候,这个线程将休眠,freachable队列出现记录项,将唤醒这个线程。这样来看,包装了本机资源的托管对象至少要进行两次GC才能回收它的内存,第一次由专用线程来执行Finalize方法,第二次才由GC回收这个对象的内存(大于2次是因为这些对象可能被提高到老的一代)。
4、手动监控和控制对象生命周期
CLR为每一个AppDomain都提供了一个GC Handle table,允许程序监视或者控制对象的生命周期。这个表中的每一条记录项都包含托管堆中一个对象的引用和监视控制对象标志。这里注意一个类GCHandle和一个枚举对象 GCHandleType。
GCHandle调用Alloc方法时候,会扫描AppDomain的GC Handle table,查找一个可用的记录项存储对象的生命周期并且传回给对象引用。GChandle的Target属性,返回句柄表示的对象,如下图所示:
GC发生时候会使用GC Handle table,首先,GC将所有对象标识为将要回收,扫描GC Handle table,所有GCHandleType为Normal和Pinned对象标识为根;然后查找GCHandleType为Weak的项,如果引用了未标记的对象,那么这个对象就是垃圾,且把这个项赋值为null;GC继续扫描中介列表,将无引用标识对象的引用放入freachable队列;GC再扫描GC Handle table,查找GCHandleType 为WeakTrackResurrection的记录想,这些记录想引用了未标记的对象(freachable队列中)变为垃圾,这些记录项赋值为Null。最后GC对内存进行压缩。