关于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. 初始化:对象创建时,引用计数设为 1。
  2. 引用增加:每当有新的引用指向该对象(如赋值 b = a),计数器 +1。
  3. 引用减少:当某个引用被销毁、改写或离开作用域时,计数器 -1。
  4. 回收:当计数器归零时,说明没有任何引用指向它,对象可被立即回收。

优点

  • 实时性:对象一旦无人引用,立即回收,内存释放及时,无停顿。
  • 实现简单:无需复杂算法,只需在赋值/销毁处维护计数。
  • 局部性好:回收动作分散,不会产生明显的"卡顿"。

缺点

  • 循环引用问题:两个对象互相引用,计数器永远不为 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是不进行压缩的。只有下面几种情况下才会进行压缩:

  1. Gen0的剩余空间容不下新 Gen0 的期望分配量(在判断前计算出来的一个值),如果压缩之后还是不够就会进行一次扩容。要注意这些判断都是在计划阶段进行的。
  2. Gen0的空间减少时,此时GC会进行压缩。
  3. 内存负载爆表。当内存负载过高的时候就进行压缩,与第二点不同的是这里的情况更紧急。
  4. PM 机制内 Gen1 收集必须压缩,为后续全量 GC 做准备
  5. 碎片率超限
关于第二点的一些补充说明,下面解释由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
2
3
4
5
6
7
8
SOH:
低地址 高地址
↓ ↓
┌──────────────┬─────────────┬──────────────┬────────────────────┐
│ │ │ │ │
└──────────────┴─────────────┴──────────────┴────────────────────┘
↑ ↑ ↑ ↑ ↑
Gen2起始位置 Gen1起始位置 Gen0起始位置 bump开始分的配位置 段尾

上面的展示是Segment模式下的情况。Region模式下,Gen1和Gen2的起始位置是占位符。而Segment模式更加好理解,大部分的资料也是用这个模式来进行说明的。需要说明的是上面样子看似Gen1和Gen2的有容量,但是实际上它们的容量是0。Gen1和Gen2的起始指针有额外的占用,因此才分配了一定存储。而在逻辑上,他们就是没有容量。此时整个段都是Gen0的,直到触发第一次GC。

GC触发条件

        “一般除非我们手动调用GC,否则GC触发条件是此时bump分配位置与段尾之间的容量不足以放下新对象的时候”。大多数的资料都是这么说明的,当然这也很好理解,但是隐式的触发条件还是有很多的,源码中将其分为如下的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
"alloc_soh"        // 小对象堆分配触发
"induced" // 显式 GC.Collect
"lowmem" // 低内存
"alloc_loh" // LOH 分配触发
"oos_soh" // 小对象堆空间耗尽(out of space)
"oos_loh" // LOH 空间耗尽
"induced_noforce" // 非强制的显式 GC
"gcstress" // GC 压力测试
"induced_lowmem" // 显式低内存
"induced_compacting" // 显式压缩式 GC
"lowmemory_host" // host 报告低内存
"pm_full_gc" // Predecessor Mechanism 全量 GC
"lowmemory_host_blocking" // host 低内存阻塞式

Gen0第一次触发GC,Gen0存活的对象会放置到Gen1中。Gen1第一次GC会将Gen1存活的对象放置到Gen2中。Gen2第一次GC就只会压缩。因为JavaGC的影响性,很多人会认为:所谓存活对象放到老一代就是将对象移动,或者说复制到老对象的区域。这么理解好像也没什么问题,但是实际上Csharp的操作并非如此。如下,是一个Gen0进行清理后还未进行代际晋升的示意:

1
2
3
4
5
6
7
低地址                                                           

┌──────────────┬─────────────┬──────────────┬──────────────────
│ │ │ │
└──────────────┴─────────────┴──────────────┴──────────────────
↑ ↑ ↑ ↑
Gen2起始位置 Gen1起始位置 Gen0起始位置 最后一个存活对象的末尾

进行晋升后如下:

1
2
3
4
5
6
7
低地址                                                           

┌──────────────┬─────────────┬──────────────┬──────────────────
│ │ │ │
└──────────────┴─────────────┴──────────────┴──────────────────
↑ ↑ ↑ ↑
Gen2起始位置 Gen1起始位置 (原)Gen0起始位置 Gen1的结尾位置(原最后一个存活对象的末尾)

实际上代际晋升的操作只是移动了指针,通过修改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。当然它的标记-清除算法也是进行改进过的,它大致分四个阶段运行。

  1. 准备: 每个对象都有一个关联的标记位。清除所有标记位,表明所有对象都可能不可达。
  2. 标记阶段: 标记所有可以通过从变量出发的指针链可达的对象。通常收集器对堆中指针变量的位置没有真实信息,因此它将所有静态数据区、栈和寄存器视为可能包含指针。任何表示收集器管理的堆对象内部地址的位模式都被视为指针。除非客户端程序向收集器提供了堆对象布局信息,否则任何从变量被发现可达的堆对象都会被类似地再次扫描。即从根内存节点(静态变量;栈;寄存器)出发,遍历扫描托管堆的内存节点,将被引用的内存节点标记。
  3. 清除阶段: 扫描堆以查找不可达、因而未标记的对象,并将它们归还到适当的空闲列表以供重用。这实际上不是一个独立的阶段;即使是非增量模式,这个操作也通常是在分配时发现空空闲列表时按需执行的。因此清除阶段极不可能触及一个不久后不会被触及的页。
  4. 终结阶段: 已注册终结但不可达的对象被排队,以便在收集器之外进行终结。
内存分配

        Unity实现中,GC有两级分配器。大块定义为大于 HBLKSIZE(HBLKSIZE 是 2 的幂,通常约为页大小)一半的块。小块以 HBLKSIZE 大小的块分配。每个块只专用于一种对象大小和种类。

        分配器为每种大小和种类的对象维护单独的空闲列表。与每种种类关联的是一个空闲列表指针数组,其中条目 freelist[i] 指向大小为 i 的对象的空闲列表。在较新版本的收集器中,索引 i 以粒(granule)表示,粒是最小的可分配单元,通常为 8 或 16 字节。一旦大块被拆分为用于较小对象,它就只能用于该大小的对象,除非收集器发现一个完全空的块。完全空的块被恢复到适当的大块空闲列表。

        这个策略会造成一个问题,如果我们现在有许多不同对象大小,那么分配器需要为每一个不同大小的对象单独维护一个空闲列表(分配块)。为了避免这样的情况发生,收集器通常不会直接给每一个不同大小的对象分配。而是会将其大小向上取整到有着较少数量的分配大小之一。确切的分配大小按需计算,但要满足它们大致按几何级数增长的约束。因此执行早期请求的对象很可能以精确的请求大小分配,受对齐约束限制。

具体的GC流程

        下文中对于GC具体流程的介绍,我并不会去总结文档中的“准备”和“终结阶段”。因为我想更加聚焦于标记-清除算法的实现。

        在非增量模式下,只要分配在当前堆大小下会失败,Unity就决定是否进行垃圾收集。在满足一定条件下,Unity会尝试扩展堆的大小。

扩展的条件
1
2
3
4
5
6
7
原文:

If the total amount of allocation since the last collection is less than the heap size divided by GC_free_space_divisor, we try to expand the heap.

翻译:

如果自上次收集以来的总分配量小于堆大小除以 GC_free_space_divisor,我们尝试扩展堆。

我没太搞懂这句话的意思,按照我的理解就是GC认为即使进行清除操作,剩余的空间也无法满足当前的分配需求。因此就需要进行扩展。而这个判断的依据就是自上次收集以来的总分配量小于堆大小除以 GC_free_space_divisor。AI对上次收集以来的总分配量的解释如下:

"自上次收集后的净分配" = 这一轮里,所有 malloc 从堆拿走的字节中,扣掉"不需要 GC 去回收"的部分后,真正会沉淀成堆内垃圾、必须靠下一次 GC 才能回收的字节数。它是"GC 一次能捞回多少"的度量,因为 GC 划不划算,取决于堆里攒的垃圾值不值得全堆扫描一次。具体计算公式如下:

1
2
3
4
5
result = (signed_word)GC_bytes_allocd
+ (signed_word)GC_bytes_dropped
- (signed_word)GC_bytes_freed
+ (signed_word)GC_finalizer_bytes_freed
- expl_managed;
  • 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本身分析也不是那么可靠,我还是要看一下对应的源码来验证。但是源码又太多了,一一验证也十分的困难,还不如我直接去理解源码。结果导致了我写出了这篇逻辑混乱的文章。

        事已至此,我已经无力再去修改了。彡(-_-;)彡