.net core底层入门学习笔记(十-GC详细流程)

.net core底层入门学习笔记(十)

本节开始记录具体GC的详细流程



一、标记阶段

1.获取根对象

根对象是程序正在使用的对象,从根对象开始递归扫描一定可以找到所有正在使用的对象。主要包括:全局变量,各个线程正在执行函数的本地变量。
函数本地变量:要实现获取函数本地变量,首先执行调用链跟踪,找到当前托管线程正在调用函数的所有调用来源,再获取函数元数据根据元数据中的GC信息找到引用类型本地变量,函数的元数据(后面会详细介绍)包含栈回滚信息与GC信息。
全局变量:通过GC句柄,.net加载模块时会枚举模块中所有全局变量,生成数组保存值类型全局变量,与引用类型全局变量,再生成固定GC句柄指向这些对象(表示他们不可被移动)。

2.递归扫描根对象并设置存活标记

获取根对象后,标记根对象为存活,即对象头中的存活标记位标记位1;判断根对象类型是否为数组,如果是数组,且元素类型为引用类型,则枚举所有元素,如果不是数组,则枚举所有引用类型成员,已经设置存活标记与不属于当前GC指定代的会调过处理。
其中枚举对象引用成员,使用类型数据中的GC描述符,GC描述符记录了对象值的什么位置开始有多少引用类型成员,不包含成员的名称与类型信息。
递归扫描引用类型成员或数组,不会递归调用函数,避免栈溢出,使用栈结构模拟递归处理。

3.卡片表扫描跨代引用设置存活标记

上述方式,只会扫描属于目标代的成员,卡片表语卡片束解决跨代引用问题,如果一个对象应用了短暂堆范围(0代与1代)内对象,那么引用成员位置在卡片表中对应的位(Bit)标记位1。卡片表 32位平台一个标记表示128字节范围,64位平台表示256字节范围。卡片表对应范围不一定包含了对象值的开始地址。
枚举卡片表会定位对象值范围内与卡片表有交集的第一个对象,从这个对象开始处理,直到交集的最后一个对象。

4.强引用GC句柄设置存活标记

GC句柄.NET用于非托管代码保存托管对象的机制。GC句柄保存在.NET运行时内部的GC句柄表中。扫描GC句柄表,不同类型GC句柄,有不同处理。
强引用GC句柄用于非托管代码中保存托管对象,且允许托管对象位置移动。扫描到此类型时,会标记存活,再递归扫描引用类型成员对象。

5.固定GC句柄设置存活标记

固定GC句柄用于托管代码传递托管对象地址或对象成员地址到非托管代码,固定GC句柄本身可以在托管代码中关联,不要钱非托管代码与.NET运行时交互。扫描到此类型时,会标记存活,再递归扫描引用类型成员对象。固定GC句柄对象指向的固定对象内存位置不可改变。

6.弱引用GC句柄,清空不再存活对象

弱引用GC句柄指示拥有对象引用,但允许目标对象被回收,完成前面的扫描后,会扫描弱引用GC句柄,判断句柄指向目标对象是否存活,不存货,则设置句柄保存的引用为null。下一次托管代码访问时,可以知道对象已经被回收。

7.扫描析构对象列表,添加不再存活对象到析构队列

完成所有GC句柄扫描后,扫描析构对象列表记录的对象地址,如果对象存活标记为不存活,则检查对象头抑制运行析构函数的标记是否为1,如果为1则删除析构对象列表中的记录,如果不为1,则将析构对象列表中的记录移动到析构队列或重要析构队列中。标记析构队列与重要析构队列中所有对象为存活,使得对象可以调用析构函数(由析构线程处理)。
已经调用析构函数的对象从析构队列或重要析构队列中移除,下一轮GC可以把他们当普通对象回收(此时,对象已经无任何引用,且不在析构对象列表,或者析构对象队列或重要队列中)
备注:重要析构队列由于在.net core中不支持线程中止,与普通析构队列区别不大。

8.枚举跟踪复活弱引用GC句柄,清空不再存活对象

与弱引用GC句柄一样处理,只是在设置null的时机变为等待析构函数执行完毕之后。

9.是否启用升代

标记阶段最后处理,是决定是否升代,如果启用升代,则GC结束后,托管堆上的对象会进入下一代。
是否启用升代,不仅在标记阶段,重新决定目标代与计划阶段结束时,都会进行判断。
下列任一条件满足,则启用升代:

  • 执行完整GC(目标为第2代,其他代也会处理,0,1,2)
  • 比目标更老的代对象过少,本地标记对象过多
  • 卡片表扫描效率过低
  • 短暂堆剩余空间过少
  • 短暂堆对象大小合计大于默认堆段大小
  • 计划阶段选择清扫而不是压缩

二、计划阶段

1.构建Plug树

经过标记阶段处理后,所有程序使用的对象存活标记都为1,且位置不可变的对象的固定标记也为1,不再使用的对象存活标记都是0.
托管堆对象由此可分为三类:存活标记为0对象;存活标记为1但固定标记为0对象;存活标记为1,固定标记也为1对象。
计划阶段第一步:创建Plug树。一个Plug管理相邻并属于同一类的对象。Plug主要管理存活的对象,因此分为两类Plug:不固定Plug,固定Plug。
每个Plug包含如下信息:

  • gap:本Plug之前存活标记为0的对象大小合计;
  • reloc:模拟压缩阶段使用,表示这个Plug要移动的内存地址偏移(通常是负数,向前移动)
  • left:距离左边Plug节点的内存偏移值
  • right:距离右边Plug节点的内存偏移值
  • skew:防止覆盖Plug第一个对象对象头预留的空间

Plug信息会保存在每个Plug第一个对象之前的空间(如果两个Plug紧密相邻,则可能会覆盖前一个Plug最后一个对象的值)。而由于固定对象可能正在被其他非托管代码访问,所以不允许覆盖,为避免这个问题,如果某个非固定对象紧接着固定对象,那么这个非固定对象也归纳到固定对象代表的固定Plug中,这样一来被覆盖值的对象只有非固定对象。
同时,如果对象存活且值被Plug信息覆盖,则覆盖之前的内容会备份在一个mark类型实例中,实例保存在各个区域关联的mark_stack_array类型列表中,GC结束前会枚举此表恢复被覆盖的内容。具体分为:saved_post_plug,保存非固定Plug覆盖固定Plug对象,save_pre_plug,固定Plug覆盖非固定Plug。

为了支持枚举托管堆上的所有Plug,多个Plug会组成一颗树,对象存活标记与固定标记会在创建Plug过程中重置为0,之后所有处理都以Plug为单位。

2.构建Brick表

如果Plug过多,Plug树过大,不利于检索。计划阶段使用Brick表,按范围划分Plug数,Brick表的结构是short数组,每个元素代表托管堆上的一个范围,32位平台代表2048字节,64位代表4096字节。具体元素中的值表示距离该范围内的Plug树根节点的内存偏移值。如果值为-1,表示根节点地址在前一个元素对应的范围中。
这样根据对象内存地址,可以找到对应的Brick表中的数组下标,然后查找对应的值,通过这个值可以找到Plug根节点,再通过left,right找到对应的Plgu信息。

3.模拟压缩

枚举所有Plug,并重新计算各个Plug中第一个对象的开始地址,并将原始地址与开始地址的偏移值信息保存在Plug信息中的reloc字段中。
除了计算Plug的reloc值外,模拟压缩还会计算各个代的新开始地址,也称为计划开始地址。计划开始地址保存在各个代的实例generation(4个)的动态数据中。
例子,假设1个非固定对象执行前是1代,GC过程中启用升代变为第2代,那么计划阶段中第1代计划开始地址在所有原有第1代对象之后,固定对象由于地址不变,所以这个固定对象可能发生降代。或者由于GC执行压缩,移动了其他代对象到这个固定对象前,也会发生降代。
模拟压缩阶段计算出来的reloc偏移值,与各个代的计划开始地址,仅在执行压缩时使用,如果不执行压缩,则计算结果会被抛弃。

4.判断是否执行压缩与新建短暂堆段

模拟压缩后,计划阶段基于模拟压缩结果,判断是否执行压缩与新建短暂堆段。以下条件任一条件满足则执行压缩:

  • 触发GC明确启用压缩(例如分配对象失败时,会触发GC,这个流程中的某个过程会要求启用压缩)
  • 触发GC的原因是开启无GC区域
  • 当前短暂堆段可用空间过少
  • 因压缩而释放的尾部空间大小大于阈值(与目标代空间碎片上限,目标代范围总大小相关的一个计算公式)
  • 当前物理系统内存占用过高。

而判断是否新建短暂堆段的需满足所有以下条件:执行压缩,GC目标代>=1,短暂堆段末尾空间大小小于需求大小1,末尾空间大小+自由对象大小合计小于需求大小0,末尾空间大小小于85000字节,最大自由对象大小小于85000字节+指针大小*3,无GC区域预留空间不足。
其中需求1,与需求0是根据分配量阈值计算而来。

如果判定需要新建短暂堆段,则在执行压缩操作前新建短暂堆段代替当前短暂堆段,可能会重用之前释放的堆段。是否新建大对象,在分配大对象中判断。

三、重定位阶段

1.修改对象引用地址

如果计划阶段决定执行压缩,重定位阶段修改所有指向这些对象的内存地址,但对象本身仍留在原来的地址。各个对象值移动后的地址,已经计算好,且以偏移值的形式保存在Plug信息中。Plug信息中可能包含多个对象,多个对象一起移动的效率更高。
重定位阶段查找对象的方式与标记阶段一样,从所有根对象进行查找,不同的是根对象传给查找函数的回调是设置存活标记,而重定位传给查找函数的回调是根据对象地址找到对应的Brick,再找到对应的Plug树根节点,获取关联的Plug信息,进而获取到要移动的偏移值,计算出移动后的地址。
此时如果对象值被下一个Plug信息覆盖,且覆盖内容包含了引用类型成员,那么重定位阶段会去修改备份的内容,即mark类型实例中的内容。
因为此时修改所有引用对象指向地址,而对象值本身没有移动,此时如果有其他托管线程运行会发生巨大问题,所以执行压缩操作的GC,其他托管线程需要切换到抢占模式。

四、压缩阶段

1.复制对象值

经过重定位阶段后,所有需要移动的对象地址都修改为移动后的地址。压缩阶段的工作就是将对象值实际复制到移动后的地址。
压缩阶段会扫描Brick表,从而查找到Plug树,找到各个Plug信息,以Plug为单位复制内存到移动后的地址。
移动时,会考虑对象头,Plug中第一个对象的对象头会包含其中,对象末尾预留给下一个对象头的空间则排除在外。
同时,如果对象值被Plug信息覆盖,则先恢复内容,再复制移动,移动完毕后再重新用Plug信息覆盖回原来的位置。这样才能保持移动后,与移动前是完整覆盖。此时如果移动不够多,则原来对象的值内容,还是会被后面的Plug覆盖,所以GC结束后,需要再将Plug信息覆盖的内容覆盖回来。

2.结束GC

压缩阶段后,执行一些GC收尾工作即可结束GC:

  • 设置各个代开始地址为计划地址
  • 释放无存活对象的小对象堆
  • 在固定Plug前的空余空间创建自由对象,加入自有对象列表
  • 如果启用升代,且并未发生降代,清零卡片表中第1代的范围(因为其中表明的是第1代是否引用其他代的内容,现在第0代已经全部升为1代,可以清零)
  • 调整各个代的动态数据部分,如总大小与碎片空间大小等
  • 重新计算各个代的分配量阈值
  • 短暂堆末尾未分配空间释放给操作系统
  • 通知GC结束,恢复其他托管线程运行(抢占模式变为合作模式)

GC结束后,有因为普通GC导致暂停的后台GC,则后台GC继续运行;如果析构队列中有等待执行析构对象,则析构线程中执行他们。

五、清扫阶段

如果GC决定不执行压缩,则直接进入清扫阶段

1.创建自由对象添加到自由列表

计划阶段创建的Plug信息中保留了每个Plug之前有多少字节的空间保存了非存活对象,清扫阶段会在这些空间上创建自由对象,并将其加入各个代的generation实例管理的自由对象列表中。
清扫阶段会强制启用升代,新的第1代开始地址为最后1个存活的第1代对象末尾,第0代开始地址为存活下来的最后一个第0代对象末尾,第2代则在第一个堆段开始地址。第0代只有一个体积最小的自有对象。

2.结束GC

此时的结束GC与上面的结束GC稍有不同,即代的地址设置在清扫阶段完成,且此时无条件清零卡片表中第1代范围。
具体流程:

  • 恢复被Plug信息覆盖的内容
  • 释放无存活对象的小对象堆段
  • 清零卡片表第1代范围
  • 调整各个代动态数据
  • 重新计算各个代分配量阈值
  • 短暂堆段末尾未分配空间释放给操作系统
  • 通知gc结束

二、后台GC

前面记录的都是普通GC的流程,.NET中的后台GC,支持不暂停(部分流程会短暂暂停一下)其他托管线程的情况下执行大部分GC工作。后台GC只针对第2代。注意区分普通GC,针对第2代,会同时处理第1代与第0代。
因为不暂停其他托管线程,所以后台GC执行过程中,不能修改对象值,不能移动对象位置,所以不支持压缩操作,只包含后台标记阶段与后台清扫阶段。
后台GC更多作为普通GC的辅助,用于减少普通GC的工作量与停顿时间,无法代替普通GC。

1.后台标记阶段

与普通GC标记阶段一样,区别在于存活标记不设置在对象类型信息最后一位,而是设置在一个位数组中,每个位代表一个范围(32位平台,代表32个字节,64位代表64字节)。如果两个对象都在同一个位中,则一个对象存活,则导致两个对象都认为是存活。
具体步骤:

  1. 调用后台标记之前,暂停其他托管线程运行(切换到抢占模式),启用写监视,记录托管堆上修改过的范围
  2. 后台标记阶段:扫描根对象,将根对象添加到一个内部队列中,恢复其他托管线程运行(切换到合作模式),从内部队列中取出根对象并递归标记存活,存活标记存储在一个内部位数对应的数组中。暂停其他托管线程运行,重新扫描根对象,根据写监视结果重新扫描托管堆上修改过的部分,这部分包含的所有引用对象强制标记为存活。关闭写监视,扫描弱引用GC句柄,设置不再存活对象引用为null。
  3. 后台清扫阶段,恢复其他托管线程运行(切换到合作模式)…

写监视器智能记录托管堆上修改过的范围,不能记录各个线程栈空间上修改过的范围,所以在后台标记阶段的最后,仍需要扫描一次根对象,确保栈上新引用的对象也标记为存活。

2.后台清扫阶段

后台清扫阶段会恢复其他线程运行,清扫阶段与普通GC清扫阶段基本相同,所有没有标记存活的对象所占空间会被回收(减少堆段末尾曾经分配现在已经不再使用的空间给操作系统;创建自由对象并添加到自由对象列表)。虽然没有修改在对象头中的存活标记,也没有创建Plug信息,清扫阶段不需要暂停其他托管线程运行。但某些工作(例如添加自由对象到自由列表)仍需要原子操作或线程锁保证线程安全,因为此时自由对象可能被其他托管线程申请使用。
后台GC会在运行过程中,多次主动检查前台GC是否需要运行,如果需要运行则暂停后台GC,等待前台GC运行完毕后继续执行。

三、调整GC行为

1.设置GC模式

.NET中关于GC选项有两个:1.决定是服务器模式,还是工作站模式;2.是否启用后台GC。
针对.net core新项目格式,可以修改csproj项目文件,在PropertyGroup中添加ServerGarbageCollection为true时,使用服务器模式,否则工作站模式,添加ConcurrentGarbageCollection为true时,启用后台GC,否则禁用后台GC

如果.NET程序用于给其他计算机提供服务,希望尽可能占用系统资源以提升吞吐量,应该启用服务器模式,入股偶希望尽量减少内存占用以支持在同一个环境运行更多程序,则使用工作站模式。若没有特殊原因,后台GC应该保持开启,能减少普通GC工作量与停顿时间。

2.设置延迟模式

修改全局变量System.Runtime.GCSetting.LatencyMode可设置延迟模式:

  • Batch:禁用后台GC,只允许普通GC
  • Interactive:启用后台GC与普通GC,常用
  • LowLatency:抑制针对第2代的普通GC与后台GC,除非系统内存不足
  • NoGCRegion:预分配空间用完之前,不允许GC,此值无法手动设置
  • SustainedLowLatency:抑制针对第2代普通GC,除非系统内存不足

3.设置延迟等级

延迟等级与延迟模式是两个不同的属性,延迟等级主要影响计算代内部计算分配量阈值使用的参数,分配量阈值会影响触发GC的频率。两种延迟等级:低内存使用,尽量减少内存使用量增加GC触发频率;平衡模式,平衡内存使用量与GC触发频率,默认值为平衡模式。

4.开启无GC区域

.net提供无GC区域机制,用于抑制所有GC触发。会先从托管堆顶部预留指定大小空间,之后所有对象都会从这个空间分配,直到空间用完,都不会触发GC。
调用函数System.GC.TryStartNoGCRegion,第三个参数为false时,代表第一次调用失败后,会触发一次完整GC,再次尝试调用,失败后才会返回false表示函数调用失败。
如果空间用尽延迟模式会从当前的NoGCRegion恢复到调用之前的模式,手动关闭无GC区域时,需要再次判断当前模式是否为NoGCRegion。

5.开启大对象压缩

默认情况下大对象堆段不会执行压缩操作,因为其碎片空间一般很大,容易被复用,且移动大对象成本很高。设置全局变量System.Runtime.GCSetting.LargeObjectHeapCompactionMode为CompactOnce,下次触发完整GC时,会强制对大对象堆进行压缩操作,但GC结束后会恢复这个值为默认值,此设置只会生效一次,下次仍需要手动设置。

6.保留堆段空间地址

默认情况下,一个堆段中所有对象都不再存活,堆段使用的物理内存会被系统回收,管理堆段的实例会被删除,启用保留堆段空间地址选项,此实例可以不被删除,加入到可重用堆段列表中,下次使用该堆段时,可以在相同虚拟内存空间分配对象,防止虚拟内存空间碎片化。64位虚拟内存空间很大,不需要太关注此问题,32位平台可能需要开启此选项。

7.其他针对GC的选项

.net core还有很多针对GC的选项,包括GC日志,区域数量,设置堆段大小等等。参数都在CLR Configuration Knobs文档的Garbage collector Configuration Konb节。可以查询到相关内容,但一般情况下使用.net默认值就好,能够满足基本上所有需求。

四、获取GC信息

1.获取GC执行次数

.net提供了一系列接口获取GC相关信息。托管堆每个代都有计数器,用于统计程序启动以来,GC执行了多少次属于这个代的处理。针对第1代触发普通GC,会同时触发第0代,针对第2代的普通GC会触发1代与0代,后台GC只针对第2代,接口:GC.CollectionCount(0),参数代表要查询的代。

2.注册完整GC触发前的通知

.net提供了专门用于监听完整GC触发与结束的接口。接口基于第2代分配量阈值。启用监视使用RegisterForFullGCNotification函数,可传入数值,代表阈值到多少发出通知。等待通知使用WaitForFullGCApproach函数,函数有可能返回监视已经取消,或超时的枚举值,需要检查。结束检查需要使用WaitForFullGCComplete函数,也需要检查返回值
应用场景:完整触发GC前,可通知负载均衡器不要把请求发到此节点,等待完整GC结束后,再通知负载均衡器可接受请求。注意此接口无法保证100%,在完整GC前接受到通知。某些情况,会直接触发完整GC,例如系统内存不足。

3.利用系统事件捕捉GC事件

在Windows上可以使用etw事件机制记录运行时内部发生的各种事件,这些事件由操作系统管理,可以被外部的工具捕捉与查看,如PerfView工具。还可以使用托管低吗捕捉ETW事件,需要使用TraceEvent的nuget包。
在Linux上使用Lttng捕捉GC事件。

4.使用EventListner捕捉GC事件

.net core 2.2开始内部事件会通过EventSource记录并允许捕捉EventListener捕捉。这个接口只能记录自身进程内发生的事件。

总结

本篇主要记录GC的整个流程细节,基本上有很多思想可以借鉴,特别是那些用位代表范围的提升性能的方式。其中了解了GC整个流程可以知道.net为GC做了很多很多工作,希望以后.net能发扬光大。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值