GC 在 .NETCore 的设计
组件架构
GC 有两个组件,一个是分配器和收集器。分配器主要负责获取更多内存信息以及在适当时候触发收集器。收集器负责回收垃圾,或者是那些在程序中没有用到的内存对象。
收集器还有另一种方式调用,那就是手动调用 GC.Collect
,或者在finalizer
线程接受到一个内存不足的异步通知(会触发调用收集器)
分配器设计
在执行引擎(Execution Engine,EE)中会调用分配器,以下是展示的信息:
- 请求分配的大小
- 线程分配上下文
- 标识,表示是否
GC 不对区别对待不同的对象。它根据 EE 来获取对象的大小。
基于这个大小,GC 会把这个对象分到两个类别:小对象(< 85000 字节)和大对象(> 85000 字节)。在管道中,大小对象都是以相同方式处理的,区别在于由于大对象有压缩操作,会让 GC 操作更显得昂贵。
当 GC 给分配器的内存时,会根据分配上下文分配内存。通过指定分配量来分配上下文的大小。
- 分配上下文 是给定的堆片段中较小的一个区域,每个区域专门给定线程使用。在单核处理器下(只有一个逻辑处理器)的机器,单个上下文被使用,它就在第 0 代分配上下文。
- 分配单元(Allocation Quantum)是分配器每次需要更多内存分配的内存大小,是为了在这个分配上下文中执行对象分配。分配一般为 8k,托管对象的平均大小大约在 35 byte,这使得一个分配量可以用户许多对象分配。
大对象是不会使用分配上下文和分配单元的。一个大对象要比那些较小的对象区域都要大。好处(下面会讨论)是特定于那些小对象的。大对象直接分配到堆片段。
分配器是按照下面几点来完成的:
- 适时触发GC:分配在分配内存超出预算或者是分配器无法再给定的片段分配时会触发 GC。关于分配预算和托管片段细节稍后会讨论。
- 保留对象的位置:对象分配是在相同的堆区域被存储在虚拟地址附近。
- 高效使用缓存:分配器在分配量单元中分配内存,而不是以对象为单位分配。它将大量内存归零以预热CPU缓存,因为这会立即分配对象内存。分配单元通常为 8k
- 高效锁:分配上下文和分配单元相关的线程保证了只有单个线程写入给定的分配单元。只要当前的分配上下文没有用完,就不需要在对象分配的时候上锁。
- 内存完整性:GC 总是将新的分配对象内存归零以阻止对象的引用指向随机内存。
- 保留堆的可抓取性:分配器在每个分配量中剩下的内存中要确保标记一个空闲的对象。例如,在一个分配单元中还剩余 30 byte 的空间,但是下一个对象大小是 40 byte,那么分配器就会将这 30 byte 变成空闲对象并获取一个新的分配单元。
分配 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 致力于有效的管理内存,以及让释放编写“托管代码”的人,不用关心垃圾回收。这里高效的意思如下:
- GC 应该频繁触发,以避免托管堆包含大量没有使用但已分配的对象(垃圾),从而提高内存利用率。
- GC 应该尽可能的少发生,以避免占用正在使用的 CPU 时间,即使频繁的 GC,也会导致更新的内存使用。
- GC 应该要富有成效。如果 GC 只回收一小部分内存,那么 GC(包含相关的 CPU 周期)就会被浪费。
- 每个 GC 应该都要快。许多工作复杂都要求低延迟。
- 托管代码开发者不需要了解关于 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 可能会更加积极的瞬时回收(意味着更多的 gen1)来避免获得一个新的堆段。
GC 流程
标记阶段
标记阶段的目的就是发现所有活动的对象。
分代收集器的好处就是收集部分堆的能力,而不是一直在查看所有的对象。当收集瞬时代时,GC 需要指出在这些代中哪些代是活动对象,这是通过 EE 报告出来的。在此方面,通过引用标记。在年老代中的对象也能保持对象在年轻代中活动。(收集器会通过根直接引用的对象,然后递归标记通过指针数组就能访问这些对象)
GC 使用卡片标记年老代。卡片是由 JIT 在分配操作期间设置的。如果 JIT 看到一个对象属于瞬时范围,他将设置一个字节,它标识这个卡片标识的源位置。在瞬时收集期间,GC 能在查看到剩余的堆的一组卡片以及这些卡片对应的对象。
计划阶段
在计划阶段会模拟压缩来确定有效结果。如果压缩成果明显,那么 GC 开始一个实际的压缩,否则 GC 就开始清扫(sweet)。
重定位阶段
如果 GC 决定压缩,那么作为结果肯定会移动对象,然后这些被标记引用的都会被更新。重定位阶段需要发现那些在代中等待回收的对象的所有引用。与之对比,标记阶段只关注活动对象,所以它不需要考虑弱引用。
压缩阶段
因为在计划阶段就已经计算好了这些对象要移动的新的地址,所以这个阶段非常简单。只用复制这些对象即可。
清除阶段
清楚阶段在活动对象之间查找死空间。它会生成空闲对象来替换这些空间。相邻的死对象会成为一个空闲对象。它会将所有的空闲对象放置到空闲链表中。
分代垃圾回收机制其实是利用了分代的概念,针对不同的代使用不同的 GC 算法。对于新生的对象(瞬时代)采用的算法一般都是引用计数法,都只会查找活动对象(GC 标记清除算法和 GC 复制算法),这样的话,越多的对象死去,花费在 GC 的时间就越少。
代码流程
由下面两个构成:
- WKS GC:工作站 GC
- SVR GC:服务器 GC
行为功能
WKS GC 与关闭并发 GC
- 用户线程运行时耗尽了分配预算并触发一个 GC
- GC 调用 SuspendEE 挂起托管线程
- GC 决定代
- 运行标记阶段
- 运行计划阶段以及决定 GC 是否执行压缩
- 重定位和压缩阶段运行,否则运行扫描阶段
- GC 调用 RestartEE 恢复托管线程
- 用户线程恢复运行
WKS GC 与开启并发 GC
下面分析的是后端 GC 做的逻辑
- 用户线程运行耗尽分配预算并触发 GC
- GC 调用 SuspendEE 挂起托管线程
- GC 决定是否由后台运行 GC
- 如果一个后台 GC 线程被唤醒。后台 GC 线程就会调用 RestartEE 来恢复托管线程
- 托管线程在后台 GC 作恶的时候继续分配
- 用户线程可能还是会耗尽分配预算并且触发一个瞬时 GC(我们称为前台 GC)。做的事情与 “WKS GC 与关闭并发 GC” 相同
- 后台 GC 再次调用 SuspendEE 来完成标记,然后调用 RestartEE 在用户线程正在运行的时候,开始并发扫描阶段
- 后台 GC 完成
SVR GC 与关闭并发 GC
- 用户线程达到分配阈值并触发 GC
- 服务 GC 线程被唤醒并调用 SuspendEE 挂起托管线程
- 服务 GC 线程执行 GC 作业(在工作站 GC 的相同阶段中是没有并发 GC)
- 服务 GC 线程调用 RestartEE 恢复托管线程
- 用户线程恢复继续往下执行
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