关于GC算法,我在另一篇文章.NET CLR之垃圾回收(GC)已经简单讲过了包括标记清除、引用计数、复制集合、标记压缩、分代收集等算法,本篇文章就不再赘述了。

此文章为网上转载收集而成,非完全原创的文章,在文章的最后提供了这些文章的来源供大家交流学习。

机制介绍

  • Untiy使用的GC机制是通过贝姆垃圾收集器(Boehm GC)来实现的,是应用在C/C++语言上的一个保守的垃圾回收器,同时也适用于其它执行环境的各类编程语言,比如我们使用的基于mono实现的C#。
  • Boehm GC使用标记清除(Mark-Sweep)来实现,它会在需要进行GC时占用主线程这会导致帧数的突然下降,产生卡顿。
  • 传统的UnityGC使用的是非分代非压缩的机制,所以卡顿现象相对较轻,但是对内存利用率进一步下降了,同时也会有Mark-Sweep共有的碎片化问题,造成内存利用率的进一步降低。但是在Unity19版本后Unity推出了增量式GC(incremental garbage collection),这是一种分代非压缩的GC机制,后面再GC优化中会提及。
  • 所以我们需要慎重地处理对象的创建(内存请求),还有释放(使用GC管理内存是没有主动释放内存的接口的,但是我们可以通过消除对某个对象的引用来做到这一点)。
  • 此外,Unity的代码分为托管与非托管,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的热点一般都是写了一些会产生大量垃圾的代码。
  • 下列介绍了几种降低GC的方法:
    • 使用对象池:
      • 对象池,顾名思义就是一定数量的已经创建好的对象(Object)的集合。
      • 当需要创建对象时,先在池子中获取,如果池子中没有符合条件的对象,再进行创建新对象,同样,当对象需要销毁时,不做真正的销毁,而是将其setActive(false),并存入池子中。这样就避免了大量对象的创建。
    • 在大量使用字符串时使用StringBuilder代替string的使用:
      • 对于执行大量字符串操作的例程 (例如在循环中多次修改字符串的应用程序) ,重复修改字符串可能会显著降低性能。
      • 替代方法是使用 StringBuilder ,它是一个可变字符串类。
      • 可变性是指在创建类的实例后,可以通过追加、移除、替换或插入字符来修改它。
      • StringBuilder对象维护缓冲区以容纳对字符串的扩展。 如果空间可用,则会将新数据追加到缓冲区;否则,将分配一个新的更大的缓冲区,将原始缓冲区中的数据复制到新缓冲区,然后将新数据追加到新缓冲区。
    • 减少装箱操作:
      • 装箱指的是值类型转换成引用类型,拆箱则是反过来。
      • 装箱和取消装箱过程需要进行大量的计算, 对值类型进行装箱时,必须创建一个全新的对象。,这可能比一些简单的引用赋值耗时最多能长 20 倍。,取消装箱的过程所需时间也可达赋值操作的四倍,所以尽量避免装箱和拆箱。
    • 用for代替foreach:
      • Unity5.5版本以前 foreach 会导致装箱,这之后的版本修复了这个问题。
      • 但是 foreach相比起直接使用下标遍历还是要慢(因为有一些额外的方法调用),不过这就和GC没啥关系了。
    • 空数组:
      • 空数组(长度为0的数组)的创建事实上也会导致堆内存的分配。
      • 所以应该将其提前创建出来并复用。
    • 关键帧限制协程调用,因为startcoroutine()实际上是new一个对象
    • struct中不要有引用类型变量
      • struct是值类型,而如果struct中有引用类型的变量
      • GC会检查整个struct,检查不必检查的对象增加了GC
    • 主动在合适的时候调用GC,比如加载场景的时候
  • 上述问题的原因都是类似的,即大量地创建了短暂使用的对象(垃圾),基本上都可以通过将会反复使用的对象创建为非局部变量来解决(或者更进一步,使用所谓对象池的技术,基本原理是一样的)。有些地方就只能通过避免会造成垃圾产生的接口来解决。总之优化GC,核心在于消灭垃圾。
  • 同时,也有一些特殊的技巧来减少GC带来的卡顿:
    • 关闭GC:可以把需要的内存先全部创建完,然后关掉GC,不过感觉这种方式应用场景太有限。
    • 主动定时GC:游戏的卡顿来自与不稳定的帧数变化(稳定的低帧数和不稳定的高帧数前者可以带来更平滑的体验),所以可以按一定间隔主动地调用 System.GC.Collect 进行GC,这样就不会有剧烈的毛刺产生,当然这个间隔不能太小,否则就和不主动调用区别不大,但也不能太小,否则会对帧数造成明显影响,具体数值的确定还是很难的。
    • 主动扩大托管堆:
      • Mono的GC会尽量避免内存的扩展,所以说它对判断 需要进行GC 了的阈值比较低,可能已分配内存达到当前GC管理内存的70%~80%就会进行GC了,如果GC的持有内存足够大的话,就会减少GC的触发。
      • 不过实际开发中还有贴图之类的内存大户,留给GC的可以内存实在不多,盲目请求过大的内存可能会被操作系统无情干掉,要慎重。而且因为托管堆占用的地址空间并不会归还,所以请求太大的托管堆会导致内存的浪费。。这种做法算是空间换时间。
  • Unity19版本新增了增量垃圾回收(Incremental Garbage Collection)
    • 虽然使用的仍然是原本的Boehm GC,但将原本单帧回收拆分成多帧处理,从而降低卡顿感,这是一种非分代式的
    • 在Unity19版本后的Edit->Project Settings->Player->Configuration->Use incremental GC可以开启选项来开启这个功能。
    • 增量式GC目前还是抢先体验版本,因为它事实上还是存在一些问题:它的基本实现原理还是Mark-Sweep,但是在两次增量式GC之间,对象的引用可能会发生变化,导致前一次GC的标记失效需要重新进行遍历标记,最糟的情况会退化为普通的非分代GC甚至更糟,因为前面的工作全白费了

资料参考/拓展阅读

https://zhuanlan.zhihu.com/p/265217138(Unity GC 学习总结)

https://www.lengyueling.cn/archives/98(.NET CLR之垃圾回收(GC))

https://blog.csdn.net/qq_43533956/article/details/124237546(unity GC机制简单介绍)

https://docs.unity3d.com/Manual/overview-of-dot-net-in-unity.html(Unity 中的 .NET 概述)

https://baike.baidu.com/item/%E8%B4%9D%E5%A7%86%E5%9E%83%E5%9C%BE%E6%94%B6%E9%9B%86%E5%99%A8(百度百科:贝姆垃圾收集器)

https://en.wikipedia.org/wiki/Boehm_garbage_collector(维基百科:Boehm garbage collector)

https://blog.csdn.net/qq_23420435/article/details/110109812(浅谈UNITY中的优化)

https://www.cnblogs.com/zblade/p/6445578.html(Unity优化之GC——合理优化Unity的GC)

https://blog.csdn.net/qq_38234381/article/details/115270672(托管代码和非托管代码)

https://blog.csdn.net/iceSony/article/details/86306080(U3D性能优化教程——Mono内存篇增量垃圾回收)

https://zhuanlan.zhihu.com/p/352725048(【性能优化】内存管理和GC优化)