游戏开发中GC优化

什么是GC?为什么要优化 GC?

这里借鉴zblade 写的一篇文章《Unity 优化之GC——合理优化 Unity 的GC》来说明,这篇文章写得挺好,这里稍整理了一下。

GC (Garbage Collection)就是垃圾回收。前面在内存泄漏章节中对垃圾回收机制做过详细介绍,这里再简单介绍下。

在游戏运行的时候,数据主要存储在内存中,当游戏数据不再需要的时候,当前这部分内存就可以被回收,以便再次利用。

内存垃圾指当前废弃数据所占用的内存,GC是指将废弃的内存进行回收,再次使用的过程。

在进行GC操作时,首先会检查被GC记录的每个变量,确定变量是否处于孤岛状态,或引用是否处于激活状态,如果引用的变量不处于激活状态或孤岛状态,则标记为可回收,标记的变量会在回收程序中被移除,其所占的内存也会被回收。

执行垃圾回收程序是一个相当耗时的操作,对象越多,变量越多,检查的操作也会越多,耗时也更长。

下面先看看内存分配和申请系统内存是如何影响耗时的。

1)Unity3D内部有两个内存管理池:堆栈内存和堆内存。堆栈

(stack)内存主要用来存储较小的和短暂的数据,堆(heap)内存主要用来存储较大的和存储时间较长的数据。

2)Unity3D中的变量只会在堆栈内存或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么存储在堆内存上。

3)只要变量处于激活状态,其占用的内存就会被标记为使用状态,该部分的内存则处于被分配的状态。

4)一旦变量不再处于激活状态,其所占用的内存则不再需要,该部分内存可以被回收到内存池中再次使用,这样的操作就是内存回收。处于堆栈上的内存回收极其快速,处于堆内存上的垃圾并不是及时回收的,此时其对应的内存依然会被标记为使用状态。

5)GC主要是指堆上的内存分配和回收,Unity3D中会定时对堆内存进行GC操作。

6) Unity3D中的堆内存只会增加,不会减少,也就是当堆内存不足时,会向系统申请更多内存,但不会在空闲时还给系统,除非应用结束重新开始。

Unity3D这种向系统申请新内存的方式是比较耗时的。在我们平时的游戏项目中,常常由于堆内存的申请与回收导致存在大量的碎片内存,而当我们需要使用大块的内存时,这些碎片内存却无法使用,此时就会引起一个耗时的操作,即向系统申请更多的内存,申请的次数越多、越频繁,GC的耗时也就越多。这其中,GC接口被调用的频率是一个关键因素。

在游戏项目中,经常不断地调用GC接口,每次调用都会重新检查所有内存变量是否被激活,并且标记需要回收的内存块以便在后面回收,这样就在逻辑中产生了很多不必要的检查和并不集中的销毁,这会导致内存的命中率下降,最终浪费了宝贵的CPU资源。

其次,GC调用的时机也非常关键,GC操作需要消耗CPu,如果堆内存有大量的引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。

如果这时在游戏的关键时期,例如在游戏的战斗时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。

最后是堆内存的碎片化导致的性能问题。

当一个内存单元从堆内存上分配出来时,其大小取决于存储变量的大小。当该内存被回收到堆内存上的时候,堆内存有可能被分割成碎片化的单元。也就是说,堆内存总体可以使用的内存单元较大,但是单独的内存单元较小。在下次内存分配的时候不能找到合适大小的存储单元,就会触发GC操作或堆内存扩展操作。

堆内存碎片会产生两个结果,一个是游戏占用的内存会越来越大,另一个是GC操作会更加频繁地被触发。

主要有三个操作会触发GC操作。

1)在堆内存上进行内存分配操作,如果内存不够,就会触发GC操作来利用闲置的内存。

2)自动的触发GC操作,不同的平台运行的频率不一样。

3)被强制执行GC操作。

特别是在堆内存上进行内存分配而内存单元不足的时候,GC会被频繁地触发,这就意味着频繁地在堆内存上进行内存分配和回收操作会频繁触发GC操作。

在Unity3D中,值类型变量都是在堆栈上进行内存分配的,而引用类型和其他类型的变量则是在堆内存上分配的。所以值类型在结束其生命周期后会被立即收回,例如,函数中的临时变量Int,其对应函数调用完后会立即回收。而引用类型是在结束其生命周期后或被清除后,执行GC操作时才会被回收,例如,函数中的临时变量List,在函数调用结束后并不会立刻被回收,而是要等到下次执行GC操作时才被回收至堆内存中。

大体来说,可以通过以下三种方法来降低GC操作的影响。

1)减少GC的运行次数。

2)减少单次GC的运行时间。

3)将GC的运行时间延迟,避免在关键时刻触发,比如可以在加载场景的时候调用GC。

可以采用以下三种策略实现上述方法。

1)对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数,从而提高GC操作的运行效率。

2)降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说,更少地触发GC操作,同时也降低堆内存的碎片化。

3)可以试着分析GC操作和堆内存扩展的时间,使其按照可预测的顺序执行。当然,此操作的难度极大,但是会大大降低 GC的影响。

减少内存垃圾的方法有以下几种。

1)缓存变量,达到重复利用的目的,减少不必要的内存垃圾。比如,在Update或OnTriggerEnter 中使用MeshRenderer

meshrender=game0bject.GetComponent()来处理模型渲染类,就会造成不必要的内存垃圾。对此,可以在Start或 Awake中先将其缓存起来,然后在Update或 OnTriggerEnter中使用。这样已经缓存的变量内存一直存在,就不会被反复地销毁和分配。

2)减少逻辑调用。堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如,在每帧都调用的 Update()和 LateUpdate()函数里,如果有内存分配,就会放大内存垃圾。

一种有效的解决方法是,想方设法减少逻辑调用。可以利用时间因素或使用对比的方式将Update()和LateUpdate()中的逻辑调用减到最小。

3)清除链表。进行链表分配时清除链表,而不是不停地生成新的链表。

在堆内存上进行链表分配的时候,如果该链表需要多次反复的分配,则可以采用链表的Clear()函数来清空链表,从而替代反复多次地分配链表。

4)对象池。使用对象池技术保留废弃的内存变量,重复利用时不再需要重新分配内存,而是利用对象池内旧有的对象。

即便我们在代码中尽可能地减少堆内存的分配行为,如果游戏中有大量的对象需要产生和销毁,依然会造成频繁的GC操作。对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。

对象池已在游戏中广泛使用,特别是在游戏中需要频繁地创建和销毁相同的游戏对象的时候,例如,枪的子弹这种会频繁生成和销毁的对象。对象池能降低CPU消耗,减少执行GC操作的次数。

5)字符串。在C#中,字符串是引用类型变量而不是值类型变量,即使看起来它存储的是字符串的值。这就意味着字符串会生成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。

C#中的字符串是不可变更的,也就是说,其内部的值在创建后是不可变更的。每次在对字符串进行操作的时候(例如运用字符串的「加」操作),C#会新建一个字符串来存储新的字符串,这会使得旧的字符串被废弃,这样就会造成内存垃圾。

可以采用以下一些方法来降低字符串的影响。

1)减少创建不必要的字符串。如果一个字符串被多次利用,则可以创建并缓存该字符串。

例如,项目中常会将文字字符串存储在数据表中,然后由程序去读取数据表,进而将所有常用的字符串存储在内存里。这样操作后,成员们在调用字符串时就可以直接调用我们存储的字符串了,而不需要去新建一个字符串来操作。

2)减少不必要的字符串操作。例如,如果在Text 组件中,有一部分字符串需要经常改变,但是其他部分不会,则可以将其分为两个部分的组件,对于不变的部分设置为类似常量的字符串即可。

优化后,不再操作字符串,而是赋值给文字组件,虽然还是会分配字符串,但大小降低,从而减少了内存垃圾

3)使用StringBuilderClass()函数。如果需要实时地操作字符串,则可以采用String-BuilderClass()来代替,StringBuilder()专为不需要进行内存分配而设计,从而可减少字符串产生的内存垃圾。

不过此类方法还是要选择性地使用,因为此方法也会在执行

ToString()时重新分配一个字符串,因此只能省去一些中间状态的内存分配,无法彻底解决字符串的内存分配问题。所以只能在小范围特定区域使用,比如,在特别频繁地操作字符串的情况下,不断增加、改变字符串的地方。游戏中若有对字符串逐步显示的需求,像写文章一样一个个或者一片片地显示,而不是全部一下子显示,使用StringBuilder就能减少字符串分配。

记住,它只能减少字符串中间状态的分配,不能免除字符串内存分配。

4)移除游戏中的Debug.Log()等LOG日志函数的代码。对于游戏来说,LOG日志其实很消耗资源,特别是在战斗激烈的情况下,本来就宝贵的CPU大量消耗在了LOG日志上不划算,它不但分配了字符串,还不间断地往文件里写数据。因此我们在游戏开发时,特别是在发布时要尽量去除LOG日志。因为它不但影响内存垃圾的分配,还会浪费CPU空间。

5)协程。调用StartCoroutine()会产生少量的内存垃圾,因为

Unity3D会生成实体来管理协程。任何在游戏关键时刻调用的协程都要特别注意,尤其是包含延迟回调的协程。

6)Foreach 循环。在 Unity3D 5.5以前的版本中,foreach 的迭代也会生成内存垃圾,主要来自其后的迭代器。foreach迭代每次都会在堆内存上产生一个System. 0bject()用来实现迭代循环操作。但是现在的版本都已经使用了新的.NET文件,不会再有这个问题。

7))函数引用。函数的引用,无论是指向匿名函数还是显式函数(在Unity3D中这两种函数都是引用类型变量),它们的分配都会在堆内存上进行。

要特别注意的是System.Action()匿名函数,它在项目中使用得特别频繁,此函数调用完后会增加内存的使用和堆内存的分配。因为它本身就是一个指向函数地址的指针变量,所以它会在堆内存中分配,并且在用完后被抛弃,形成垃圾。

具体函数的引用和终止均取决于操作平台和编译器设置,但是,如果想减少GC,最好减少匿名函数的使用。

8)LINQ和常量表达式。由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。

如果可以,请尽量使用其他方式代替LINQ,这样就可减少LINQ产生的内存垃圾。

9)主动调用GC操作。如果我们知道堆内存在被分配后并没有被使用,那么我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换或读进度条的时候),可以主动地调用GC操作System. GC.Collect()。因为通过主动调用,可以主动驱使GC操作来回收堆内存,从而将体验不好的时间段放在察觉不到的时候,或者不会被明显察觉的时候。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值