CoreCLR文档翻译 - GC的设计

时间:2021-06-20 02:46:09

此文档来源于CoreCLR的BOTR(The Book of the Runtime), 点击打开原文

一切著作权归微软公司所有

GC的设计

作者: Maoni Stephens (@maoni0) - 2015

提示: 推荐看 The Garbage Collection Handbook 这本书学习更多关于GC的知识 (在文章底部的链接中)

组件结构

在GC中有两个主要的组件, 一个是分配器(Allocator), 另一个是收集器(Collector).

分配器负责获取更多的内存并且在适当的时机触发收集器.

收集器负责回收垃圾和不再被程序使用的对象内存.

此外还有一些途径可以触发收集器, 例如手动调用GC.Collect函数或析构线程(Finalizer Thread)收到一个内存不足的异步通知(由收集器发送).

分配器的设计

分配器由运行引擎(Execution Engine (EE))调用, 调用时会带有以下的信息:

  • 需要分配的大小
  • 线程专用的分配上下文(Allocation Context)
  • 标记, 如对象是否有析构函数

GC不会根据对象类型的不同做出特殊处理, 它会从运行引擎获取对象的大小, 根据对象的大小把对象分为两类:

  • 小对象 (小于 85,000字节)
  • 大对象 (大于或等于 85,000字节)

原则上小对象和大对象都可以用同样的方式处理, 但因为压缩(Compacting)大对象的代价会比较昂贵所以GC会区分对待.

当GC把一段内存交给分配器时, 它会参照分配上下文(Allocation Context).

分配上下文的大小取决于分配单位(Allocation Quantum)的定义.

  • 分配上下文是在堆段(Heap Segment)中专门给指定线程使用的小区域, 在单核计算机(指单逻辑核心)中只会使用一个上下文, 即第0世代(Generation 0)分配上下文.
  • 分配单位是分配器每次申请的内存大小, 用于在分配上下文中给对象分配内存. 分配单位通常为8K且受管理对象(Managed Object)的大小通常为32字节, 使得一个分配单位可以用于多个对象的分配 (译者注: 原理和内存池相同)

大对象不会使用分配上下文和分配单位, 因为一个大对象本身可以大于这些小区域.

并且使用这些区域带来的好处(会在下面讨论)仅仅受限于小对象.

大对象会直接在堆段(Heap Segment)中分配.

分配器的设计要求实现:

  • 在适当时机触发GC: 如果超过了分批预算(由收集器设置的阈值)或分配器不能在指定的堆段上分配时将会触发GC, 分配预算(Allocation Budget)和受管理的段(Managed Segments)的细节将在下面讨论.
  • 保持对象位置: 如果多个对象在同一个堆段中分配, 它们的虚拟内存地址也会邻接.
  • 高效的使用缓存: 分配器每次都会按 分配单位 申请内存, 而不是按每个对象的大小. 在申请内存后它会对足够激活cpu缓存的内存大小进行清0, 因为在此之后对象会从这块内存中分配(所以性能会得到提升). 分配单位通常为8K.
  • 高效的避免线程锁: 因为分配上下文和线程的绑定, 可以保证每个分配单位的内存只有对应一个线程可以操作. 因此只要当前的分配上下文没有用完, 给对象分配内存时就不需要线程锁.
  • 内存完整性: GC总会把新分配的对象的内存清0, 从而防止对象指向随机的内容(未定义的内容).
  • 保证堆可爬取(Crawlable): 分配器会在每个分配单位即将用完之际新建一个*对象(Free Object)填充, 例如一个分配单位剩余30个字节并且下一个要分配的对象需要40个字节, 则分配器会新建一个30个字节的*对象填充原来的分配单位并重新获取一个新的分配单位

分配器的API

 Object* GCHeap::Alloc(size_t size, DWORD flags);
Object* GCHeap::Alloc(alloc_context* acontext, size_t size, DWORD flags);

以上的函数可以用于分配小对象和大对象.

另外还有一个函数用于强制从大对象的堆(LOH: Large Object Heap)中分配内存:

 Object* GCHeap::AllocLHeap(size_t size, DWORD flags);

收集器的设计

GC的目标

GC致力于高效的内存管理, 只要求程序员付出很小的努力

高效指的是:

  • GC应该足够频繁的发生, 以避免堆中有大量(根据比例和绝对值)未使用但已分配的对象(垃圾)和多余的内存.
  • GC应该尽可能不频繁的发生, 以避免过多的消耗cpu时间, 即便频繁发生可以让程序占用更小的内存.
  • GC应该是生产性的, 如果一次GC只回收了少量的内存那么这次GC和它消耗的cpu周期被浪费了.
  • 每次GC应该足够快, 许多场景会要求低延迟.
  • 程序员们不需要为了优化内存的利用对GC了解太多(取决于他们的工作).

    – GC应该调整自身以适应不同的内存使用模式.

受管理的堆(Managed Heap)的逻辑表现

CLR GC把对象分成了不同的世代, 当第 N 世代的垃圾被回收后, 生存的对象会被标记为第 N+1 世代, 这个过程被称为升级(Promotion).

还有一些例外的情况当我们决定是否降级或不升级.

对于小对象的堆会分为3个世代: 第0世代(gen0), 第1世代(gen1)和第2世代(gen2).

对于大对象只有1个世代: 第3世代(gen3).

第0世代和第1世代被称为短暂(对象的生命周期短)的世代.

对于小对象的堆, 世代中的数字代表了年龄, 第0世代是最年轻的世代.

但不代表第0世代中的对象一定比第1世代和第2世代的对象年轻, 下面将会说明那些例外.

收集一个世代中的垃圾同时也会收集所有比它年轻的世代的垃圾.

原则上大对象可以使用和小对象一样的处理方式, 但是压缩大对象的代价会非常的昂贵, 因此大对象和小对象会受到不同的对待.

因为性能上的原因, 大对象只使用了一个世代(第3世代)并且这个世代会和第2世代一起回收垃圾.

因为第2世代和第3世代可以很大, 需要和短暂的世代(第0世代和第1世代)在开销上划出边界.

为对象分配内存时总会从最年轻的世代分配 - 对于小对象总会从第0世代分配, 对于大对象总会从第3世代分配(因为只有一个世代).

受管理的堆(Managed Heap)的物理表现

受管理的堆(Managed Heap)是一个包含了受管理的堆段(Managed Heap Segments)的集合.

受管理的堆段是从系统内核申请得到的一块连续的内存空间. (译者注: 即malloc/brk申请得到的空间)

用于区别小对象和大对象, 堆段又分为小对象堆段和大对象堆段.

每个堆中的堆段都是相互链接在一起的, 至少会有1个小对象堆段和1个大对象堆段 - 它们会在加载CLR时预留.

每个小对象的堆中只有一个短暂的堆段(Ephemeral Segment)用于存放短暂世代(第0世代和第1世代)的对象, 但也有可能包含第2世代的对象.

其他额外的堆段(0或1或更多个)中只会存放第2世代的对象.

每个大对象的堆中有一个或更多个堆段.

堆段中的空间会从较低的地址向较高的地址消耗, 即地址更小的对象比地址更大的对象更老. 这里也有一些例外将在下面说明.

堆段会在需要时向系统申请, 并且在不包含任何生存的对象时删除.

但是初始的堆段(加载时预留的)会一直保留.

每个堆中每次只会处理一段(而不是全部), 如在回收小对象和分配大对象时.

这样的设计提供了更好的性能, 因为大对象只会在第2世代回收时一同回收(代价相当昂贵).

堆段会按它们的申请顺序链接在一起, 链中的最后一个堆段一定是短暂的堆段(Ephemeral Segment).

回收的堆段(不包含任何生存的对象)不一定会被删除, 也可能被作为一个新的短暂的堆段, 这种机制仅在小对象的堆中实现.

每次分配大对象都会考虑整个大对象的堆, 而小对象仅仅考虑短暂的堆段.

分配预算(Allocation Budget)

分配预算是一个关联于每个世代的逻辑概念, 当世代的大小达到了指定的限制则会触发GC.

每个世代的预算值属性基本取决于该世代的对象的生存率, 如果生存率较高, 那么这个限制会调大使得下次对该世代的GC会得到一个更好的回收率.

判断需要回收哪个世代的垃圾

当GC被触发时, GC首先需要确定回收哪个世代.

除了分配预算外, 还有这些因素需要考虑:

  • 世代的碎片化程度 – 如果这个世代的碎片化程度比较高, 则收集这个世代将会得到更好的效果
  • 如果系统内存占用比较高, 并且每次可以回收到一定的内存, GC可能会更积极的去回收.

    这对于防止系统内存分页(把多出的内存数据转移到硬盘)很重要.
  • 如果短暂的堆段的空间已经用完, GC可能会更积极的去回收以防止申请一个新的堆段.

GC的工作流程

标记阶段 (Mark phase)

标记阶段的目标是寻找所有存活的对象.

多世代收集器的好处是每次只需要去看堆中最近的对象, 而不需要去看历史生成的所有对象.

当收集短暂世代(第0世代和第1世代)中的对象时, GC需要寻找这些世代中所有存活的对象,

运行引擎(EE)使用中的对象会标记为存活, 此外被其他对象(更老世代的对象)引用的对象也会标记为存活.

GC在标记更老的世代中的对象时会使用卡片(Cards),

JIT的帮助类会在赋值操作时设置卡片, 如果JIT的帮助类看到一个对象在短暂的范围中它会设置一个包含了源位置的卡片的字节.

在短暂世代的回收中, GC可以只看堆中其余的部分设置的卡片来找到它们对应的对象.

(译者注: 卡片表用于标记跨代引用,例如只扫代0的时候如果代1引用了代0的对象也要扫卡片表中代1的部分对象)

计划阶段 (Plan phase)

计划阶段会做一个比较来决定使用压缩(Compaction)还是清扫(Sweeps).

如果压缩效果更好则GC会开始实际的压缩, 否则GC会开始清扫.

重定位阶段 (Relocate phase)

如果GC决定要压缩, 那就要移动现有的对象, 指向这些对象的引用都需要被更新.

重定位阶段需要找出指向回收世代中的对象的所有引用.

相反, 标记阶段仅会参考存活的对象因此不需要考虑弱引用(Weak References).

压缩阶段 (Compact phase)

这个阶段的目标非常直接, 因为计划阶段已经计算了对象应该移动到的新地址, 压缩阶段会复制对象到这些新地址中.

清扫阶段 (Sweep phase)

清扫阶段会寻找夹在存活对象中的空余空间(死对象), 并在这些空间里创建一些*对象.

相邻的死对象会被合并成一个*对象.

创建的*对象会放到 *对象列表(freelist) 里.

代码流程

缩写: (译者注: 以下不会使用这些缩写)

  • WKS GC: 工作站GC.
  • SRV GC: 服务器GC

各个模式的举动

工作站GC - 不启用并发式GC

  1. 用户线程超过了分配预算并触发了GC.
  2. GC调用SuspendEE函数来停止所有受管理的线程(Managed Threads).
  3. GC决定需要回收哪个世代.
  4. 运行标记阶段.
  5. 运行计划阶段决定是用压缩还是用清扫.
  6. 如果决定压缩则运行重定位阶段和压缩阶段, 否则运行清扫阶段.
  7. GC调用RestartEE函数来恢复受管理的线程.
  8. 用户线程恢复运行.

工作站GC - 启用并发式GC

这里说明了后台GC如何运作.

  1. 用户线程超过了分配预算并触发了GC.
  2. GC调用SuspendEE函数来停止所有受管理的线程(Managed Threads).
  3. GC决定是否使用后台GC.
  4. 如果使用后台GC, 则后台GC线程会被唤醒并进行回收工作. 后台GC线程会调用RestartEE唤醒受管理的线程.
  5. 受管理的线程继续运行, 同时后台GC线程也继续它的工作.
  6. 用户线程可能又一次的超过了分配预算并触发了一个短暂的GC(我们称为前台GC), 流程和"工作站GC - 不启用并发式GC"一样.
  7. 后台GC线程再次调用SuspendEE函数, 进行标记阶段(Marking), 然后调用RestartEE函数, 再进行可以并行的清扫阶段(Sweep).
  8. 后台GC已完成工作.

服务器GC - 不启用并发式GC

  1. 用户线程超过了分配预算并触发了GC.
  2. 服务器GC线程被唤醒, 并调用SuspendEE来停止所有受管理的线程(Managed Threads).
  3. 服务器GC进行回收工作(流程和工作站GC - 不启用并发式GC相同).
  4. 服务器GC线程调用RestartEE唤醒受管理的线程.
  5. 用户线程恢复运行.

服务器GC - 启用并发式GC

流程和工作站GC - 启用并发式GC一样, 除了非后台的GC也会在服务器GC线程中完成.

物理架构

这一段旨在帮助你追踪代码流程.

当用户线程用完分配单位(Allocation Quantum), 需要一个新的分配单位时会调用try_allocate_more_space函数.

当try_allocate_more_space函数需要触发GC时会调用GarbageCollectGeneration函数.

在"工作站GC - 不启用并发式GC"的模式下, GarbageCollectGeneration会在触发GC的用户线程上完成所有工作, 代码流程是:

 GarbageCollectGeneration()
{
SuspendEE();
garbage_collect();
RestartEE();
} garbage_collect()
{
generation_to_condemn();
gc1();
} gc1()
{
mark_phase();
plan_phase();
} plan_phase()
{
// 实际的计划阶段, 判断要用压缩还是清扫
if (compact)
{
relocate_phase();
compact_phase();
}
else
make_free_lists();
}

在"工作站GC - 启用并发式GC"的模式(默认模式)下, 后台GC的代码流程是:

 GarbageCollectGeneration()
{
SuspendEE();
garbage_collect();
RestartEE();
} garbage_collect()
{
generation_to_condemn();
// 判断要用后台GC, 唤醒后台GC
do_background_gc();
} do_background_gc()
{
init_background_gc();
start_c_gc (); // 等待后台GC完成工作并重启受管理的线程
wait_to_proceed();
} bgc_thread_function()
{
while (1)
{
// 等待事件
// 唤醒
gc1();
}
} gc1()
{
background_mark_phase();
background_sweep();
}

资源链接

译者后注

这份文档简单的介绍了GC的设计和工作流程, 同时也带来和很多疑问, 例如一个程序有多少个堆和什么是卡片等

我将在CoreCLR源码探索的系列文章中分析CoreCLR的GC源码来解决这些疑问

另外博客园上已经有一位大神对CoreCLR的GC源码进行了部分分析,可以查看他的博客