CoreCLR源码探索(四) GC内存收集器的内部实现 分析篇

来源:互联网 发布:淘宝评价体系分析 编辑:程序博客网 时间:2024/06/11 03:03

在这篇中我将讲述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跨平台或扫描二维码关注

阅读全文
0 0
原创粉丝点击