suzai 发表于 2025-2-6 23:48:34

.NET Core GC标记阶段(mark_phase)底层原理浅谈

简介

C# 采用基于代的回收机制,并使用了更复杂的 链式跟踪算法 来识别对象是否为垃圾。
GC触发的原因

截至到.NET 8,GC触发的原因有18种
enum gc_reason{    reason_alloc_soh = 0,//小对象堆,快速分配预算不足    reason_induced = 1,//主动触发GC,没有关于压缩和阻塞的选项    reason_lowmemory = 2,//操作系统发出内存不足通知信号    reason_empty = 3,//操作系统内存耗尽    reason_alloc_loh = 4,//大对象堆,快速分配预算不足    reason_oos_soh = 5,//小对象堆段,慢速分配预算不足    reason_oos_loh = 6,//大对象堆段,慢速分配预算不足    reason_induced_noforce = 7, // it's an induced GC and doesn't have to be blocking.    reason_gcstress = 8,      // this turns into reason_induced & gc_mechanisms.stress_induced = true    reason_lowmemory_blocking = 9,    reason_induced_compacting = 10,    reason_lowmemory_host = 11,//主机发出内存不足通知信号    reason_pm_full_gc = 12, // provisional mode requested to trigger full GC    reason_lowmemory_host_blocking = 13,    reason_bgc_tuning_soh = 14,    reason_bgc_tuning_loh = 15,    reason_bgc_stepping = 16,    reason_induced_aggressive = 17,    reason_max};根据命名总体分为5类

[*]ptr_limit预算用完
reason_alloc_soh,reason_alloc_loh 代空间不够
[*]段空间预算用完
reason_oos_soh,reason_oos_loh oos是OutOfSegment的缩写,
[*]代码主动触发
reason_induced_xxxx,比如GC.Collect
[*]系统内存不足
reason_lowmemory,reason_lowmemory_blocking,reason_empty,reason_lowmemory_host
[*]其它
reason_bgc_tuning_soh,reason_bgc_tuning_loh
https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/gc.h
眼见为实:触发GC的详情

使用x coreclr!*gc_heap::settings*

眼见为实:触发GC的次数

x coreclr!*full_gc_counts*

enum gc_type{    gc_type_compacting = 0,    gc_type_blocking = 1,#ifdef BACKGROUND_GC    gc_type_background = 2,#endif //BACKGROUND_GC    gc_type_max = 3};coreCLR内部会记录各种类型GC触发的次数,以供参考。
GC回收流程图(节选)

以非并发工作站GC为蓝本,投石问路,先介绍最简单的一种,后面文章再展开。说人话就是挖坑待埋。

https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/garbage-collection.md
对象的遍历与标记

在mark_phase阶段,CLR会从根对象出发,寻找所有可到达对象,也就是依旧存在引用关系的对象。主要历经以下几个步骤

[*]将根对象转换为托管对象的地址
如果根对象是非托管对象,需要在托管堆中给它占一个坑
[*]设置固定标记
如果对象被固定,在ObjectHeader中,设置一个flg。
[*]开始遍历对象的引用
通过MethodTable的类型信息,得出内部引用的偏移量。从而以深度优先的方式扫描。并将已经扫描的对象维护到一个集合中,这被称为"标记堆栈"
当标记堆栈中,不存在未访问的对象时。即代表遍历操作完成
注意:pinned与marked标记都是在mrak_phase阶段被设置,plan_phase阶段被清除。在正常情况下,是不会存在objectheader与methodtable中的。
根对象

标记阶段最重要的任务,就是标记引用根(root),对于引用跟踪算法而言,这是万物的起点。
C#中,root分为四种,组成了标记阶段要扫描的所有root
线程栈(thread stack)

查找stack上的根对象,如果实时扫描stack与寄存器,在面对高频次的小对象GC中会显得力不从心,因此,CLR团队采用了更高性能的查询策略。

[*]空间换时间,对引用类型缓存一个额外的gcinfo来提高查询性能
[*]激进式根回收,对作用域进行判断,过滤掉出不会被线程栈引用的对象
眼见为实:gcinfo

点击查看代码      static void Main(string[] args)      {            var name = "lewis.liu";            var age = 155;            var person=new Person();            Debugger.Break();      }

眼见为实:激进式根回收

点击查看代码      static void Main(string[] args)      {            Timer timer = new Timer((obj) =>            {                Console.WriteLine($"我是 Timer 线程,当前时间:{DateTime.Now}");            }, null, 0, 100);             var person = new Person();            GC.Collect();            Console.WriteLine("GC已触发。。。");            Console.ReadLine();      }使用.NET Core 2.1 分别以Debug与Release来运行。会发现惊喜!
1. Debug

2. Release

简单来说,不同版本的JIT对作用域有不同的判断策略,.NET Core 2.1中,会认为在触发GC.Collect()之前,timer与person都不再被stack所引用,因此会被GC所释放掉。这个过程叫做激进式根回收
.NET Core 3 之后已经修复此问题。
终结器队列(Finalize queue)

带有析构函数的对象,会单独在终结器队列中持有一份对这个对象的引用
眼见为实

点击查看代码    internal class Program    {      static void Main(string[] args)      {            Run();            Debugger.Break();            GC.Collect();            Console.WriteLine("11");            Debugger.Break();      }      static void Run()      {            var person = new Person();      }    }    class Person    {      public int Id { get; set; }      ~Person()      {            Console.WriteLine("析构已执行");      }    }
GC句柄表(GCHandles)

通过GCHandle.Alloc或者fix产生的固定,以及CLR内部固定的对象
它们全部存储在CLR内部的全局句柄映射表中 ,可以使用!gchandles命令查看
GCHandleType总共有五种,GC只会对Normal,Pinned视为root,

[*]Weak
弱引用,弱引用允许应用程序访问对象,同时不会阻止垃圾回收器回收该对象
[*]WeakTrackResurrection
类似弱引用,与普通弱引用不同的是,当对象被垃圾回收时,它仍然可以跟踪对象的复活(Resurrection)情况
[*]Normal
强引用,只要这个GCHandle存在,垃圾回收器就不会回收它所引用的托管对象
[*]Pinned
强引用的更强版,当一个对象被这种方式引用时,它会在内存中被 “固定”,防止垃圾回收器移动它的位置。这在与非托管代码交互时非常重要
[*]AsyncPinned(未开放)
固定句柄还有一个重要的变种,叫异步固定句柄,作用与类似pinned,有一个额外功能:一旦异步操作完成,它即在内部取消pinned状态。使得pinned的时间尽可能的短。目前尽在CLR内部开放。
还有一个fixed关键字,也可以固定对象。两者结果相同,但是root不同,使用GCHandle时是句柄表,使用fixed关键词是标记堆栈
眼见为实

点击查看代码    internal class Program    {      static void Main(string[] args)      {            Run();            Debugger.Break();      }      static void Run()      {            var person = new Person();            GCHandle.Alloc(person, GCHandleType.Normal);      }    }    class Person    {    }

跨代引用

为了解决跨代引用问题产生的卡表,卡包

图中举例了一个常见的情况

[*]年前对象引用老对象
A对象创建的时候,字段引用了对象E 。这很合理吧?
[*]老对象引用年前对象
在代码运行过程中,对象D的某个字段引用了对象A 。这也很合理吧?
那么问题来了,CLR将SOH分为0代,1代,2代。目的就是想根据生命周期的不同。对不同的代设置不同的GC频率。
现在好了,我回收0代内存,还因为被1代和2代引用。导致GC标记的时候,要遍历整个对象图
那内存分代图个什么?

remembered sets

为了处理跨代引用的问题,CLR团队引入了remembered sets(记忆集)。简单来说,就是记录对象被哪些对象所引用。拿空间换时间
太阳底下没有新鲜事,JVM也是使用记忆集解决跨代引用的问题

举个例子,回收1代内存。在某个时机(对象创建,指针移动),冗余了引用关系。那么只需要扫描对象的GC Root与remembered sets 便可轻松得知对象是否能被回收
思考一个问题,这里还有优化空间吗?
remembered sets存储的优化

上面可以看到,当回收1代内存时,要同时维护与0代和2代的关系。实现记忆集并不是一件容易的事
思考一下单链表与双链表的维护成本
那么如何减少维护成本呢?CLR团队做了一个决定:”回收N代意味着回收该代以及N-1代的对象“
简单来说就是:

[*]仅回收0代
[*]仅回收0代,1代
[*]完全回收,回收0,1,2以及LOH
这样的话,记忆集只需要维护比对象代更老的关系,大大减轻了维护成本
remembered sets查询的优化

将每个对象的引用存储在记忆集中,记忆集虽然很小(只包含了更老对象的引用),但在大型系统中,几万甚至十几万个对象都是正常的现象,那么意味着也会有同等数量的记忆集。维护数量如此庞大的记忆集,将会为GC管理带来极大的开销
因此,CLR团队必须做出一些妥协,在性能与准确性之间进行权衡

[*]卡表(Card Tables)
卡表的原理很简单,将老一代的内存地址。在逻辑上拆分为固定大小的区域。某个区域只要有任意对象存在跨代引用。就将对象所在的区域标记为dirty.

这样虽然会导致即使只有一个记忆集,也必须访问card中的所有对象。但也极大提高了GC的性能
有点类似涂抹算法
源码地址:https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/amd64/JitHelpers_FastWriteBarriers.asm
在.NET Runtime中,一个bit位(clean/dirty代表0/1)对应256byte(64位)与128byte(32位) .
以64位系统为例,1byte可以表示8256=2048byte的存储区域。
一个卡表字的大小为4字节(DWORD).它所覆盖的大小为42048=8192byte

[*]卡包(Card Bundles)
任意一个对象包含跨代引用,都会涂抹2048byte的空间。这就足够了吗?
假如一个Web应用程序内存使用量为1gb,那么对应多少卡表呢?102410241024/2024=524288. 512kb。
乍一看还算好,1mb都没有。但不要忘了,每一个对象都需要扫描一遍Card Table,以确定是否存在跨代引用。
想象一下,一个占用内存32gb的,超过5万个对象的服务器。它们的扫描量有多大。这会严重拖慢GC时间
因此,基于Card Tables的思路,CLR团队又引入了Card Bundles的概念。
以64位系统为例,每个bit位代表32个卡表字。因此一个bit覆盖范围为328192=262144,256kb
一个卡包位占用4字节空间,因此一个卡包可以覆盖256kb8*4=8192kb ,8mb空间。
这时候再算一下1gb内存使用量的web服务器,1024/8=128 .扫描量是不是就大大减少了。
其核心思路类似涂抹算法+二级缓存
眼见为实

点击查看代码      static void Main(string[] args)      {            var address = CreatedAddress();            GC.Collect();//gc后 address 升为1代            Debugger.Break();            var person = CreatedPerson();            person.Address = address;//0代的person引用1代的address            Debugger.Break();      }      static Address CreatedAddress()      {            var address = new Address()            {                City = "xxx",                County = "yyy"            };            return address;      }      static Person CreatedPerson()      {            var person = new Person()            {                Address = null,            };            return person;      }
页: [1]
查看完整版本: .NET Core GC标记阶段(mark_phase)底层原理浅谈