Skip to the content.

GC 在 .NETCore 的设计

组件架构

GC 有两个组件,一个是分配器和收集器。分配器主要负责获取更多内存信息以及在适当时候触发收集器。收集器负责回收垃圾,或者是那些在程序中没有用到的内存对象。

收集器还有另一种方式调用,那就是手动调用 GC.Collect,或者在finalizer线程接受到一个内存不足的异步通知(会触发调用收集器)

分配器设计

在执行引擎(Execution Engine,EE)中会调用分配器,以下是展示的信息:

GC 不对区别对待不同的对象。它根据 EE 来获取对象的大小。

基于这个大小,GC 会把这个对象分到两个类别:小对象(< 85000 字节)和大对象(> 85000 字节)。在管道中,大小对象都是以相同方式处理的,区别在于由于大对象有压缩操作,会让 GC 操作更显得昂贵。

当 GC 给分配器的内存时,会根据分配上下文分配内存。通过指定分配量来分配上下文的大小。

大对象是不会使用分配上下文和分配单元的。一个大对象要比那些较小的对象区域都要大。好处(下面会讨论)是特定于那些小对象的。大对象直接分配到堆片段。

分配器是按照下面几点来完成的:

分配 APIs

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

上面的函数能用来分配小对象和大对象。这里也有直接分配到大对象的函数:

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

收集器的设计

GC 的目标

GC 致力于有效的管理内存,以及让释放编写“托管代码”的人,不用关心垃圾回收。这里高效的意思如下:

托管堆的逻辑展示

CLR GC 是分代收集器,它意味着对象在逻辑逻辑上划分为代。当一个 N 代被回收时,生存的对象被标记为 N + 1 代。这个过程被称为提升。这里有一个例外,就是当我们决定降级而不是提升的时候。

对于小对象堆来说,被划分为三代:gen0,gen1,gen2。大对象只有一代 :gen3。Gen0 和 gen1 作为瞬时代(对象短时间内会被释放)优先分配。

小对象堆的代数标示它的年龄 — — gen0 是最年轻的代。这不意味着在这代中所有的对象都要比 gen1,gen2 要年轻。还是有例外的,这种情况将在下面会说。回收代意味着在这个带中回收所有的对象。

原则上,大对象与小对象一样的方式处理,但是由于大对象存在对象压缩操作,会让垃圾回收处理变得非常昂贵,所以处理他们又有些不同。大对象只有一代,并且出于性能方面,只用 gen2 收集器回收。gen2,gen3 都很大,回收瞬时代(gen0,gen1)可能也需要很大的成本。

分配操作是在 gen0 发生的 —— 小对象总是在 gen0,因为大小对象只有一代,所以大对象在 gen3。

托管堆的物理展示

托管堆是一组托管堆片段的集合。一个托管堆是一个连续的内存块,是 GC 通过操作系统获取的。堆段被分区到小对象段和大对象段,来区分小对象和大对象。每个堆与堆之家是链在一起的。至少有一个小对象和一个大对象 —— 当 CLR 加载时会反转加载。

在每个小对象堆中总是有一个瞬时段,这是 gen1 和 gen1 生存的地方。这个段可能会包含 gen2 的对象。除了瞬时段,还有零个,多个附加段,这些附加段都是 gen2,因为它只包含了 gen2 对象。

这里有 1 个或多个段在大对象堆。

堆段是从低地址到高地址使用的,这意味着段中低地址的对象比高地址的对象更老。下面将再次介绍一些例外情况。

堆段能够按需获取。当它不包含任何活性对象时会被删除,然而在堆上的原始段始终时存在。对于每个堆,每次获取一个段,它会在小对象 GC 期间进行,大对象的分配期间进行。这么设计是为了提供更好的性能,因为大对象只能在 gen2 中回收(它相对来说非常昂贵)

堆段在获取的时间顺序排列在一起的。链中最后一个段总是瞬时段。回收段(没有活动对象)能够重用,而不是删除也不是成为一个新的瞬时代。段重用只在小对象堆实现了这个功能。每次大对象分配时,都会考虑整个大对象堆。小对象堆分配只考虑瞬时段。

分配预算

分配预算是一个与每代相关的逻辑概念。它是一个尺寸大小的阈值,当 GC 代超出了这个限制阈值时,就会触发 GC。

预算(budget)是根据该代的存活率在该代上设置的一个属性。如果生存率高,预算就会变得更大,期望在下一次该代进行 GC 时,死对象与活对象得比率会更高。

指定代收集

当 GC 触发时,GC 首先就要决定哪一个代收集。在分配预算方面,有其他因素必须要考虑:

GC 流程

标记阶段

标记阶段的目的就是发现所有活动的对象。

分代收集器的好处就是收集部分堆的能力,而不是一直在查看所有的对象。当收集瞬时代时,GC 需要指出在这些代中哪些代是活动对象,这是通过 EE 报告出来的。在此方面,通过引用标记。在年老代中的对象也能保持对象在年轻代中活动。(收集器会通过根直接引用的对象,然后递归标记通过指针数组就能访问这些对象)

GC 使用卡片标记年老代。卡片是由 JIT 在分配操作期间设置的。如果 JIT 看到一个对象属于瞬时范围,他将设置一个字节,它标识这个卡片标识的源位置。在瞬时收集期间,GC 能在查看到剩余的堆的一组卡片以及这些卡片对应的对象。

计划阶段

在计划阶段会模拟压缩来确定有效结果。如果压缩成果明显,那么 GC 开始一个实际的压缩,否则 GC 就开始清扫(sweet)。

重定位阶段

如果 GC 决定压缩,那么作为结果肯定会移动对象,然后这些被标记引用的都会被更新。重定位阶段需要发现那些在代中等待回收的对象的所有引用。与之对比,标记阶段只关注活动对象,所以它不需要考虑弱引用。

压缩阶段

因为在计划阶段就已经计算好了这些对象要移动的新的地址,所以这个阶段非常简单。只用复制这些对象即可。

清除阶段

清楚阶段在活动对象之间查找死空间。它会生成空闲对象来替换这些空间。相邻的死对象会成为一个空闲对象。它会将所有的空闲对象放置到空闲链表中。

分代垃圾回收机制其实是利用了分代的概念,针对不同的代使用不同的 GC 算法。对于新生的对象(瞬时代)采用的算法一般都是引用计数法,都只会查找活动对象(GC 标记清除算法和 GC 复制算法),这样的话,越多的对象死去,花费在 GC 的时间就越少。

代码流程

由下面两个构成:

行为功能

WKS GC 与关闭并发 GC

  1. 用户线程运行时耗尽了分配预算并触发一个 GC
  2. GC 调用 SuspendEE 挂起托管线程
  3. GC 决定代
  4. 运行标记阶段
  5. 运行计划阶段以及决定 GC 是否执行压缩
  6. 重定位和压缩阶段运行,否则运行扫描阶段
  7. GC 调用 RestartEE 恢复托管线程
  8. 用户线程恢复运行

WKS GC 与开启并发 GC

下面分析的是后端 GC 做的逻辑

  1. 用户线程运行耗尽分配预算并触发 GC
  2. GC 调用 SuspendEE 挂起托管线程
  3. GC 决定是否由后台运行 GC
  4. 如果一个后台 GC 线程被唤醒。后台 GC 线程就会调用 RestartEE 来恢复托管线程
  5. 托管线程在后台 GC 作恶的时候继续分配
  6. 用户线程可能还是会耗尽分配预算并且触发一个瞬时 GC(我们称为前台 GC)。做的事情与 “WKS GC 与关闭并发 GC” 相同
  7. 后台 GC 再次调用 SuspendEE 来完成标记,然后调用 RestartEE 在用户线程正在运行的时候,开始并发扫描阶段
  8. 后台 GC 完成

SVR GC 与关闭并发 GC

  1. 用户线程达到分配阈值并触发 GC
  2. 服务 GC 线程被唤醒并调用 SuspendEE 挂起托管线程
  3. 服务 GC 线程执行 GC 作业(在工作站 GC 的相同阶段中是没有并发 GC)
  4. 服务 GC 线程调用 RestartEE 恢复托管线程
  5. 用户线程恢复继续往下执行

SVR GC 与开启并发 GC

这种场景与 WKS GC 的并发 GC 是一致的,除非在 SVR GC 线程上没有 GC 后台线程了。

物理架构

这节是想帮助你理解下面的一些代码流程

用户线程耗尽分配量,并通过尝试更多分配空间(try_allocate_more_space)来获取新的分配量。

当需要触发 GC 时 try_allocate_more_space 调用 GarbageCollectGeneration。

给定 WKS GC 在禁用并发 GC 情况下,在触发 GC 的用户线程上都会执行 GarbageCollectGeneration。

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();
}

在给定 WKS GC 开启并发 GC(默认情况),下面的代码就是后台 GC 的工作流

GarbageCollectGeneration()
 {
     SuspendEE();
     garbage_collect();
     RestartEE();
 }

 garbage_collect()
 {
     generation_to_condemn();
     // decide to do a background GC
     // wake up the background GC thread to do the work
     do_background_gc();
 }

 do_background_gc()
 {
     init_background_gc();
     start_c_gc ();

     //wait until restarted by the BGC.
     wait_to_proceed();
 }

 bgc_thread_function()
 {
     while (1)
     {
         // wait on an event
         // wake up
         gc1();
     }
 }

 gc1()
 {
     background_mark_phase();
     background_sweep();
 }

原文地址

https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/garbage-collection.md