ainatipen 发表于 2022-9-7 12:32

Unity GC 学习总结

什么是GC

    总所周知,内存是程序运行时所需要的重要资源,在程序运行时往往需要内存来临时存储各种数据,但是操作系统提供给进程的堆内存(注意是堆内存,栈上的内存会随函数调用自动被回收,下文提及的都是指堆内存)是有限的,所以我们需要对这有限的资源进行管理,在一些旧的编程语言(比如C/C++)中,需要开发者自己通过malloc和free之类的内存申请、释放接口来管理内存,想要保证正确地管理内存需要开发者花费相当的精力,为了解决这个问题,出现了很多自动内存管理技术,GC(Garbage Collection)就是其中一种。
    在代码中,我们会反复地申请内存来完成各种计算,等到确认内存不需要使用时,我们就会归还这部分内存,从而可以将其用于其他地方。GC所做的事情,就是自动确定那些不需要的内存,或者说 Garbage ,然后将其归还。这样开发者就无需关心内存的管理。
GC的实现

    实现GC的策略有很多种,其中最常见一种就是 Tracing garbage collection,或者叫 Mark-Sweep,这种算法会通过一个 root Object,遍历这个该对象引用的变量,并且标记,递归这个过程,这样就确定了所有reachable的对象,剩下的对象即视为garbage。
    另一种常见的策略还有引用计数(Reference counting),它是通过为每个对象维护一个引用计数,这代表当前对该对象的引用数目,当引用为0,即代表该对象为 Garage。引用技术有如下缺点

[*]循环引用问题
[*]保存计数带来的空间开销
[*]修改引用数目带来的速度开销以及原子性要求
[*]非实时(一个引用的变化可能递归得导致一系列引用修改,内存释放)
    有很多算法可以一定程度解决上述问题,顺便一提,C++使用的智能指针即是基于引用计数实现的,COM对象也使用了引用计数来管理。
GC的优缺点

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

[*]内存泄漏
[*]内存碎片
[*]访问已经释放的指针
[*]反复释放指针
缺点
    那么代价是什么呢?享受 GC 带来的便利,意味你必须承受 GC 开销对性能的影响,眼睁睁地看着它费老大劲去处理一个你一眼看出来的 Garbage 。比如
void func()
{
    A *p = new A();
   
    p->f();

   // delete p;
}
(其实上面这个例子中A 可以被 Escape Analysis 转换为栈上的内存创建,但这里无视哈,只是举简单例子)
很多地方自己进行内存管理性能会比 GC好(当然这个得看人还有代码逻辑)。有很多关于GC和手动内存管理的比较,甚至还有这样黑的。大部分 GC 实现做的事情不只是确定Garage 然后释放,往往还会做一系列事情来消除内存碎片,这往往很费时,这对实时性要求要的系统是致命的。不过也有一些GC会不断运行在另外的线程来缓解这个问题,消除了运行时的毛刺,但是客观来讲还是占用了系统资源。而且在对象多的情况下还是会有问题(不过讲道理,对象多的情况,手动管理要写到比GC有性能优势也不一定容易)。

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.字符串
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),上述代码即可以优化成
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;
      }
    }
}
实际上还可以通过将Text拆成两个来优化。
2.闭包
    闭包的使用也需要慎重,因为闭包除了函数指针还会将捕获的变量一起包装起来创建到堆上,相当于 new 了个对象,性能敏感部分代码还是要慎重使用。可以通过将匿名函数改为成员函数,捕获变量改为成员变量一定程度上缓解,不过还是会有影响。
3.装箱
    还有要小心装箱,这也会隐式地导致对象的创建。从而产生意想不到的垃圾。用枚举值当字典的key的时,各种字典操作会调用 Object.getHashCode 获取哈希值 ,该方法会导致装箱。Unity5.5版本以前 foreach 会导致装箱,这之后的版本修复了这个问题,但是 foreach相比起直接使用下标遍历还是要慢(因为有一些额外的方法调用),不过这就和GC没啥关系了。
4.返回数组的Unity API
    应该是为了防止意外修改内部值,Unity API返回数组对象时返回的是一份拷贝。类似下面的代码
for(int i = 0; i < mesh.vertices.Length; i++)

{

    float x, y, z;

    x = mesh.vertices.x;

    y = mesh.vertices.y;

    z = mesh.vertices.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的触发,可以通过类似下面的代码
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
      var tmp = new System.Object;
      
      // 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 = new byte;
      
      // 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(其实更糟,因为前面的工作全白费了)。比如这样的代码
void Update()
    {
      if (Time.frameCount % 2 == 0)
      {
            s = "A";
      }
      else
      {
            s = "B";
      }
    }
"A" 和"B" 在垃圾与不是垃圾之间反复横跳(字符串常量的引用可能不太一样,但这里是为了表达对象引用情况反复变化的意思)。而且增量式GC还需要额外的开销来确定对象的引用是否变化,这开销也不可忽视,实际项目看对毛刺的容忍程度来确定要不要使用增量式GC,而且要好好地做Profiler,很容易一不小心就负优化了。

参考链接
页: [1]
查看完整版本: Unity GC 学习总结