Unity GC + C# GC + Lua GC原理

Unity垃圾回收原理

参考文章:垃圾回收 (计算机科学) - 维基百科,自由的百科全书 (wikipedia.org)

在计算机科学中,垃圾回收 (英语:Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。

原理

垃圾回收器有两个基本的原理:

  1. 考虑某个对象在未来的程序执行中,将不会被访问。
  2. 回收这些对象所占用的存储器。

分类

收集器实现

引用计数收集器

主条目:引用计数

最早的也是最简单的垃圾回收实现方法,这种方法为占用物理空间的对象附加一个计数器,当有其他对象引用这个对象时计数器加一,反之引用解除时减一。这种算法会定期检查尚未被回收的对象的计数器,为零的话则回收其所占物理空间,因为此时的对象已经无法访问。这种方法无法回收循环引用的存储对象。

跟踪收集器

主条目:追踪垃圾回收

近现代的垃圾回收实现方法,这种算法会定期遍历它管理的内存空间,从若干根储存对象开始查找与之相关的存储对象,然后标记其余的没有关联的存储对象,最后回收这些没有关联的存储对象占用的内存空间。

回收算法

基于其标记和回收行为,又分为若干细致方法。

标记-清除

先暂停整个程序的全部运行线程,让回收线程以单线程进行扫描标记,并进行直接清除回收,然后回收完成后,恢复运行线程。这样会产生大量的空闲空间碎片,和使大容量对象不容易获得连续的内存空间,而造成空间浪费。

标记-压缩

和"标记-清除"相似,不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化。

复制

需要程序将所拥有的内存空间分成两个部分。程序运行所需的存储对象先存储在其中一个分区(定义为"分区0")。同样暂停整个程序的全部运行线程,进行标记后,回收期间将保留的存储对象搬运汇集到另一个分区(定义为"分区1"),完成回收,程序在本次回收后将接下来产生的存储对象会存储到"分区1"。在下一次回收时,两个分区的角色对调。[3]

这种方式非常简单,但是因为只有一个"半空间"(semi-space)被用于分配对象,内存使用相较于其他算法是其两倍。这种技术也叫做"停止并复制"。Cheney算法是改进的半空间分配器。

增量回收器

需要程序将所拥有的内存空间分成若干分区。程序运行所需的存储对象会分布在这些分区中,每次只对其中一个分区进行回收操作,从而避免暂停所有正在运行的线程来进行回收,允许部分线程在不影响回收行为下保持运行,并且降低回收时间,增加程序响应速度。

分代

由于"复制"算法对于存活时间长,大容量的储存对象需要耗费更多的移动时间,和存在储存对象的存活时间的差异。需要程序将所拥有的内存空间分成若干分区,并标记为年轻代空间和年老代空间。程序运行所需的存储对象会先存放在年轻代分区,年轻代分区会较为频密进行较为激进垃圾回收行为,每次回收完成幸存的存储对象内的寿命计数器加一。当年轻代分区存储对象的寿命计数器达到一定阈值或存储对象的占用空间超过一定阈值时,则被移动到年老代空间,年老代空间会较少运行垃圾回收行为。一般情况下,还有永久代的空间,用于涉及程序整个运行生命周期的对象存储,例如运行代码、数据常量等,该空间通常不进行垃圾回收的操作。 通过分代,存活在局限域,小容量,寿命短的存储对象会被快速回收;存活在全局域,大容量,寿命长的存储对象就较少被回收行为处理干扰。

现今的GC(如Java.NET)使用分代收集(generation collection),依照对象存活时间的长短使用不同的垃圾收集算法,以达到最好的收集性能。

原文链接:Unity GC 学习总结 - 知乎 (zhihu.com)

什么是GC

总所周知,内存是程序运行时所需要的重要资源,在程序运行时往往需要内存来临时存储各种数据,但是操作系统提供给进程的堆内存(注意是堆内存,栈上的内存会随函数调用自动被回收,下文提及的都是指堆内存)是有限的,所以我们需要对这有限的资源进行管理

在代码中,我们会反复地申请内存来完成各种计算,等到确认内存不需要使用时,我们就会归还这部分内存,从而可以将其用于其他地方。GC所做的事情,就是自动确定那些不需要的内存,或者说 Garbage ,然后将其归还。这样开发者就无需关心内存的管理。

GC的实现

实现GC的策略有很多种,其中最常见一种就是Tracing garbage collection,或者叫 Mark-Sweep ,这种算法会通过一个root Object,遍历这个该对象引用的变量,并且标记,递归这个过程,这样就确定了所有reachable的对象,剩下的对象即视为garbage。

另一种常见的策略还有引用计数(Reference counting),它是通过为每个对象维护一个引用计数,这代表当前对该对象的引用数目,当引用为0,即代表该对象为 Garage。引用技术有如下缺点

  • 循环引用问题
  • 保存计数带来的空间开销
  • 修改引用数目带来的速度开销以及原子性要求
  • 非实时(一个引用的变化可能递归得导致一系列引用修改,内存释放)

有很多算法可以一定程度解决上述问题,顺便一提,C++使用的智能指针即是基于引用计数实现的,COM对象也使用了引用计数来管理。

GC的优缺点

优点

如上文提及的,可以将程序从对内存的维护中解放出来,专心于代码逻辑。不会发生因为内存管理不当而导致的问题,例如

  • 内存泄漏
  • 访问已经释放的指针
  • 反复释放指针

缺点

那么代价是什么呢?享受 GC 带来的便利,意味你必须承受 GC 开销对性能的影响,眼睁睁地看着它费老大劲去处理一个你一眼看出来的 Garbage 。比如

Unity 中的GC

Unity的脚本后端是基于Mono的实现(当然现在多了个IL2CPP,不过也是类似的GC实现),而Mono使用的GC是所谓的Boehm--Demers--Weiser garbage collector。是Mark-Sweep 的实现,它会在需要进行GC时占用主线程,进行遍历-标记-垃圾回收的过程,然后在归还主线程控制权 。这会导致帧数的突然下降,产生卡顿(不过因为该实现是非压缩式的,所以卡顿现象相对较轻,但是对内存利用率进一步下降了,会有内存碎片 的问题。。囧)。所以我们需要慎重地处理对象的创建(内存请求),还有释放(使用GC管理内存是没有主动释放内存的接口的,但是我们可以通过消除对某个对象的引用来做到这一点)。此外,Unity的代码分为两部分:托管与非托管,GC影响的只有托管部分的代码使用的堆内存。而且这个托管堆占用的地址空间不会返还给操作系统,非托管内存需要手动维护

GC的优化

上文讲到了GC对性能影响的原因(占用主线程进行大量工作),而优化GC即是减小GC占用主线程时花费的CPU时间,所以优化GC优化的是CPU时间,而非内存,事实上常见的优化GC的手段之一就是占用内存

排查热点

优化的第一步就是确定性能热点,我们可以使用 Unity 自带的 Profiler 中 CPU Usage里的Garbage Collector来确定,或者粗暴一点使用 GarbageCollector.GCMode 这一接口来关掉GC,然后观察 Profiler 中 Memory里的 Total GC Allocated 来确定。不过该接口无法用于编辑器下。

常见热点与优化方式

GC优化的核心在于消除垃圾,减小GC运行时间。GC的热点一般都是写了一些会产生大量垃圾的代码。

1.字符串

cs 复制代码
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

上述代码中拼接字符串会导致一些额外的中间对象产生,所以会大量创建临时的变量,可以通过使用StringBuilder来优化。此外还在Update中每帧调用,进一步恶化了问题,创建了更多的临时变量。可以通过将变量改为非局部变量来解决(这也就是上面讲的占用内存,优化GC),上述代码即可以优化成

cs 复制代码
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Text;

public class ExampleScript : MonoBehaviour
{
    public Text scoreBoard;
    public StringBuilder scoreText;
    public int score;
    public int oldScore;

    void Update()
    {
        if (score != oldScore)
        {
            scoreText.Clear();
            scoreText.Append("Score: ");
            scoreText.Append(score.ToString());
            scoreBoard.text = scoreText.ToString();
            oldScore = score;
        }
    }
}

StringBuilder

[C#] StringBuilder简介及使用方法_c# stringbuilder length方法-CSDN博客

C#中,StringBuilder弥补了string在赋值时开辟新空间不足之处。

StringBuilder类型变量会初始化一段长度,供后续对该变量进行增加。当然也可以手动定义其长度

StringBuilder builder = new StringBuilder(10);

其缺点是需要较为精确估算出StringBuilder类型变量的长度,否则若在使用中实际builder长度超出了定义的长度,会自动开辟一段新的StringBuilder空间,并将原先的数据赋值给新的空间,旧的地址就变成了垃圾。

cs 复制代码
StringBuilder builder = new StringBuilder(10);
for (int i = 1; i < 10; i++) 
{
    builder.Append(i); // 0123456789
}

整个操作都是在一处内存地址,提高了内存利用率。在Unity实际开发时也有很大的用处

实例方法

单词反转 Hello world => world Hello

cs 复制代码
private static StringBuilder t3(string str)
{
    string[] str2 = str.Split(' ');
    StringBuilder builder = new StringBuilder(str.Length);
    for (int i = str2.Length - 1; i >= 0; i--)
    {
        builder.Append(str2[i]);
        if (i != 0)
            builder.Append(" ");
    }
    return builder;
}
 
// 调用
Console.WriteLine(t3("Hello world")); // world Hello

2.闭包

闭包的使用也需要慎重,因为闭包除了函数指针还会将捕获的变量一起包装起来创建到堆上,相当于 new 了个对象,性能敏感部分代码还是要慎重使用。可以通过将匿名函数改为成员函数,捕获变量改为成员变量一定程度上缓解,不过还是会有影响。

3.装箱

还有要小心装箱,这也会隐式地导致对象的创建。从而产生意想不到的垃圾。用枚举值当字典的key的时,各种字典操作会调用 Object.getHashCode 获取哈希值 ,该方法会导致装箱。Unity5.5版本以前 foreach 会导致装箱,这之后的版本修复了这个问题,但是 foreach相比起直接使用下标遍历还是要慢(因为有一些额外的方法调用),不过这就和GC没啥关系了。

4.返回数组的Unity API

应该是为了防止意外修改内部值,Unity API返回数组对象时返回的是一份拷贝。类似下面的代码

cs 复制代码
for(int i = 0; i < mesh.vertices.Length; i++)

{

    float x, y, z;

    x = mesh.vertices[i].x;

    y = mesh.vertices[i].y;

    z = mesh.vertices[i].z;

    // ...

    DoSomething(x, y, z);   

}

会导致4次数组拷贝,可以通过cache返回的数组(返回引用解决)来解决。

5.空数组

空数组(长度为0的数组)的创建事实上也会导致堆内存的分配。所以应该将其提前创建出来并复用。

上述问题的原因都是类似的,即大量地创建了短暂使用的对象(垃圾),基本上都可以通过将会反复使用的对象创建为非局部变量来解决(或者更进一步,使用所谓对象池的技术,基本原理是一样的)。有些地方就只能通过避免会造成垃圾产生的接口来解决。总之优化GC,核心在于消灭垃圾

特别的技巧

1.关闭GC

可以把需要的内存先全部创建完,然后关掉GC,不过感觉这种方式应用场景太有限。

2.主动定时GC

游戏的卡顿来自与不稳定的帧数变化(稳定的低帧数和不稳定的高帧数前者可以带来更平滑的体验),所以可以按一定间隔主动地调用 System.GC.Collect 进行GC,这样就不会有剧烈的毛刺产生,当然这个间隔不能太小,否则就和不主动调用区别不大,但也不能太小,否则会对帧数造成明显影响,具体数值的确定还是很难的。

3.主动扩大托管堆

Mono的GC会尽量避免内存的扩展,所以说它对判断 需要进行GC 了的阈值比较低,可能已分配内存达到当前GC管理内存的70%~80%就会进行GC了,如果GC的持有内存足够大的话,就会减少GC的触发,可以通过类似下面的代码

cs 复制代码
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}

来强行扩大GC管理内存的大小。不过实际开发中还有贴图之类的内存大户,留给GC的可以内存实在不多,盲目请求过大的内存可能会被操作系统无情干掉,要慎重。而且因为托管堆占用的地址空间并不会归还,所以请求太大的托管堆会导致内存的浪费。。这种做法算是空间换时间。

增量式GC(incremental garbage collection)

上文提到的 Unity GC实现是非分代式 的,也就是是说,要么不做,要做就一次性作完。unity 在 2018 的版本推出了所谓增量式GC的功能,还是基于 Boehm--Demers--Weiser garbage collector 的实现,但是不再是非分代式的,这能带来特别的技巧♂中第二点同样的好处,即均衡负载到多帧,消除毛刺 。可以缓解卡顿。因为GC的执行分配到每帧了,所以单帧GC的执行时间会受到垂直同步 还有 unity 的 Application.targetFrameRate 的影响。

增量式GC目前还是抢先体验版本,因为它事实上还是存在一些问题,它的基本实现原理还是标记-清扫,但是在两次增量式GC之间,对象的引用可能会发生变化,导致前一次GC的标记失效,需要重新进行遍历标记,最糟的情况会退化为普通的非分代GC(其实更糟,因为前面的工作全白费了)。比如这样的代码

cs 复制代码
void Update()
    {
        if (Time.frameCount % 2 == 0)
        {
            s = "A";
        }
        else
        {
            s = "B";
        }
    }

"A" 和 "B" 在垃圾与不是垃圾之间反复横跳(字符串常量的引用可能不太一样,但这里是为了表达对象引用情况反复变化的意思)。而且增量式GC还需要额外的开销来确定对象的引用是否变化,这开销也不可忽视,实际项目看对毛刺的容忍程度来确定要不要使用增量式GC,而且要好好地做Profiler,很容易一不小心就负优化了。

贝姆垃圾收集器 原文链接:贝姆垃圾收集器 - 维基百科,自由的百科全书 (wikipedia.org)

Boehm-Demers-Weiser garbage collector ,也就是著名的Boehm GC ,是计算机应用在C/C++语言上的一个保守的垃圾回收器

原文链接:Unity 垃圾回收GC的原理? - 知乎 (zhihu.com)

1、BoehmGC中的内存分配

作为一个重量级的基础组件库,BoehmGC的使用方法非常简单,只需要把系统函数malloc替换为GC_malloc即可,之后你就完全不用管何时free的问题。在小型项目里,你甚至可以直接

cs 复制代码
#define malloc(n) GC_malloc(n)

然后再不用管free,BoehmGC自会帮你打理好一切。

既然全盘接管了内存分配,那就必须做到以下两点,才能称得上是合格的分配器

1). 分配的效率要高

2). 尽量避免内存浪费,避免碎片化等

那BoehmGC是怎么做的呢?

2、BoehmGC的内存分配架构

在整个内存分配链的最底部,BoehmGC通过平台相关接口来向操作系统申请内存(可能是malloc, sbrk, 或者mmap等)。为了提高效率会根据配置,每次批量申请4K的倍数大小,除了用户能使用的内存之外,还有BoehmGC内部维护的数据结构(通过GC_scratch_alloc分配)。

分配器的核心是一个分级的结构,BoehmGC把每次申请根据内存大小归类成小内存对象(Small Object)和大内存对象(Large Object),这点和STL的分配器也比较相似。归类的依据具体来说就是,

1)不超过PageSize/2,也就是2048字节的对象为小内存对象

2)大于PageSize/2的对象为大内存对象

cs 复制代码
//heap block定义了一个页,大小为4K的倍数
struct hblk {
    char hb_body[HBLKSIZE];
};

对于Large Object,向上取整到4K的倍数大小,直接以整数个hblk的形式给出。

而Small Object则会先申请一个hblk出来,而后在这块内存上进一步细分为Small Objects,形成free-list。

3、BoehmGC的内存管理策略

为了尽量减少碎片化和加速分配,BoehmGC在设计上就做了一些限制,充分体现了"物以类聚"的思想。

首先,GC管理的对象有一个最小的"粒度",即Granule。

32位上这个值是8字节,64位则是16字节。

在64位环境下,即使用户申请的内存是10个字节,也会被向上调整到16字节。

一个在用的hblk如果不是属于一个large object,那就是容纳了若干个等大小的small object。

对于有一定内存分配器实现经验的开发者来说,以上两点应该都比较熟悉了,不过BoehmGC把这种"物以类聚"的设计贯彻落实得更加彻底。

对于大内存对象(large object),按照对应的hblk数,把他们归类到若干个freelist中。具体的做法可以参考GC_hblk_fl_from_blocks和GC_allochblk_nth。

当大内存对象被垃圾回收的时候,会尝试把相邻的hblk合并,减少内存碎片。

对于小内存对象的大小分档,也不是完全按照Granule的等差数列来决定。有些临近的大小会被优化合并掉,比如系统当前有很多1024字节的闲置块,但申请1008字节的小内存对象仍然可能miss。此时用1024字节的块可能是更好的选择,适当的合并临近的block size可以优化内存分配效率。这块的做法可以参考GC_init_size_map和GC_extend_size_map。

三、和lua GC有什么区别?

不知道有没有同学想过,lua中的GC用到是什么算法。

Lua 5.3 的垃圾回收机制采用的是标记-清除算法(mark-and-sweep) ,它会对所有经过 Lua 管理的内存进行垃圾回收,但不会回收非 Lua 管理的内存,例如使用 malloc 或者 new 分配的内存。该算法的优点是实现简单,效率高,但缺点是可能会产生内存碎片

而Boehm GC(Garbage Collector)是一个通用的垃圾回收库,可以用于 C 和 C++ 语言中的动态分配的内存

四、和Java 、C# GC的区别?

Java和 C# GC 都是精准式GC,而Boehm GC 是保守式的。

下面是一个比较官方的回答

"保守式垃圾回收是一种通过近似方式识别和回收垃圾对象的方式。在保守式垃圾回收中,垃圾回收器并不直接访问对象的内部结构和引用关系,而是通过扫描内存中的数据块,识别出可能是指向对象的指针,并将其标记为活动对象。然后,垃圾回收器将从活动对象出发,递归地遍历和标记其他可达的对象,并回收那些未被标记为活动对象的内存。保守式垃圾回收不需要额外的内存开销来维护对象之间的引用关系,但可能会存在一定的误判,即将某些实际上是垃圾的对象错误地标记为活动对象。"

用大白话讲就是Boehm GC无法区分指针和非指针,这就可能由于误判导致有些已经可以释放的内存无法释放。

而Java和 C# GC是可以的,但是需要付出一定的代价。

Lua GC

以后补充

相关推荐
小春熙子1 小时前
Unity图形学之Shader结构
unity·游戏引擎·技术美术
Sitarrrr3 小时前
【Unity】ScriptableObject的应用和3D物体跟随鼠标移动:鼠标放置物体在场景中
3d·unity
极梦网络无忧3 小时前
Unity中IK动画与布偶死亡动画切换的实现
unity·游戏引擎·lucene
逐·風11 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
_oP_i13 小时前
Unity Addressables 系统处理 WebGL 打包本地资源的一种高效方式
unity·游戏引擎·webgl
Leoysq1 天前
【UGUI】实现点击注册按钮跳转游戏场景
游戏·unity·游戏引擎·ugui
_oP_i1 天前
unity中 骨骼、纹理和材质关系
unity·游戏引擎·材质
Padid2 天前
Unity SRP学习笔记(二)
笔记·学习·unity·游戏引擎·图形渲染·着色器
Tp_jh2 天前
推荐一款非常好用的C/C++在线编译器
linux·c语言·c++·ide·单片机·unity·云原生
dangoxiba2 天前
[Unity Demo]从零开始制作空洞骑士Hollow Knight第十八集补充:制作空洞骑士独有的EventSystem和InputModule
游戏·unity·c#·游戏引擎·playmaker