English 简体中文 繁體中文 한국 사람 日本語 Deutsch русский بالعربية TÜRKÇE português คนไทย french
查看: 5|回复: 0

.NET Core GC计划阶段(plan_phase)底层原理浅谈

[复制链接]
查看: 5|回复: 0

.NET Core GC计划阶段(plan_phase)底层原理浅谈

[复制链接]
查看: 5|回复: 0

353

主题

0

回帖

1069

积分

金牌会员

积分
1069
wmz

353

主题

0

回帖

1069

积分

金牌会员

积分
1069
2025-2-6 16:12:27 | 显示全部楼层 |阅读模式
简介

在mark_phase阶段之后,所有对象都被标记为有用/垃圾对象。此时,垃圾回收器已经拥有启动垃圾回收的所有前置准备工作。

这个时候,垃圾回收期应该执行"清除回收"还是"压缩回收"呢?只有做一下试验才能得出理论支撑。
模拟压缩

这里会有一个悖论,如果你要知道压缩是否划得来,那你就得先压缩后查看其结果,才知道压缩的成本。
CLR团队如何解决这个问题呢?plan_phase阶段会计算与压缩过程相关的所有信息,而这些信息是以旁敲侧击的方式计算,并没有实际移动对象。这样我们就能从侧面知道压缩的结果
插头(plug)/间隙(gap)

模拟压缩阶段,CLR会将托管堆上的对象分为有空(plug)和没用(gap)两块,也就是所谓的插头和间隙.

通过将托管堆拆分成plug与gap,我们可以轻松计算出其重要信息

  • 每个gap的大小和位置都会被记住,如果最终是清除回收,那么大多数gap都将成为Free的可用空间。
  • 每个plug的位置与偏移量都会被记住,如果最终选择了压缩回收,则会使用重定位偏移量来移动plug
重排plug

计划阶段在重排 Plug 区块时,内部使用了一个单独分配器,所以此时plug是并没有被移动的.分配器仅将对象指针进行操作,进行模拟。


  • 当遇到第一个plug时,分配器会找到根据对象自身的alloc_ptr指针,移动分配器的指针,并记录两个指针之间的偏移量,记为重定位偏移量
  • 遇到下一个plug时,分配器会在上一个分配的基础上,继续分配。直到遇到最后一个plug。
这样,所有的重定位偏移量都被计算出来,因此GC可以准确的知道以下信息

  • 压缩效率是多少?
  • 如果是清除压缩,在哪里创建Free列表?
  • 如果是压缩回收,plug如何移动?
plug数据结构

既然要模拟压缩,那么就意味着有数据结构来承载额外的信息。在 coreclr 源码中有一个叫 gap_reloc_pair 结构体记录Plug的信息。
gap没有专用的数据结构来存,大家可以思考一下。为什么?

  • gap
    记录着plug前面gap大小,也就是可回收的字节长度
  • reloc
    plug 新地址的相对旧地址的偏移量,通常是负数
  • m_pair
    记录plug左右plug的位置,如果GC决定执行压缩回收,它将非常频繁地使用插头信息。因此为了提高效率,会同时维护
    到二叉搜索树中。因此对于一个插头来说,左节点都是比较小的地址,右节点是高地址。


gap_reloc_pair的存储

按照常规方案,gap_reloc_pair的存储会在托管堆上单独开辟一段内存区间来存放,但是CLR团队非常巧妙的把gap_reloc_pair放在gap块中,因为gap不再使用,覆盖它是安全的,非常巧妙的设计!
将plug的信息存储在plug之前,这就是为什么即使是一个空对象也必须是24字节的原因
有人可能会问了,内存段的段首,第一个plug前面没有gap怎么办?
实际上,每一代的对象,都是从一个空对象开始的,因此即使是第一个插头,它也是有gap的。
眼见为实:plug前面的gap中存放着gap_reloc_pair

在bp coreclr!WKS::gc_heap::decide_on_compacting 下断点。

眼见为实:第一个plug,有一个天然的gap


代降级

因为pinned对象的存在,导致对象代的提升不是100%的,有可能会不升反降。在执行压缩的场景下,如果pinned对象出现在了一些特别尴尬的位置,GC会考虑给某些pinned对象降代或者不升代
举个例子,如果不存在降代现在,GC堆会发生什么情况

可以看到,0代段被压缩到很小,导致没分配几次内存又要GC,又会导致STW非常频繁,从而使得程序卡顿,CPU增高,吞吐量降低等现象

这个时候,只有选择不升代,或者降代,才能维持好GC代之间的平衡。
降级是一种优化,确保更多的内存碎片被重用。
眼见为实

点击查看代码    internal class Program    {        static void Main(string[] args)        {            Append();            AppendPinned();            Compact();        }        public static GCHandle gcHandle;        public static List<byte[]> list = new List<byte[]>();        static void Append()        {            //填 10M 数组到 临时段上            for (int i = 0; i < 1024 * 10; i++)            {                list.Add(new byte[1000]);            }            Console.WriteLine("1. 10M 数据已分配完毕,请查看临时段大小,准备分配 pinned 对象!");            Debugger.Break();        }        static void AppendPinned()        {            gcHandle = GCHandle.Alloc(new byte[1024 * 50], GCHandleType.Pinned);  //50k            list = null;            Console.WriteLine("2. pinned (50k) 已分配,list已去根,请再次观察托管堆!准备触发 GC");            Debugger.Break();        }        static void Compact()        {            GC.Collect(2, GCCollectionMode.Forced, true, true);            Console.WriteLine("3. GC 已触发,请观察 pinned 对象 所属的代!");            Debugger.Break();        }    }
未GC前,pinned为0代,正常情况下,它会升为1代。

因为该pinned对象非常尴尬的出现在了一大批gap对象之后,如果升代,会导致前面这一片gap对象空间同样被纳入1代的代边界范围,这极大的缩小了0代的代边界。
因此,ClR选择将Pinned对象不升代
番外篇:无效结构的内存转储

有时候,我们在plan_phase阶段,通过!dumpheap 指令查看托管堆的时候,会发现托管堆看不了。提示如下信息

还记得之前说过的gap_reloc_pair数据结构吗?它被CLR团队非常巧妙的放在了gap中。
问题就在于此,如果你的dump正好在plan_phase执行过程中,因为gap上的原始内容被gap_reloc_pair覆盖,所以此时的托管堆相当于是被破坏状态。因此CLR为了防止你被脏数据误解,直接不让你观察。
眼见为实


在执行plan_phase之前,通过GCScan::GcRuntimeStructuresValid方法来模拟托管堆被破坏,plan_phase执行完后再恢复。
决定压缩的诱因

在模拟压缩阶段,GC根据会计算出压碎率,碎片大小的,并辅助其它条件。来决定是否执行压缩回收。
其它条件可能是,主动触发,也可能是OOM之前的最后一次Full GC ,或者是临时段空间不足
其核心方法为
BOOL gc_heap::decide_on_compacting (int condemned_gen_number,                                    size_t fragmentation,                                    BOOL& should_expand)通过返回Bool,来告诉下一阶段应该执行清除回收还是压缩回收
眼见为实


https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/gc.cpp
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

353

主题

0

回帖

1069

积分

金牌会员

积分
1069

QQ|智能设备 | 粤ICP备2024353841号-1

GMT+8, 2025-3-10 15:10 , Processed in 1.767405 second(s), 30 queries .

Powered by 智能设备

©2025

|网站地图