.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类

  1. ptr_limit预算用完
    reason_alloc_soh,reason_alloc_loh 代空间不够
  2. 段空间预算用完
    reason_oos_soh,reason_oos_loh oos是OutOfSegment的缩写,
  3. 代码主动触发
    reason_induced_xxxx,比如GC.Collect
  4. 系统内存不足
    reason_lowmemory,reason_lowmemory_blocking,reason_empty,reason_lowmemory_host
  5. 其它
    reason_bgc_tuning_soh,reason_bgc_tuning_loh

https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/gc.h

GC回收流程图(节选)

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

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

对象的遍历与标记

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

  1. 将根对象转换为托管对象的地址
    如果根对象是非托管对象,需要在托管堆中给它占一个坑
  2. 设置固定标记
    如果对象被固定,在ObjectHeader中,设置一个flg。
  3. 开始遍历对象的引用
    通过MethodTable的类型信息,得出内部引用的偏移量。从而以深度优先的方式扫描。并将已经扫描的对象维护到一个集合中,这被称为"标记堆栈"

当标记堆栈中,不存在未访问的对象时。即代表遍历操作完成

注意:pinned与marked标记都是在mrak_phase阶段被设置,plan_phase阶段被清除。在正常情况下,是不会存在objectheader与methodtable中的。

根对象

标记阶段最重要的任务,就是标记引用根(root),对于引用跟踪算法而言,这是万物的起点。

C#中,root分为四种,组成了标记阶段要扫描的所有root

线程栈(thread stack)

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

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

眼见为实: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,

  1. Weak
    弱引用,弱引用允许应用程序访问对象,同时不会阻止垃圾回收器回收该对象
  2. WeakTrackResurrection
    类似弱引用,与普通弱引用不同的是,当对象被垃圾回收时,它仍然可以跟踪对象的复活(Resurrection)情况
  3. Normal
    强引用,只要这个GCHandle存在,垃圾回收器就不会回收它所引用的托管对象
  4. Pinned
    强引用的更强版,当一个对象被这种方式引用时,它会在内存中被 "固定",防止垃圾回收器移动它的位置。这在与非托管代码交互时非常重要
  5. 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
    {
    }


跨代引用

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

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

  1. 年前对象引用老对象
    A对象创建的时候,字段引用了对象E 。这很合理吧?
  2. 老对象引用年前对象
    在代码运行过程中,对象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代的对象"

简单来说就是:

  1. 仅回收0代
  2. 仅回收0代,1代
  3. 完全回收,回收0,1,2以及LOH

这样的话,记忆集只需要维护比对象代更老的关系,大大减轻了维护成本

remembered sets查询的优化

将每个对象的引用存储在记忆集中,记忆集虽然很小(只包含了更老对象的引用),但在大型系统中,几万甚至十几万个对象都是正常的现象,那么意味着也会有同等数量的记忆集。维护数量如此庞大的记忆集,将会为GC管理带来极大的开销

因此,CLR团队必须做出一些妥协,在性能与准确性之间进行权衡

  1. 卡表(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).它所覆盖的大小为4
2048=8192byte

  1. 卡包(Card Bundles)
    任意一个对象包含跨代引用,都会涂抹2048byte的空间。这就足够了吗?
    假如一个Web应用程序内存使用量为1gb,那么对应多少卡表呢?10241024 1024/2024=524288. 512kb。
    乍一看还算好,1mb都没有。但不要忘了,每一个对象都需要扫描一遍Card Table,以确定是否存在跨代引用。

想象一下,一个占用内存32gb的,超过5万个对象的服务器。它们的扫描量有多大。这会严重拖慢GC时间

因此,基于Card Tables的思路,CLR团队又引入了Card Bundles的概念。

以64位系统为例,每个bit位代表32个卡表字。因此一个bit覆盖范围为328192=262144,256kb
一个卡包位占用4字节空间,因此一个卡包可以覆盖256kb
8*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;
        }