关于GC的那点事
关于GC的那点事
前言
GC(Garbage Collection(垃圾回收))也是在开发中老生常谈的问题。刚好我在B站上刷到了一些视频,我认为讲的很清楚,就记录一下。同时这也是面试中经常会被问到的问题。为了我其他的文章,这GC的文章也是不得不写了。
我先说明一点。GC在Unity中的实现和CSharp自身的GC还是不太一样的。但是我们仍然可以通过CSharp的GC来了解Unity中GC的运行方式。虽然我参考的视频中其讲述的.Net8的GC,但是其原理大差不差。如果你要更加了解对应的实现,我推荐你去看一下对应的源码。本文也没有对具体的实现进行讲解,只是讲明大概的原理。
我个人其实并没有去严格验证视频中关于GC的真实性,并且在对应的GC项目中我大量使用了AI来进行总结,并没有去阅读GC的源码。所以本篇文章中可能存在一些错误。如果有错误,欢迎指正。
正文
Garbage Collection(垃圾回收)简称GC。它主要的职责就是判断那些对象是无用的,然后回收它们。 既然要判断那些对象是无用的,那么我们就需要有一个具体的判断标准。
引用计数
引用计数(Reference Counting)是一种经典的内存管理/垃圾回收(GC)方法,核心思想是每个对象维护一个计数器,记录有多少引用指向它。
基本原理
- 初始化:对象创建时,引用计数设为 1。
- 引用增加:每当有新的引用指向该对象(如赋值 b = a),计数器 +1。
- 引用减少:当某个引用被销毁、改写或离开作用域时,计数器 -1。
- 回收:当计数器归零时,说明没有任何引用指向它,对象可被立即回收。
优点
- 实时性:对象一旦无人引用,立即回收,内存释放及时,无停顿。
- 实现简单:无需复杂算法,只需在赋值/销毁处维护计数。
- 局部性好:回收动作分散,不会产生明显的"卡顿"。
缺点
循环引用问题:两个对象互相引用,计数器永远不为 0,导致内存泄漏。这是引用计数最致命的缺陷。
举个例子:在一个运行中,有两个对象A和B,A引用B,B引用A。那么即使之后这两个对象不会再被用到了,但是因为他们互相引用,因此他们就永远不会被回收。
性能开销:每次指针赋值/修改都要更新计数。单次成本不高,但是累积起来就很高了。而且方法需要记录对应的引用次数。当变量数量过大时,对内存也有一定的压力。
原子性问题:多线程环境下计数器增减需要原子操作或加锁,进一步增加成本。
结论
正是因为引用计数的缺点,CSharp并没有将纯粹引用计数作为GC的核心算法,而是使用了追踪式垃圾回收——标记-清除算法。
标记-清除算法
标记-清除(Mark-Sweep)算法是一种基于可达性分析的垃圾回收算法,核心思想是:判断对象是否"可达",而不是靠计数。
基本流程
标记-清除可以分为两个阶段:标记阶段和清除阶段。
标记阶段
在说明标记阶段之前,我们需要先了解一下一个概念“根对象”。根对象也被称为是 GC Roots,是整个垃圾回收过程的起始点和判断对象是否存活的依据。
GC Roots 通常包括:
- 栈(虚拟机栈)中的局部变量、参数
- 静态变量(全局变量)
- 常量池中的引用
- 活跃线程对象
而标记阶段就是从一组根对象(GC Roots)出发,遍历所有能被引用到达的对象,把它们标记为"存活"。
清除阶段
在清除阶段,程序会线性扫描整个堆,回收所有未被标记的对象(即不可达对象),并释放其内存。
优点
- 解决循环引用:只要无法从根到达,无论对象之间如何互相引用,都会被回收。这是相比引用计数的根本优势。
- 无需编译期/运行期额外计数器维护,逻辑清晰。
缺点
- STW(Stop The World):标记和清除过程通常需要暂停程序(挂起所有用户线程),产生停顿。
- 内存碎片:清除后内存不连续,产生大量碎片,可能导致大对象无法分配。
- 效率不稳定:标记和清除都要遍历,且需扫描整个堆,暂停时间随堆大小增长。
空闲列表(Free-List)
在标记-清除算法中,空闲列表(Free List) 是一个用于管理堆内存中所有空闲块的数据结构。它的核心作用是记录并组织内存中所有未被占用的连续空闲区域,以便在分配新对象时快速查找和划分。
空闲列表通常采用链表形式实现,每个节点对应一个空闲内存块。
在清除阶段时,将释放的内存块插入空闲列表,并执行合并(Coalescing)——检查该块的上下相邻内存是否也是空闲的,如果是,则合并成一个更大的节点,防止外部碎片。
在分配新对象时,根据分配策略(如首次适应、最佳适应)遍历列表,找到合适大小的节点。若节点大于需求,则将其拆分,剩余部分继续留在列表中。
空闲列表是标记-清除算法中常见的优化手段。由于标记-清除算法不移动对象(不压缩),内存会变得支离破碎。空闲列表能够将分散在各处的碎片化空闲块串联起来,确保即使没有大块连续空间,也能利用零散空间分配小对象。
但是它仍然有着缺陷,由于只合并相邻空闲块,无法解决外部碎片问题(总空闲内存足够,但单个连续块无法满足大对象分配)。这时候只有标记-压缩算法可以解决。而且分配时遍历空闲列表可能耗时较长。
标记-压缩算法
标记-压缩算法(Mark-Compact,也叫标记-整理)解决了内存碎片问题。其分为标记阶段、压缩/整理阶段和更新引用阶段。标记阶段和标记-清除算法一样,这里不在赘述。
压缩阶段
把所有存活对象向内存的一端移动,让它们紧密排列,消除碎片。常见整理方式有:
- 顺序整理:存活对象按地址顺序向前移动。
- 双向整理 / 滑动整理:一端向前移,腾出连续空闲区。
- 表格法(Table):用表格记录对象地址映射,避免对象间互相覆盖。
更新引用
压缩阶段进行移动后,对象的内存地址都进行了改变,因此需要更新所有指向它们的引用,保证指针指向新位置。
优点
- 无内存碎片:存活对象连续排列,空闲空间集中,利于大对象分配。
- 无需额外空间:相比"标记-复制"不需要一半空闲内存,内存利用率高。
- 适用老年代:老年代对象存活率高,移动成本相对可接受。
缺点
- 性能开销大:移动对象 + 更新引用很耗时,尤其是对象多、存活率高时。
- STW 时间长:移动和更新引用通常需要暂停用户线程(在并发 GC 之前)。
- 实现复杂:需要处理对象移动时的引用更新、跨引用处理等细节。
分代GC(Generational GC)
在一些应用中较长的STW并非是不可接受的缺点,但是在游戏中,大多数场景下较长STW是不能忍受的。程序员们针对这些问题也提出了一些解决方案。其中分代GC(Generational GC)就是最常用的解决方案之一。同时CSharp中也使用了分代GC。
在一些工程实践中,我们可以发现绝大多数对象的生命周期都非常短暂,只有少部分对象存活时间比较长。这个也被称为弱分代假设(Week Generational Hypothesis)。分代GC就是基于这个假设而提出的。所以实际上我们并不需要去扫描整个堆,我们只需要对生命周期较短的对象进行高频率的GC,而对于长生命周期的对象进行低频率的GC。
整体思想是当我们new一个对象时,我们会将这个对象的内存放到Gen0代的区域中。当触发回收条件的时候,我们优先对Gen0代的进行对应的GC算法。这时候或许有部分对象被留了下来,这些对象就要放到Gen1代的区域中。而Gen1代进行对应的GC算法后留下来的对象则放到Gen2代的区域中。一般Gen2代就是最高的一代。每一代都有其对应的触发条件,一般是本代存储空间不足时才触发。在这样的机制下,我们无需扫描整个堆,从而加快了算法的执行速度。
当然这个算法也是存在缺点的。
- 实现复杂:需要有卡表、晋升策略等额外机制。
- 仍有停顿:如果所有代都要进行一个操作(Full GC)代价依然高昂(STW 时间长)
- 如果场景并不符合假设,则收益会下降
.Net中的GC实现(小对象堆)
在最新的.Net实现中,我们不能简单说.Net中的GC就是分代GC。首先最新版本的.Net(之后就只会称.Net)中的内存堆有三种类型:小对象堆(SOH)、大对象堆(LOH)和固定对象堆(POH)。且其内存组织也分为两种:传统Segment模式与新的Region模式。其中.Net8+默认是Region模式。Region化分代是分代GC的一种改进。如果要一一讨论,那文章的篇幅也太长了(主要我找的参考资料也没有对应的描述)。本文聚焦于小对象堆中的GC实现,而小对象对中的GC就是分代GC。一共有三代,Gen0、Gen1和Gen2。
关于Gen0所需要知道事情
Gen0区域在.Net中是Bump分配。 Bump 分配(Bump Allocation,也叫指针碰撞 / 撞指针分配)是 GC 托管堆中最常用的快速对象分配方式。核心思想是:内存空闲区是连续的,分配时只需移动一个指针即可完成。你可以把它看做是“栈”,新来的对象只能分配到“栈顶”,也就是已分配内存的末尾。用这样的方式我们只需要维护一个"已分配内存末尾"的指针。每一步分配的动作只有两个:把新对象写到当前指针位置;指针向后移动对象大小的字节数("bump",即撞一下指针)。它的耗时是 O(1),几乎没有任何查找逻辑,是所有分配方式中最快的一种。
具体GC的流程
上面我们说了标记-清除算法和标记-压缩算法。在.Net中这两个算法都是存在的。一旦开始进入GC的执行,程序要么走标记-清除算法,要么走标记-压缩算法。所以要执行哪个算法,程序需要在执行GC前进行判断,而这个判断的阶段就是计划阶段。根据不同的情况,.Net会去选择对应的算法。这些选择的判断十分复杂的,碍于篇幅,我就简单说明一下。
大部分情况下Gen0和Gen1是不进行压缩的。只有下面几种情况下才会进行压缩:
- Gen0的剩余空间容不下新 Gen0 的期望分配量(在判断前计算出来的一个值),如果压缩之后还是不够就会进行一次扩容。要注意这些判断都是在计划阶段进行的。
- Gen0的空间减少时,此时GC会进行压缩。
- 内存负载爆表。当内存负载过高的时候就进行压缩,与第二点不同的是这里的情况更紧急。
- PM 机制内 Gen1 收集必须压缩,为后续全量 GC 做准备
- 碎片率超限
关于第二点的一些补充说明,下面解释由AI给出,因此我这里做折叠
一般的资料中对于Gen0的空间都是只增不减或者是固定。这是一种方便大家理解而进行简化。实际上Gen0的空间是动态变化的。
是否增加是看存活率的大小。存活率 = 本次GC活下来的对象大小 / 本次GC开始时的对象大小。当这个值高的时候,Gen0就会进行扩容。如果这个值低的话,Gen0就会进行减少容量。
AI给的理由是:每次 GC 回收的东西很少,说明这代对象生命周期长。GC 做了也白做,不如把预算调大、让下次 GC 晚点来——用更大的堆换取更少的停顿次数(吞吐量优先)。存活率低表明每次 GC 都"大赚"。这时预算调小、内存占用更低(内存占用优先)。不过这并不是第二点中触发的条件。
除了上面说明的缩减条件外,还有一个原因是碎片化。标记-清除算法会产生一些碎片。比如现在有三个对象,A、B、C。这三个对象是按顺序连续的,此时B被清除。则A和C之间就会产生一个空闲区。这个空闲区就是我们所说的碎片。在.Net的标记-清除算法中有一个free-list,它会记录对应的空闲的碎片。当碎片量过多的时候,就会进行减少容量的操作。
最后一个原因是内存压力裁剪。系统内存压力高时把 Gen0 总预算裁到剩余内存配额内。后面两个才是触发压缩的条件。
PM(Provisional Mode,临时预备模式)是什么,下面解释由AI给出
简单来说:在「高内存负载 + Gen2 高碎片」同时出现的僵局下,GC 进入一种预备模式,用「gen1 必压缩 + 紧跟一次全量压缩 GC」的组合拳,把碎片和内存压力一次性化解。
全量压缩 GC 要重定位 Gen2 的所有存活对象并遍历年轻代引用。若年轻代先被 Gen1 压缩理顺,则提升到 Gen2 的对象连续紧凑,全量压缩时"好搬、碎片少",且避免"边提升边制造新碎片",让全量 GC 的重定位更高效。即先用小代价的 Gen1 压缩把年轻代理顺,紧接一击全量压缩 GC 收尾,把"高内存 + 高碎片"僵局一次清掉。
关于第五点的一些补充说明,下面解释由AI给出
在第二点补充说明中,本文简单介绍了什么是“碎片”。但是第五点的碎片和第二点的碎片是不同的。第二点中所谓的碎片指的是free-list的碎片,它仅仅针对Gen0。而第五点中的碎片指的是本代的碎片,不仅仅包括了Gen0。
无论是触发标记-清除算法还是标记-压缩算法,都是对存储不够后的处理。而具体GC的流程还是一样。
首先是标记阶段,这个和之前所说一下从根对象开始进行标记。然后是计划阶段,它根据标记阶段的结果来决策接下来的阶段是清除还是压缩。接下来就是清除阶段或是压缩重定位阶段。这里你可能发现了一个问题,分代GC的概念在哪里。实际上,简单来说除了标记阶段外,其他的阶段都以代为单位进行的。下面我们就来详细的说明一下。分代GC的情况。
一开始,程序会预先给Gen0、Gen1和Gen2三代分配起点。我参考的视频中说Gen0、Gen1和Gen2一开始是一起的。但是实际看代码我感觉不是这样。在Segment模式下,Gen0、Gen1和Gen2是紧挨着的,只是Gen1和Gen2的容量在一开始的时候并没有分配。在Region模式下,Gen1和Gen2的起始位置只有占位符。当然这也不影响理解。实际情况更像是下面这样:
1 | SOH: |
上面的展示是Segment模式下的情况。Region模式下,Gen1和Gen2的起始位置是占位符。而Segment模式更加好理解,大部分的资料也是用这个模式来进行说明的。需要说明的是上面样子看似Gen1和Gen2的有容量,但是实际上它们的容量是0。Gen1和Gen2的起始指针有额外的占用,因此才分配了一定存储。而在逻辑上,他们就是没有容量。此时整个段都是Gen0的,直到触发第一次GC。
GC触发条件
“一般除非我们手动调用GC,否则GC触发条件是此时bump分配位置与段尾之间的容量不足以放下新对象的时候”。大多数的资料都是这么说明的,当然这也很好理解,但是隐式的触发条件还是有很多的,源码中将其分为如下的情况
1 | "alloc_soh" // 小对象堆分配触发 |
Gen0第一次触发GC,Gen0存活的对象会放置到Gen1中。Gen1第一次GC会将Gen1存活的对象放置到Gen2中。Gen2第一次GC就只会压缩。因为JavaGC的影响性,很多人会认为:所谓存活对象放到老一代就是将对象移动,或者说复制到老对象的区域。这么理解好像也没什么问题,但是实际上Csharp的操作并非如此。如下,是一个Gen0进行清理后还未进行代际晋升的示意:
1 | 低地址 |
进行晋升后如下:
1 | 低地址 |
实际上代际晋升的操作只是移动了指针,通过修改Gen0起始位置和Gen1的结尾位置来实现Gen0到Gen1的晋升。Gen2到Gen1的晋升也是同理。这时候或许你又有些疑惑,假设A、B和C在Gen0。B在清理阶段被回收了,而Gen1的结尾位置还是在C的末尾。这样难道不会照成存储浪费的问题吗?如果按照上面的描述去理解,这个问题确实存在。但是除了标记-清理算法外,我们还有标记-压缩算法。上文也说了当碎片率到达一定程度的时候就会触发标记-压缩算法,这时候当前要晋升代的内部碎片就不在了。当然你要是硬说这不就是将对象移动到老一代,那也可以,总之知道这个过程就好了。具体实现可以去查看一下关于GC的源码实现。
我们上述用的表示是Segment模式下的表达,但是你也可以套用在Region模式下。当然具体的实现和理论上还是有一些区别的。简单理解这两个模式倒是没有什么区别。现在最新的.Net是以Region模式为默认模式,或许Region模式有着更好的特性吧。当然这就是另外一个话题了。
一些我没有验证的部分,完全由AI给出
其实代不仅仅有晋升,也有降级。在计划阶段特定条件下会把部分对象降级回更年轻的代。
Unity中的GC实现
最近这几年,Unity官方一直有打算将自己的GC改为.Net的GC实现。但是现在并没有完成,而且这也只能在Unity6+版本中实现了。因为总所周知的原因,国内现在的Unity版本都是5.x的版本。所以我们只关系其旧版本的实现。幸好对应的GC工程仍然存在,因此我们也有机会去了解一下Unity中的GC实现。关于这个项目的由来,我是从《更高效地利用内存空间!Unity正逐步移植到CoreCLR GC》获取到的。
Boehm GC
Unity 目前用的是 Boehm GC,一种不会挪动对象的保守型
GC。它会扫描所有线程堆栈(包括托管与原生代码,意味着它不是我们前面说的分代GC),寻找要分配的托管对象,一旦分配了托管对象,该对象的位置将永远不会在内存中移动。你可以在GC工程中的gcdescr.md文档中找到其原理的概述。这里我简单总结了一下在gcdescr.md中的信息。
PS:我个人认为这部分最好是直接去看原项目中的文档。因为我直接用AI进行翻译,所以我的总结并不算好可能会对你存在误导。我认为对于这Boehm GC 重要的一点就是Boehm GC只使用了标记-清除算法,且它不分代。关于这点是由Unity官方文档给出的。在写这篇文章的时候,我发现了一个关于Boehm GC不错的文章——Unity IL2Cpp的GC原理,大家可以去这里看看。
Boehm GC只使用了标记-清除算法。这也就是为什么他会声称自己是不会挪动对象的保守型 GC。当然它的标记-清除算法也是进行改进过的,它大致分四个阶段运行。
- 准备: 每个对象都有一个关联的标记位。清除所有标记位,表明所有对象都可能不可达。
- 标记阶段: 标记所有可以通过从变量出发的指针链可达的对象。通常收集器对堆中指针变量的位置没有真实信息,因此它将所有静态数据区、栈和寄存器视为可能包含指针。任何表示收集器管理的堆对象内部地址的位模式都被视为指针。除非客户端程序向收集器提供了堆对象布局信息,否则任何从变量被发现可达的堆对象都会被类似地再次扫描。即从根内存节点(静态变量;栈;寄存器)出发,遍历扫描托管堆的内存节点,将被引用的内存节点标记。
- 清除阶段: 扫描堆以查找不可达、因而未标记的对象,并将它们归还到适当的空闲列表以供重用。这实际上不是一个独立的阶段;即使是非增量模式,这个操作也通常是在分配时发现空空闲列表时按需执行的。因此清除阶段极不可能触及一个不久后不会被触及的页。
- 终结阶段: 已注册终结但不可达的对象被排队,以便在收集器之外进行终结。
内存分配
Unity实现中,GC有两级分配器。大块定义为大于 HBLKSIZE(HBLKSIZE 是 2 的幂,通常约为页大小)一半的块。小块以 HBLKSIZE 大小的块分配。每个块只专用于一种对象大小和种类。
分配器为每种大小和种类的对象维护单独的空闲列表。与每种种类关联的是一个空闲列表指针数组,其中条目
freelist[i] 指向大小为 i
的对象的空闲列表。在较新版本的收集器中,索引 i
以粒(granule)表示,粒是最小的可分配单元,通常为 8 或 16
字节。一旦大块被拆分为用于较小对象,它就只能用于该大小的对象,除非收集器发现一个完全空的块。完全空的块被恢复到适当的大块空闲列表。
这个策略会造成一个问题,如果我们现在有许多不同对象大小,那么分配器需要为每一个不同大小的对象单独维护一个空闲列表(分配块)。为了避免这样的情况发生,收集器通常不会直接给每一个不同大小的对象分配。而是会将其大小向上取整到有着较少数量的分配大小之一。确切的分配大小按需计算,但要满足它们大致按几何级数增长的约束。因此执行早期请求的对象很可能以精确的请求大小分配,受对齐约束限制。
具体的GC流程
下文中对于GC具体流程的介绍,我并不会去总结文档中的“准备”和“终结阶段”。因为我想更加聚焦于标记-清除算法的实现。
在非增量模式下,只要分配在当前堆大小下会失败,Unity就决定是否进行垃圾收集。在满足一定条件下,Unity会尝试扩展堆的大小。
扩展的条件
1 | 原文: |
我没太搞懂这句话的意思,按照我的理解就是GC认为即使进行清除操作,剩余的空间也无法满足当前的分配需求。因此就需要进行扩展。而这个判断的依据就是自上次收集以来的总分配量小于堆大小除以 GC_free_space_divisor。AI对上次收集以来的总分配量的解释如下:
"自上次收集后的净分配" = 这一轮里,所有 malloc 从堆拿走的字节中,扣掉"不需要 GC 去回收"的部分后,真正会沉淀成堆内垃圾、必须靠下一次 GC 才能回收的字节数。它是"GC 一次能捞回多少"的度量,因为 GC 划不划算,取决于堆里攒的垃圾值不值得全堆扫描一次。具体计算公式如下:
1 | result = (signed_word)GC_bytes_allocd |
- GC_bytes_allocd:本轮总共拿了多少内存。这是"毛分配"。
- GC_bytes_dropped:GC 自己丢弃的整块内存也算消耗(它占过堆,之后也不在 freelist 里可复用)
- GC_bytes_freed:用户显式 free 掉的部分不算。因为这内存已回到空闲链表,下次分配直接复用,不需要 GC 去扫描回收。
- GC_finalizer_bytes_freed:终结器里释放的也属于"用户自己还了",要扣。
- expl_managed:本轮净新增的不可回收(显式管理)对象。这类对象的生死由用户自己负责,也不是 GC 的回收对象。注意这里减的是"本轮净增额"(当前存量 − 上次 GC 时存量),如果你这轮分配 100 又释放 80,净增只有 20,账就是平的。
这里AI还举例说明了一下:
设上一轮 GC 刚结束,之后程序做了这些事:
GC_malloc 若干次,累计成功 500 KB → GC_bytes_allocd = 500。其中 150 KB 被程序显式 GC_free 了 → GC_bytes_freed = 150。程序还 GC_malloc_uncollectable 了 50 KB(一直不释放)→ expl_managed = 50。没有终结器、没有 dropped。那么:
1 | 净分配 = 500 + 0 − 150 + 0 − 50 = 300 KB |
含义:这轮堆里真正沉淀下来的、只能等 GC 来收的垃圾是 300 KB;另外 200 KB(显式释放的 150 + 自管对象 50)程序自己处理掉了,GC 收不到也不用收。
如前文所说,标记-清除算法的第一个阶段是标记阶段。在每次收集中,收集器标记所有可能从指针变量可达的对象。这里的指针变量指的就是根对象。我们之前说根对象指的是 栈(虚拟机栈)中的局部变量、参数,静态变量(全局变量),常量池中的引用,活跃线程对象。当然这里也一样。在文档中其准确说明了对应的根对象:
- 寄存器。
- 栈。对于单线程应用程序,在大多数平台上,这是通过扫描(近似)当前栈指针与GC栈底(
GC_stackbottom) 之间的内存来完成的。(对于 Intel Itanium,寄存器栈被单独扫描。)GC栈底 变量以高度平台特定的方式设置,取决于gcconfig.h中的相应配置信息。注意当前活动的栈需要仔细扫描,因为客户端代码的被调用者保存寄存器可能出现在收集器栈帧内部,而栈帧可能在标记过程中变化。这是通过_急切地_扫描栈的某些区段来解决的,有效地在某个时间点捕获快照。 - 静态数据区。在最简单的情况下,这是数据开始(
DATASTART)和数据结尾(DATAEND)之间的区域,详细看gcconfig.h中定义。然而,在大多数情况下,这还涉及与动态库关联的静态数据区。这些由dyn_load.c中大多平台特定的代码识别。标记器维护一个显式的内存区域栈,这些区域已知是可访问的,但尚未搜索其中包含的指针。每个栈条目包含要扫描的块的起始地址,以及块的描述符。如果没有可用的布局信息,则描述符只是长度。(其他可能性参见gc_mark.h。)
在标记阶段开始时,所有根段(如上所述)都会被压入栈等待扫描。GC确定候选指针是否真的是堆块的地址。这通过以下步骤完成:
- 候选指针与粗略的堆边界进行检查。这些堆边界的维护使得所有实际堆对象都落在它们之间。为了便于黑名单,我们还包含堆可能扩展到的地址区域。大多数非指针都无法通过这个初始测试。
- 候选指针被分成两部分;最高有效位标识地址空间中大小为
HBLKSIZE的页,最低有效位指定该页内的偏移。(一个硬件页实际上可能由多个这样的页组成。) - 候选指针的页地址部分有着对应的表目录。每个表条目包含
0,表示该页不是垃圾收集堆的一部分;一个小整数
n,表示该页是大对象的一部分,至少从 n
页前开始;或一个指向该页描述符的指针。在第一种情况下,候选指针
i不是真正的指针,可以安全忽略。在后两种情况下,我们可以获得包含对象开头的页的描述符。 - 计算被引用对象的起始地址。页描述符包含该页中对象的大小、对象种类以及这些对象所需的标记位。大小信息可用于将候选指针映射到对象起始地址。为了加速此过程,页头还包含一个指向预计算页偏移到对象开头位移的映射的指针。使用此映射避免了在计算对象起始地址时可能很慢的整数取余运算。
- 检查并设置目标对象的标记位。如果对象之前未标记,则对象被压入标记栈。描述符从页描述符读取。(这是在页首次分配时根据
GC_obj_kinds的信息计算的。)
关于黑名单
收集器实现了_黑名单_机制,如 Boehm 的 "空间高效的保守收集",PLDI'93 所述,也可在这里获取。
在标记阶段,收集器跟踪 near misses
,即试图跟随一个指针到垃圾收集堆之外,或到堆内当前未分配的页。作为此类near misses目标的页未来很可能成为被误识别的指针的目标。为了最小化此类误识别造成的未来损害,它们将只被分配给小的无指针对象。
收集器理解两种不同的黑名单。如果某页是来自需要内部指针识别的位置(例如栈,或在设置了
GC_all_interior_pointers
时的堆)的near misses的目标,则该页可能被列入内部指针引用的黑名单(GC_add_to_black_list_stack)。在这种情况下,我们还避免分配包含此页的大块。
如果near misses来自不需要内部指针识别的来源,则(通过
GC_add_to_black_list_normal)将其列入黑名单。以这种方式列入黑名单的页可以出现在大对象内部,只要它不是大对象的第一页。
GC_allochblk
例程在将块分配给特定对象种类和大小时尊重黑名单。它偶尔会丢弃(即分配并遗忘)完全被列入黑名单的块,以避免大块空闲列表过长且只包含不可用块。如果对小无指针对象的需求低,这否则会成为一个问题。
在标记阶段结束时,剩余空闲列表的标记位被清除,以防空闲列表因杂散指针被意外标记。
在标记阶段结束后就是清除阶段。在这个阶段,GC检查堆中的所有块。未标记的大对象立即被归还到大对象空闲列表。检查每个小块页以查看所有标记位是否都已清除。如果是这样,整个页被归还到大对象空闲列表。包含一些可达对象的小块页被排队以便稍后清除,除非我们确定该页包含很少的可用空间,否则不再进一步检查它。这个初始清除过程只接触块头,而不接触块本身。因此即使堆的大区段不在物理内存中,它也不需要大量分页。
非空的小块页在分配尝试遇到该对象大小和种类的空Free-List(空闲列表)时被清除。其反复清除正确大小和种类的页,直到找到至少一个空块。清除这样的页涉及扫描页头中的标记位数组,并构建通过对象第一个字链接的空闲列表。这确实涉及接触适当的数据页,但在大多数情况下,它只会在用于分配之前不久被接触。因此任何分页本质上都是不可避免的。
Unity中的内存机制
Unity中的内存机制和.net还是有所不同的。Unity内存分为2个部分:托管堆和非托管堆。有些人也会将非托管堆再进行细分,分为:Unity本机存储和非托管堆。我们之前讨论的GC仅仅作用于托管堆。所以对应的非托管堆(不包括Unity本机存储)就要我们自己来控制其生命周期了。
参考资料
闲言碎语
我总感觉自己这篇文章写的很乱。这大概率是因为我想简单粗略的写一下关于GC的文章,但是又觉得很多东西都重要,最终导致了文章混乱。我本来想着对照参考视频中的内容一点点去做就好了。然后我鬼迷心窍的想看一下参考视频中的所说的项目。我想着全部照抄也不好,至少多搞点东西吧。然而我并没有这样的实力。c++还是太难了,我只能用AI辅助分析。可AI本身分析也不是那么可靠,我还是要看一下对应的源码来验证。但是源码又太多了,一一验证也十分的困难,还不如我直接去理解源码。结果导致了我写出了这篇逻辑混乱的文章。
事已至此,我已经无力再去修改了。彡(-_-;)彡





