在这篇中我将讲述GC Collector内部的实现, 这是CoreCLR中除了JIT以外最复杂部分,下面一些概念目前尚未有公开的文档和书籍讲到。
为了分析这部分我花了一个多月的时间,期间也多次向CoreCLR的开发组提问过,我有信心以下内容都是比较准确的,但如果你发现了错误或者有疑问的地方请指出来,
以下的内容基于CoreCLR 1.1.0的源代码分析,以后可能会有所改变。
因为内容过长,我分成了两篇,这一篇分析代码,下一篇实际使用LLDB跟踪GC收集垃圾的处理。
需要的预备知识
看过BOTR中GC设计的文档 原文 译文
看过我之前的系列文章, 碰到不明白的可以先跳过但最少需要看一遍
CoreCLR源码探索(一) Object是什么
CoreCLR源码探索(二) new是什么
CoreCLR源码探索(三) GC内存分配器的内部实现
对c中的指针有一定了解
对常用数据结构有一定了解, 例如链表
对基础c++语法有一定了解, 高级语法和STL不需要因为微软只用了低级语法
GC的触发
GC一般在已预留的内存不够用或者已经分配量超过阈值时触发,场景包括:
不能给分配上下文指定新的空间时
当调用try_allocate_more_space不能从segment结尾或自由对象列表获取新的空间时会触发GC, 详细可以看我上一篇中分析的代码。
分配的数据量达到一定阈值时
阈值储存在各个heap的dd_min_gc_size(初始值), dd_desired_allocation(动态调整值), dd_new_allocation(消耗值)中,每次给分配上下文指定空间时会减少dd_new_allocation。
如果dd_new_allocation变为负数或者与dd_desired_allocation的比例小于一定值则触发GC,
触发完GC以后会重新调整dd_new_allocation到dd_desired_allocation。
参考new_allocation_limit, new_allocation_allowed和check_for_full_gc函数。
值得一提的是可以在.Net程序中使用GC.RegisterForFullGCNotification可以设置触发GC需要的dd_new_allocation / dd_desired_allocation的比例(会储存在fgn_maxgen_percent和fgn_loh_percent中), 设置一个大于0的比例可以让GC触发的更加频繁。
StressGC
允许手动设置特殊的GC触发策略, 参考这个文档
作为例子,你可以试着在运行程序前运行export COMPlus_GCStress=1
GCStrees会通过调用GCStress<gc_on_alloc>::MaybeTrigger(acontext)
触发,
如果你设置了COMPlus_GCStressStart环境变量,在调用MaybeTrigger一定次数后会强制触发GC,另外还有COMPlus_GCStressStartAtJit等参数,请参考上面的文档。
默认StressGC不会启用。
手动触发GC
在.Net程序中使用GC.Collect可以触发手动触发GC,我相信你们都知道。
调用.Net中的GC.Collect会调用CoreCLR中的GCHeap::GarbageCollect => GarbageCollectTry => GarbageCollectGeneration。
GC的处理
以下函数大部分都在gc.cpp里,在这个文件里的函数我就不一一标出文件了。
GC的入口点
GC的入口点是GCHeap::GarbageCollectGeneration函数,这个函数的主要作用是停止运行引擎和调用各个gc_heap的gc_heap::garbage_collect函数
因为这一篇重点在于GC做出的处理,我将不对如何停止运行引擎和后台GC做出详细的解释,希望以后可以再写一篇文章讲述
以下是gc_heap::garbage_collect函数,这个函数也是GC的入口点函数,
主要作用是针对gc_heap做gc开始前和结束后的清理工作,例如重设各个线程的分配上下文和修改gc参数
GC的主函数
GC的主函数是gc1,包含了GC中最关键的处理,也是这一篇中需要重点讲解的部分。
gc1中的总体流程在BOTR文档已经有初步的介绍:
首先是mark phase,标记存活的对象
然后是plan phase,决定要压缩还是要清扫
如果要压缩则进入relocate phase和compact phase
如果要清扫则进入sweep phase
在看具体的代码之前让我们一起复习之前讲到的Object的结构
GC使用其中的2个bit来保存标记(marked)和固定(pinned)
标记(marked)表示对象是存活的,不应该被收集,储存在MethodTable指针 & 1中
固定(pinned)表示对象不能被移动(压缩时不要移动这个对象), 储存在对象头 & 0x20000000中
这两个bit会在mark_phase中被标记,在plan_phase中被清除,不会残留到GC结束后
再复习堆段(heap segment)的结构
一个gc_heap中有两个segment链表,一个是小对象(gen 0~gen 2)用的链表,一个是大对象(gen 3)用的链表,
其中链表的最后一个节点是ephemeral heap segment,只用来保存gen 0和gen 1的对象,各个代都有一个开始地址,在开始地址之后的对象属于这个代或更年轻的代。
接下来我们将分别分析GC中的五个阶段(mark_phase, plan_phase, relocate_phase, compact_phase, sweep_phase)的内部处理
标记阶段(mark_phase)
这个阶段的作用是找出收集垃圾的范围(gc_low ~ gc_high)中有哪些对象是存活的,如果存活则标记(m_pMethTab |= 1),
另外还会根据GC Handle查找有哪些对象是固定的(pinned),如果对象固定则标记(m_uSyncBlockValue |= 0x20000000)。
简单解释下GC Handle和Pinned Object,GC Handle用于在托管代码中调用非托管代码时可以决定传递的指针的处理,
一个类型是Pinned的GC Handle可以防止GC在压缩时移动对象,这样非托管代码中保存的指针地址不会失效,详细可以看微软的文档。
在继续看代码之前我们先来了解Card Table的概念:
Card Table
如果你之前已经了解过GC,可能知道有的语言实现GC会有一个根对象,从根对象一直扫描下去可以找到所有存活的对象。
但这样有一个缺陷,如果对象很多,扫描的时间也会相应的变长,为了提高效率,CoreCLR使用了分代GC(包括之前的.Net Framework都是分代GC),
分代GC可以只选择扫描一部分的对象(年轻的对象更有可能被回收)而不是全部对象,那么分代GC的扫描是如何实现的?
在CoreCLR中对象之间的引用(例如B是A的成员或者B在数组A中,可以称作A引用B)一般包含以下情况
各个线程栈(stack)和寄存器(register)中的对象引用堆段(heap segment)中的对象
CoreCLR有办法可以检测到Managed Thread中在栈和寄存器中的对象
这些对象是根对象(GC Root)的一种
GC Handle表中的句柄引用堆段(heap segment)中的对象
这些对象也是根对象的一种
析构队列中的对象引用堆段(heap segment)中的对象
这些对象也是根对象的一种
同代对象之间的引用
隔代对象之间的引用
请考虑下图的情况,我们这次只想扫描gen 0,栈中的对象A引用了gen 1的对象B,对象B引用了gen 0的对象C,
在扫描的时候因为B不在扫描范围(gc_low ~ gc_high)中,CoreCLR不会去继续跟踪B的引用,
如果这时候gen 0中无其他对象引用对象C,是否会导致对象C被误回收?
为了解决这种情况导致的问题,CoreCLR使用了Card Table,所谓Card Table就是专门记录跨代引用的一个数组
当我们设置B.member = C的时候,JIT会把赋值替换为JIT_WriteBarrier(&B.member, C)(或同等的其他函数)
JIT_WriteBarrier函数中会设置*dst = ref,并且如果ref在ephemeral heap segment中(ref可能是gen 0或gen 1的对象)时,
设置dst在Card Table中所属的字节为0xff,Card Table中一个字节默认涵盖的范围在32位下是1024字节,在64位下是2048字节。
需要注意的是这里的dst是B.member的地址而不是B的地址,B.member的地址会是B的地址加一定的偏移值,
而B自身的地址不一定会在Card Table中得到标记,我们之后可以根据B.member的地址得到B的地址(可以看find_first_object函数)。
有了Card Table以后,只回收年轻代(非Full GC)时除了扫描根对象以外我们还需要扫描Card Table中标记的范围来防止误回收对象。
接下来我们看下GCHeap::Promote函数,在plan_phase中扫描到的对象都会调用这个函数进行标记,
这个函数名称虽然叫Promote但是里面只负责对对象进行标记,被标记的对象不一定会升代
经过标记阶段以后,在堆中存活的对象都被设置了marked标记,如果对象是固定的还会被设置pinned标记
接下来是计划阶段plan_phase:
计划阶段(plan_phase)
在这个阶段首先会模拟压缩和构建Brick Table,在模拟完成后判断是否应该进行实际的压缩,
如果进行实际的压缩则进入重定位阶段(relocate_phase)和压缩阶段(compact_phase),否则进入清扫阶段(sweep_phase),
在继续看代码之前我们需要先了解计划阶段如何模拟压缩和什么是Brick Table。
计划阶段如何模拟压缩
计划阶段首先会根据相邻的已标记的对象创建plug,用于加快处理速度和减少需要的内存空间,我们假定一段内存中的对象如下图
计划阶段会为这一段对象创建2个unpinned plug和一个pinned plug:
第一个plug是unpinned plug,包含了对象B, C,不固定地址
第二个plug是pinned plug,包含了对象E, F, G,固定地址
第三个plug是unpinned plug,包含了对象H,不固定地址
各个plug的信息保存在开始地址之前的一段内存中,结构如下
struct plug_and_gap
{
// 在这个plug之前有多少空间是未被标记(可回收)的
ptrdiff_t gap;
// 压缩这个plug中的对象时需要移动的偏移值,一般是负数
ptrdiff_t reloc;
union
{
// 左边节点和右边节点
pair m_pair;
int lr; //for clearing the entire pair in one instruction
};
// 填充对象(防止覆盖同步索引块)
plug m_plug;
};
眼尖的会发现上面的图有两个问题
对象G不是pinned但是也被归到pinned plug里了
这是因为pinned plug会把下一个对象也拉进来防止pinned object的末尾被覆盖,具体请看下面的代码
第三个plug把对象G的结尾给覆盖(破坏)了
对于这种情况原来的内容会备份到saved_post_plug中,具体请看下面的代码
多个plug会构建成一棵树,例如上面的三个plug会构建成这样的树:
第一个plug: { gap: 24, reloc: 未定义, m_pair: { left: 0, right: 0 } }
第二个plug: { gap: 132, reloc: 0, m_pair: { left: -356, right: 206 } }
第三个plug: { gap: 24, reloc: 未定义, m_pair: { left: 0, right 0 } }
第二个plug的left和right保存的是离子节点plug的偏移值,
第三个plug的gap比较特殊,可能你们会觉得应该是0但是会被设置为24(sizeof(gap_reloc_pair)),这个大小在实际复制第二个plug(compact_plug)的时候会加回来。
当计划阶段找到一个plug的开始时,
如果这个plug是pinned plug则加到mark_stack_array队列中。
当计划阶段找到一个plug的结尾时,
如果这个plug是pinned plug则设置这个plug的大小并移动队列顶部(mark_stack_tos),
否则使用使用函数allocate_in_condemned_generations计算把这个plug移动到前面(压缩)时的偏移值,
allocate_in_condemned_generations的原理请看下图
函数allocate_in_condemned_generations不会实际的移动内存和修改指针,它只设置了plug的reloc成员,
这里需要注意的是如果有pinned plug并且前面的空间不够,会从pinned plug的结尾开始计算,
同时出队列以后的plug B在mark_stack_array中的len会被设置为前面一段空间的大小,也就是32+39=71。
现在让我们思考一个问题,如果我们遇到一个对象x,如何求出对象x应该移动到的位置?
我们需要根据对象x找到它所在的plug,然后根据这个plug的reloc移动,查找plug使用的索引就是接下来要说的Brick Table。
Brick Table
brick_table是一个类型为short*的数组,用于快速索引plug,如图
根据所属的brick不同,会构建多个plug树(避免plug树过大),然后设置根节点的信息到brick_table中,
brick中的值如果是正值则表示brick对应的开始地址离根节点plug的偏移值+1,
如果是负值则表示plug树横跨了多个brick,需要到前面的brick查找。
brick_table相关的代码如下,我们可以看到在64位下brick的大小是4096,在32位下brick的大小是2048
brick_table中出现负值的情况是因为plug横跨幅度比较大,超过了单个brick的时候后面的brick就会设为负值,
如果对象地址在上图的1001或1002,查找这个对象对应的plug会从1000的plug树开始。
另外1002中的值不一定需要是-2,-1也是有效的,如果是-1会一直向前查找直到找到正值的brick。
在上面我们提到的问题可以通过brick_table解决,可以看下面relocate_address函数的代码。
brick_table在gc过程中会储存plug树,但是在gc完成后(gc不执行时)会储存各个brick中地址最大的plug,用于给find_first_object等函数定位对象的开始地址使用。
对于Pinned Plug的特殊处理
pinned plug除了会在plug树和brick table中,还会保存在mark_stack_array队列中,类型是mark。
因为unpinned plug和pinned plug相邻会导致原来的内容被plug信息覆盖,mark中还会保存以下的特殊信息
saved_pre_plug
如果这个pinned plug覆盖了上一个unpinned plug的结尾,这里会保存覆盖前的原始内容
saved_pre_plug_reloc
同上,但是这个值用于重定位和压缩阶段(中间会交换)
saved_post_plug
如果这个pinned plug被下一个unpinned plug覆盖了结尾,这里会保存覆盖前的原始内容
saved_post_plug_reloc
同上,但是这个值用于重定位和压缩阶段(中间会交换)
saved_pre_plug_info_reloc_start
被覆盖的saved_pre_plug内容在重定位后的地址,如果重定位未发生则可以直接用(first - sizeof (plug_and_gap))
saved_post_plug_info_start
被覆盖的saved_post_plug内容的地址,注意pinned plug不会被重定位
saved_pre_p
是否保存了saved_pre_plug
如果覆盖的内容包含了对象的开头(对象比较小,整个都被覆盖了)
这里还会保存对象离各个引用成员的偏移值的bitmap (enque_pinned_plug)
saved_post_p
是否保存了saved_post_p
如果覆盖的内容包含了对象的开头(对象比较小,整个都被覆盖了)
这里还会保存对象离各个引用成员的偏移值的bitmap (save_post_plug_info)
mark_stack_array中的len意义会在入队和出队时有所改变,
入队时len代表pinned plug的大小,
出队后len代表pinned plug离最后的模拟压缩分配地址的空间(这个空间可以变成free object)。
mark_stack_array
mark_stack_array的结构如下图:
入队时mark_stack_tos增加,出队时mark_stack_bos增加,空间不够时会扩展然后mark_stack_array_length会增加。
计划阶段判断使用压缩(compact)还是清扫(sweep)的依据是什么
计划阶段模拟压缩的时候创建plug,设置reloc等等只是为了接下来的压缩做准备,既不会修改指针地址也不会移动内存。
在做完这些工作之后计划阶段会首先判断应不应该进行压缩,如果不进行压缩而是进行清扫,这些计算结果都会浪费掉。
判断是否使用压缩的根据主要有
系统空余空闲是否过少,如果过少触发swap可能会明显的拖低性能,这时候应该尝试压缩
碎片空间大小(fragmentation) >= 阈值(dd_fragmentation_limit)
碎片空间大小(fragmentation) / 收集代的大小(包括更年轻的代) >= 阈值(dd_fragmentation_burden_limit)
其他还有一些零碎的判断,将在下面的decide_on_compacting函数的代码中讲解。
对象的升代与降代
在很多介绍.Net GC的书籍中都有提到过,经过GC以后对象会升代,例如gen 0中的对象在一次GC后如果存活下来会变为gen 1。
在CoreCLR中,对象的升代需要满足一定条件,某些特殊情况下不会升代,甚至会降代(gen1变为gen0)。
对象升代的条件如下:
计划阶段(plan_phase)选择清扫(sweep)时会启用升代
入口点(garbage_collect)判断当前是Full GC时会启用升代
dt_low_card_table_efficiency_p成立时会启用升代
请在前面查找dt_low_card_table_efficiency_p查看该处的解释
计划阶段(plan_phase)判断上一代过小,或者这次标记(存活)的对象过多时启用升代
请在后面查找promoted_bytes (i) > m查看该处的解释
如果升代的条件不满足,则原来在gen 0的对象GC后仍然会在gen 0,
某些特殊条件下还会发生降代,如下图:
在模拟压缩时,原来在gen 1的对象会归到gen 2(pinned object不一定),原来在gen 0的对象会归到gen 1,
但是如果所有unpinned plug都已经压缩到前面,后面还有残留的pinned plug时,后面残留的pinned plug中的对象则会不升代或者降代,
当这种情况发生时计划阶段会设置demotion_low来标记被降代的范围。
如果最终选择了清扫(sweep)则上图中的情况不会发生。
计划代边界
计划阶段在模拟压缩的时候还会计划代边界(generation::plan_allocation_start),
计划代边界的工作主要在process_ephemeral_boundaries, plan_generation_start, plan_generation_starts函数中完成。
大部分情况下函数process_ephemeral_boundaries会用来计划gen 1的边界,如果不升代这个函数还会计划gen 0的边界,
当判断当前计划的plug大于或等于下一代的边界时,例如大于等于gen 0的边界时则会设置gen 1的边界在这个plug的前面。
最终选择压缩(compact)时,会把新的代边界设置成计划代边界(请看fix_generation_bounds函数),
最终选择清扫(sweep)时,计划代边界不会被使用(请看make_free_lists函数和make_free_list_in_brick函数)。
计划阶段(plan_phase)的代码
gc_heap::plan_phase函数的代码如下
计划阶段在模拟压缩和判断后会在内部包含重定位阶段(relocate_phase),压缩阶段(compact_phase)和清扫阶段(sweep_phase)的处理,
接下来我们仔细分析一下这三个阶段做了什么事情:
重定位阶段(relocate_phase)
重定位阶段的主要工作是修改对象的指针地址,例如A.Member的Member内存移动后,A中指向Member的指针地址也需要改变。
重定位阶段只会修改指针地址,复制内存会交给下面的压缩阶段(compact_phase)完成。
如下图:
图中对象A和对象B引用了对象C,重定位后各个对象还在原来的位置,只是成员的地址(指针)变化了。
还记得之前标记阶段(mark_phase)使用的GcScanRoots等扫描函数吗?
这些扫描函数同样会在重定位阶段使用,只是执行的不是GCHeap::Promote而是GCHeap::Relocate。
重定位对象会借助计划阶段(plan_phase)构建的brick table和plug树来进行快速的定位,然后对指针地址移动所属plug的reloc位置。
重定位阶段(relocate_phase)的代码
重定位阶段(relocate_phase)只是修改了引用对象的地址,对象还在原来的位置,接下来进入压缩阶段(compact_phase):
压缩阶段(compact_phase)
压缩阶段负责把对象复制到之前模拟压缩到的地址上,简单点来讲就是用memcpy复制这些对象到新的地址。
压缩阶段会使用之前构建的brick table和plug树快速的枚举对象。
gc_heap::compact_phase函数的代码如下:
这个函数的代码是不是有点眼熟?它的流程和上面的relocate_survivors很像,都是枚举brick table然后中序枚举plug树
压缩阶段结束以后还需要做一些收尾工作,请从上面plan_phase中的recover_saved_pinned_info();继续看。
参考链接
https://github.com/dotnet/coreclr/blob/master/Documentation/botr/garbage-collection.md
https://raw.githubusercontent.com/dotnet/coreclr/release/1.1.0/src/gc/gc.cpp
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcimpl.h
https://github.com/dotnet/coreclr/blob/release/1.1.0/src/gc/gcpriv.h
https://github.com/dotnet/coreclr/issues/8959
https://github.com/dotnet/coreclr/issues/8995
https://github.com/dotnet/coreclr/issues/9053
https://github.com/dotnet/coreclr/issues/10137
https://github.com/dotnet/coreclr/issues/10305
https://github.com/dotnet/coreclr/issues/10141
写在最后
GC的实际处理远远比文档和书中写的要复杂,希望这一篇文章可以让你更加深入的理解CoreCLR,如果你发现了错误或者有疑问的地方请指出来,
另外这篇文章有一些部分尚未涵盖到,例如SuspendEE的原理,后台GC的处理和stackwalking等,希望以后可以再花时间去研究它们。
下一篇我将会实际使用LLDB跟踪GC收集垃圾的处理,再下一篇会写JIT相关的内容,敬请期待
相关的代码太多,请阅读原文。
相关文章:
《代码的未来》读书笔记:内存管理与GC那点事儿
CoreCLR源码探索(一) Object是什么
CoreCLR源码探索(二) new是什么
CoreCLR源码探索(三) GC内存分配器的内部实现
.NET跨平台之旅:corehost 是如何加载 coreclr 的
.NET CoreCLR开发人员指南(上)
原文地址:http://www.cnblogs.com/zkweb/p/6625049.html
.NET社区新闻,深度好文,微信中搜索dotNET跨平台或扫描二维码关注