Unity使用的GC方式——贝姆GC(BOEHM GC)

Unity合作的Mono版本为Mono的早期版本,此时还没有使用SGen GC,后来Mono将默认GC方式改为SGen GC,Unity并没有继续购买,因此Unity使用的GC方式仍然是老的贝姆GC。
贝姆GC官方网页:https://www.hboehm.info/gc/index.html

1. 阶段

贝姆GC是一种基于标记清除法的GC方式。其整体过程可粗略分为四个阶段:

  1. 准备阶段:所有对象的MarkBit重置。
  2. 标记阶段:从Root出发进行扫描,将可达对象进行标记。
  3. 清理阶段:扫描托管堆,将所有未标记的对象返回给对应的FreeList。
  4. Finalization阶段:所有注册了终结器的无效对象加入终结器队列单独处理。

2. 分配内存

贝姆GC对于对象的内存管理是区分内存类型和内存大小的。

  • 内存类型分为NORMAL、PTRFREE和UNCOLLECTABLE三类(这个UNCOLLECTABLE是从网上其他文章看来的,原文里没发现,只看到STUBBORN,不过这个并不重要)。
  • 根据对象占用内存大小不同,又分为大对象和小对象。

HBLK

运行时从操作系统申请的堆内存被划分成一个一个的内存块进行管理,称为HBLK。HBLK的大小不同,其粒度为内存页大小的整数倍,并通过数组记录HBLK链表,数组下标(0除外)代表当前位置链表中HBLK管理的相邻Page的数量。比如数组下标为1的位置,其链表内每个HBLK大小为1个Page,下标为4的位置,HBLK的大小则为4个Page。
在这里插入图片描述

大对象存储

对于大对象的分配,将大对象的内存大小向上计算,得到满足大小的HBLK粒度,然后从对应粒度的HBLK链表中找到可用的HBLK进行存储。

小对象存储

对于小对象的分配,由于每个对象实际占用的内存大小各不相同,为了避免内存被划分的稀碎,贝姆GC对于小对象的内存分配也是分粒度的,一般以16B作为基数。

小对象并不是直接存储在HBLK中,而是将HBLK中的Page根据指定的小对象的内存粒度进行拆分,这样就形成了一小块一小块不同粒度的内存块,将同样粒度的内存块串成链表,就有了不同粒度的可用内存链表(ok_freeList)。另外,上文说到,GC回收的内存也会被返还给FreeList。

当申请创建小对象时,根据对象所占内存向上计算所属的内存粒度,然后查找对应粒度的FreeList,找到一块可用的内存,将对象存储进去,然后将该块内存从FreeList移除。

当对应粒度的FreeList为空时,会触发一次GC,尝试回收内存块,如果还没有可用的内存块,则查找HBLK链表,找到一块可用的HBLK,将HBLK中的Page拆分,补充FreeList。

这里需要注意的点是,由于对象内存分了类型,所以不同类型的对象不能存放到一起,因此是每个类型的内存都维持了一个数组,里面记录着可分配给当前类型的不同粒度的内存块的链表。而向HBLK申请补充FreeList时也一样,一旦HBLK对某一类型和粒度的对象进行了拆分,这一整个HBLK就不能再用于存储其他类型和粒度的对象了。

由此我们可以得出,在Unity中,创建均匀且大量使用的小对象对于内存是更友好的。而创建尺寸各异且数量很少的对象就会导致HBLK被拆分后实际又没有那么多对象可存,从而浪费内存。

在这里插入图片描述

3. 标记阶段

标记过程为STW的,通过标记栈,依次从Uncollectable对象和ROOT对象出发进行遍历,将所有可被遍历到的对象进行标记。当标记栈被清空时,标记阶段结束,此时未被标记的对象被认为是可回收的。
其中,ROOT对象包括以下三类:

  • 寄存器内的对象
  • 栈上的对象
  • 静态区的对象

4. 清理阶段

原文中提到,清理阶段实际上并不是一个真正的独立阶段。因为这个阶段并不是真的停下来什么都不做开始清理内存,其对于内存的处理分为几种情况。

  • 对于未标记的大对象,由于其存储在HBLK中,直接将其所占用的内存返还给HBLK FreeList。
  • 对于存储小对象的Page,如果其MarkBit Table中都是可被清理,则整页返还给HBLK FreeList。
  • 对于有小对象不可清除的Page,暂时不做处理,等到出现上文分配内存需要查找可用FreeList时,再对要分配的类型和粒度的Page进行检查,将其中可以回收的内存块返还给ok_FreeList。

这样做的好处是当分配内存操作触发GC后,不需要立刻将所有内存都进行回收,只需要对一部分当前要分配的类型和粒度的内存进行回收即可。

5. Finalization 阶段

我们知道,运行时管理的是托管对象,假设现在有一个托管对象A,且A引用了非托管对象B。为了避免内存泄漏,就需要在A被释放之前先手动释放对B的引用。我们固然可以在特定的逻辑节点进行这个操作,但更多的时候,我们希望在A被回收前自动结束对B的引用。

从上文可以知道,A的回收是在清理阶段或者在新分配内存时由GC线程自己触发的,我们并不清楚这个操作发生的具体时间点。为了能够使这个操作可控,于是有了终结器机制。

所有注册了终结器的对象(比如重写了Finalize方法)会被额外存到一个单独的哈希表中,我们可以称之为FinalizableList。意味着列表中的所有对象在被回收前都需要先执行终结器进行一些额外的操作。

那是不是说对于这些对象,只要简单地先执行终结器再释放就可以了呢?当然也不是。因为终结器中到底写了些什么逻辑运行时是不知道的,所以理论上就存在这样的情况:一个本来不可达的对象(未被标记的垃圾内存)在终结器中手动添加了从某个可达对象到自己引用,于是在终结器执行结束之后自己就变成非垃圾对象,也就是“复活”了。同样,该对象引用链之下的所有对象也都跟着复活了。

基于此,在标记阶段结束时,会检查FinalizableList列表,将其中所有不可达的对象重新推到标记栈上,然后由其出发进行遍历,重新进行一轮标记。也就是先默认这些对象复活了。

这里需要注意的是,该阶段的本意是先保证这些Finalizable对象下游的对象不会被提前回收造成程序错误,然后对其执行终结器。然而一个Finalizable对象A可能在重新标记时遍历到另一个Finalizable对象B,这时,如果A和B都执行终结器被认为是不安全的,于是在遍历出发时,对象本身并不会被标记,只有在遍历过程中被其他对象引用到时才会进行标记。

在这一轮标记结束后,将仍然未被标记过的Finalizable对象从FinalizableList移除,加入一个等待执行终结器的队列F-Queue。本轮GC便不再对这些对象进行回收。

F-Queue中的对象会在适当的时机执行终结器并从队列中移除。这样在之后的GC过程中,如果该对象仍未被标记到,由于它既不在FinalizableList中也不在F-Queue中,就可以正常被释放了,而且终结器也不会被重复执行。

由上可知,即使没有手动复活的操作,注册了终结器的对象最少也要经过两轮GC才能被真正释放,而那些被Finalizable对象直接或间接引用到的Finalizable对象甚至需要更多轮的GC才会被释放,所以才会有非必要不要随便注册终结器的说法。

  • 25
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值