目录
一、GC简介
1. 堆内存分配和回收机制
2. 垃圾回收时的操作
3. 何时会触发垃圾回收?
4. GC操作带来的问题
二、 GC优化
1.降低GC影响的方法
2.减少内存垃圾的数量
3.造成不必要的堆内存分配的因素
《1》字符串
《2》容器类
《3》 匿名方法(Lambda)和闭包
《4》Unity函数调用
《5》装箱操作
《6》协程
《7》foreach循环
《8》函数引用
《9》 UGUI的重构
《10》语言集成查询LINQ和常量表达式
4.重构代码来减小GC的影响
5. 定期执行GC操作
三、检测堆内存分配:Unity profiler window
一、GC简介
在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使用的过程。
Unity中将垃圾回收当作内存管理的一部分,如果游戏中废弃数据占用内存较大,则游戏的性能会受到极大影响,此时垃圾回收会成为游戏性能的一大障碍点。
1. Unity内部有两个内存管理池:堆内存和栈内存
堆内存(heap) 主要用来存储较大的和存储时间较长的数据,主要是负责程序中的对象和数据。
栈内存(stack) 主要用来存储较小的和短暂的数据,主要是负责运行时的代码,例如函数调用。
2. 哪些数据在堆和栈上?
栈->值类型: bool byte char decimal double enum float int long sbyte short struct uint ulong ushort
堆->非空引用类型: class interface delegate object string,同时包含:
1) 值类型数组
2) 装箱的值类型
3. Unity托管堆简介
托管堆是由项目的脚本运行时(Scripting Runtime)——Mono或者IL2CPP内存管理器管理的一个内存片段:底层都是在C++分配内存。
(1) Unity Mono内存是只升不降,即使用后,哪怕空闲再多,也不会还给系统
(2) Il2cpp,则与一般的C++内存释放一样,释放的内存都会还给系统
4.Unity的GC机制
使用了Boehm GC算法(可以参考:https://en.wikipedia.org/wiki/Boehm_garbage_collector),是非分代(non-generational)和非压缩(non-compacting)的。
1) "非分代"是指GC执行清理操作时,必须遍历整个内存,去标记哪些没有被引用并且删除,随着内存的增长,它的性能就会降低。 目前2019版本的unity在实验分代GC算法
2) “非压缩”意味着内存中的对象不会被重新定位,去减小对象之间的内存空隙
1. 堆内存分配和回收机制
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下几步:
1)首先,unity检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应大小的内存单元;
2)如果没有足够的存储单元,unity会触发垃圾回收来释放不再被使用的堆内存。这步操作是一步缓慢的操作,如果垃圾回收后有足够大小的内存单元,则进行内存分配。
3)如果垃圾回收后并没有足够的内存单元,则unity会扩展堆内存的大小,这步操作会很缓慢,然后分配对应大小的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数。
2. 垃圾回收时的操作
当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
1)GC会检查堆内存上的每个存储变量;
2)对每个变量会检测其引用是否处于激活状态;
3)如果变量的引用不再处于激活状态,则会被标记为可回收;
4)被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
注意:包含引用类型的结构体:struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct,大大增加GC工作量。
3. 何时会触发垃圾回收?
主要有三个操作会触发垃圾回收:
1) 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
2) GC会自动的触发,不同平台运行频率不一样;
3) GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
4. GC操作带来的问题
《1》.GC操作会需要大量的时间来运行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。
《2》GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。
《3》另外一个GC带来的问题是堆内存的碎片划。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这也会触发GC操作或者堆内存扩展操作。
堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。
二、 GC优化
1.降低GC影响的方法
《1》 减少GC的运行次数:减少托管堆内存的分配频率
《2》 减少单次GC运行的时间:涉及到对游戏的重构,减少变量和引用的分配,更少的引用会减少GC操作中的检测个数从而提高GC的运行效率
《3》 主动GC:将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载/切换的时候、关闭UI的时候lua调用GC等。
基于以上方法,可以采用三种策略:
(1) 减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。
(2) 降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化。
(3) 可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。这样操作的难度极大,但是这会大大降低GC的影响。
2.减少内存垃圾的数量
《1》缓存
如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用。
《2》不要在频繁调用的函数中反复进行堆内存分配
在MonoBehaviour中,如果我们需要进行堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如Update()和LateUpdate()函数这种每帧都调用的函数,这会造成大量的内存垃圾。我们可以考虑在Start()或者Awake()函数中进行内存分配,这样可以减少内存垃圾。或者在Update中采用计时器,特别是在运行有规律但是不需要每帧都运行的代码中。
《3》清除链表
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。
void Update()
{
List myList = new List();
PopulateList(myList);
}
===》
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
《4》对象池
即便我们在代码中尽可能地减少堆内存的分配行为,但是如果游戏有大量的对象需要产生和销毁依然会造成GC。对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹这种会频繁生成和销毁的对象。
3.造成不必要的堆内存分配的因素
《1》字符串
字符串是不可变的引用类型变量,每次对字符串进行一些拆分和拼接操作时,都会产生新的字符串
减少字符串影响的方法:
(1) 减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
(2) 减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可
(3) 字串符拼接采用 StringBuilder,尽量避免使用 + 操作符 和 strting.Format方法
,从而减少字符串产生的内存垃圾。
(4) 移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。
(5) 按时间刷新的字符串,例如:能按分钟更新,就不要按秒刷新字串符
(6) 使用string.Intern来减少字符串数量达到优化内存的效果,主要是针对固定不变的字符串,例如策划配置的文本数据
(7) 避免装箱,例如 newStr = "text" + 1 -> "text" + 1.ToString() 后者是采用非托管的方法直接操作内存完成
(8) 值类型转换字符串务必采用ToString()方法
《2》容器类
(1) 容器大小确定时,初始化时就直接设置目标大小,例如 项目中文本字符串字典初始化
(2) List集合类
1) 尽量重用,不要临时分配,例如:目前list类使用最频繁,尽量临时分配时采用 ListPool<T>.Get , 使用完再ListPool<T>.Release
2) 查找list中对象时,避免直接使用Find, FindAll方法,建议采用for循环查找方式丢入ListPool中
3) FindAll方法中注意千万不要有GC分配的操作
(3) 字典和枚举
将枚举作为字段的Key,会引起装箱操作,有额外的临时内存分配,因为内部是采用Object.GetHashCode(Object) 来获取哈希值来作为Key值,只要是涉及到Key操作的都会引起装箱,例如Add,TryGetValue, ContainKey, Remove等等
《3》 匿名方法(Lambda)和闭包
(1) 在C#中所有方法的引用都是引用类型,都会被分配到堆中。把一个方法作为参数传递时,都会产生临时的内存分配,不管传递的是匿名方法还是已经定义的方法。
(2) 如果一个匿名函数引用到外部变量,则会形成一个闭包,C#为了实现这一点会生成一个匿名类(记住,类都是引用类型)来保存用到的外部变量,所以当调用这个闭包时,首先会实例化一个副本,同时会采用外部变量实际值来初始化这个副本,最终导致会在堆上分配内存。
优化方法
1) 尽量避免将方法作为参数传递,如果无法避免,优先采用匿名方法
2)尽量避免使用闭包,如果无法避免,绝对不能每帧执行的函数中使用闭包
《4》Unity函数调用
在代码编程中,当我们调用不是我们自己编写的代码,无论是Unity自带的还是插件中的,我们都可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,我们在使用的时候需要注意它的使用。
这儿没有明确的列表指出哪些函数需要注意,每个函数在不同的情况下有不同的使用,所以最好仔细地分析游戏,定位内存垃圾的产生原因以及如何解决问题。有时候缓存是一种有效的办法,有时候尽量降低函数的调用频率是一种办法,有时候用其他函数来重构代码是一种办法。
在Unity中如果函数需要返回一个数组,则一个新的数组会被分配出来用作结果返回,这不容易被注意到,特别是如果该函数含有迭代器,下面的代码中对于每个迭代器都会产生一个新的数组:
void ExampleFunction()
{
for(int i=0; i < myMesh.normals.Length;i++)
{
Vector3 normal = myMesh.normals[i];
}
}
//对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组
//就可以实现相同的功能,从而减少内存垃圾的产生:
==》优化
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for(int i=0; i < meshNormals.Length;i++)
{
Vector3 normal = meshNormals[i];
}
}
此外另外的一个函数调用GameObject.name 或者 GameObject.tag也会造成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会造成不必要的内存垃圾,对结果进行缓存是一种有效的办法,但是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,可以采用GameObject.CompareTag()来替代。
在下面的代码中,调用gameobject.tag就会产生内存垃圾:
private string playerTag="Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag == playerTag;
}
// 采用GameObject.CompareTag()可以避免内存垃圾的产生:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
不只是GameObject.CompareTag,unity中许多其他的函数也可以避免内存垃圾的生成。比如我们可以用Input.GetTouch()和Input.touchCount()来代替Input.touches,或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。
《5》装箱操作
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。如下面代码所示:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price:{0} gold",cost);
}
在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。
《6》协程
调用 StartCoroutine()会进行内存分配,产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。
yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
yield return 0;
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
yield return null;
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete)
{
yield return new WaitForSeconds(1f);
}
我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
yield return delay;
}
如果游戏中的协程产生了内存垃圾,我们可以考虑用其他的方式来替代协程。重构代码对于游戏而言十分复杂,但是对于协程而言我们也可以注意一些常见的操作,比如如果用协程来管理时间,最好在update函数中保持对时间的记录。如果用协程来控制游戏中事件的发生顺序,最好对于不同事件之间有一定的信息通信的方式。对于协程而言没有适合各种情况的方法,只有根据具体的代码来选择最好的解决办法。
《7》foreach循环
在unity5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操作。每次在foreach迭代的时候,都会在堆内存上生产一个System.Object用来实现迭代循环操作。在unity5.5中解决了这个问题,比如,在unity5.5以前的版本中,用foreach实现循环:
void ExampleFunction(List listOfInts)
{
foreach(int currentInt in listOfInts)
{
DoSomething(currentInt);
}
}
如果游戏工程不能升级到5.5以上,则可以用for或者while循环来解决这个问题,所以可以改为
void ExampleFunction(List listOfInts)
{
for(int i=0; i < listOfInts.Count; i++)
{
int currentInt = listOfInts[i];
DoSomething(currentInt);
}
}
《8》函数引用
函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增加内存的使用和堆内存的分配。具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。
返回数组的函数调用或访问器,以及返回字符串的调用都会产生临时内存分配,可以尝试其他替代方案 例如
1) Mesh.vertices Mesh.normals 等等,如果进行频繁操作,可以采用缓存数据的方式,而不是函数调用获取
2) GameObject.name,GameObject.tag 后者的比较可以采用GameObject.CompareTag()来替代
3) Input.touches 可以采用Input.GetTouch(),Input.touchCount替代
《9》 UGUI的重构
UGUI的重构会引起临时的内存分配,重点减少触发重构因素发生频率
重构因素(顶点数据变化):
1) 激活图形(Graphic)组件
2) Graphic组件的大小,父节点变化
3) 任何颜色相关修改,例如:Image color, shadow effectColor等等
4) Image类型和参数修改,例如:Simple->Filled,fillCenter变化, fillAmount变化,sprite,uvRect变化等等
5) RawImage类型和参数修改,例如:texture,uvRect 变化
6) Text类型的字符串内容,字体,字体大小和对齐方式等修改,例如:text 字符串内容变化,supportRichText,resizeTextForBestFit,resizeTextMinSize,alignment,fontSize, horizontalOverflow,verticalOverflow,fontStyle变化等等
7) 其他:
shadow组件 useGraphicAlpha effectDistance,effectColor变化
- 删除不需要的顶点数据,例如,修改UGUI VertexHelper类
- 当前项目我们只保留:顶点坐标,UV坐标,顶点Color和索引数据
- 法线,切线,UV1-3数据使用不到,直接注释掉了对这些数据的填充代码
《10》语言集成查询LINQ和常量表达式
由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
LINQ 并不是所有的平台都完整的支持,例如IOS,并不支持部分操作,项目中是禁止使用。
4.重构代码来减小GC的影响
即使我们减小了代码在堆内存上的分配操作,代码也会增加GC的工作量。最常见的增加GC工作量的方式是让其检查它不必检查的对象。
《1》struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct。如果这样的操作很多,那么GC的工作量就大大增加。
在下面的例子中struct包含一个string,那么整个struct都必须在GC中被检查:
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
我们可以将该struct拆分为多个数组的形式,从而减小GC的工作量:
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
《2》另外一种在代码中增加GC工作量的方式是保存不必要的Object引用,在进行GC操作的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工作量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候会去检查下一个对象框:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
通过重构代码,我们可以返回下一个对话框实体的标记,而不是对话框实体本身,这样就没有多余的object引用,从而减少GC的工作量:
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
当然这个例子本身并不重要,但是如果我们的游戏中包含大量的含有对其他Object引用的object,我们可以考虑通过重构代码来减少GC的工作量。
5. 定期执行GC操作
主动调用GC操作
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:
System.GC.Collect() //调用GC
通过主动的调用,我们可以主动驱使GC操作来回收堆内存。
三、检测堆内存分配:Unity profiler window
我们可以用unity->window->profiler窗口来检测堆内存分配以及一些其他的性能数据。
当选中某一帧的时候,下方会出现详细调用