文章目录
测试的 Unity 版本
- 测试的 Unity 版本
- FAQ-测试中常见的 GC 问题
- Demo
- Check_NewDotNetManagedObj
- Check_GetComponentAndTryGetComponent
- Check_GetComponentsInChildren
- Check_ReturnRefOrValue
- Check_ToString_Concat_Trim
- Cehck_EnumToString
- Check_StringEquals
- Check_StringToArg
- Check_NewString
- Check_ToLowerString
- Check_ReplaceString
- Check_GameObject_get_name_or_get_tag
- Check_GetTransform
- Check_BoxingOrUnBoxing
- Check_Enumerator
- Check_EnumeratorGCSize
- Check_Task_Delay_TimerMgr
- Check_PassCallbackWhichHasGenericType
- Check_List
- Check_Using
- Check_ReuseCoroutinue
- Check_EnumGetValues
- Check_Lambda
- Check_LayerMaskGetMask
- Check_ParamsToArg
- Check_UGUI_TextToggleOrUpdate
- Check_UGUI_ImageToggle
- Check_UGUI_RawImageToggle
- Check_MeshRenderToggle
- 什么时候适合 删除缓存 并 GC.Collect?
- Project
- References
- Incremental garbage collection
Unity 2019 3.8f1
FAQ-测试中常见的 GC 问题- 什么叫 GC? GC 是 Garbage Collector 的缩写,意思是:垃圾收集器
- 为何要有 GC? 因为以往我们在写C/C++ 等相对低级的语言中,程序员可以只对堆内存的分配和释放 但是由于内存对象的管理异常复杂,特别是业务逻辑繁杂的更加难以管理,时不时就会出现一个:“0xXXXXXX 内存不可访问”、“Out of Memory”,之类的错误提示
- 所以在 C#.Net 中,CLR(Common Language Runtime)底层就封装了对托管的内存对象的管理,免得出现类似上面的错误提示
- 上面说了有托管对象,那么对应的就有非托管对象不是 CLR 中管理的,这些对象需要手动释放
- 另外,Unity 在 IL2CPP 后,会针对 GC 下的签名内容都替换成 Unity 内置的 C++ 版 GC,与 C#.NET 有一些差异,但是触发 GC.Collect 的时机都是差不多的
- GC.Collect 什么时候触发? CLR 底层会有检测当前托管堆内存可用大小是否小于下限,当到达下限,就会触发 GC.Collect,也就是不断 GC.Alloc 分配了托管堆内存,而导致托管堆可用大小变小。CLR 库中的类对象基本都是托管的,所以我们在各种 new、CreateActivor() 之类的接口来创建对象时,底层都会调用 GC.Alloc
- 为何要避免 GC(就上面说的GC.Collect)? 因为 GC 需要底层需要处理的消耗比较大,具体可自行百度,这个与 GC 的检测机制有关,Unity 还可以在 PlayerSetting 中设置 GC 模式,是:Generation 还是 Increatement 方式
- 为何要控制 GC 频率? 上面说了,GC 会导致 CPU 消耗大,如果托管对象多而小,导致的一些内存碎片过细,会更加消耗 CPU 因此,我们尽可能的将托管对象缓存起来,反复使用,这样在运行过程中就可以减少的 GC.Alloc 操作
- 如何实现 0 GC? 如果你想这么做,就不要用 Unity,用 UE(而且,我相信 UE 应该也是有封装类似的内存管理系统的,这样同样也会有 GC 问题,只不过 C++ 在内存操作方面可以比 C#.NET 更为灵活的底层 API,你可以跳过 GC 来自己分配内存),就是用那些没有内置:内存分配统计、内存自动回收,运行库的语言,因为 .net 的底层很多都有 GC 问题,特别是字符串处理,因此很难做到 0 GC
下面时自行测试的内容,想要了解 .net、Unity 中其他 API 是否有 GC,可以留言告知我一下,我会测试后,更新到 blog
下面的测试,都使用 Unity 的 Profiler
如果你想快速入门 Unity Profiler,其实可以自己打开一下这个 Profiler Window 就打开知道怎么用了 如果还是看不懂,就看官方教程也行(但是是英文的):Fixing Performance Problems - 2019.3
Demo Check_NewDotNetManagedObj public class DotNetManagedObj
{
public int a1, a2, a3, a4, a5, a6;
}
private DotNetManagedObj cached_obj;
private void Check_NewDotNetManagedObj()
{
Profiler.BeginSample("Check_NewDotNetManagedObj");
Profiler.BeginSample("1");
new DotNetManagedObj();
Profiler.EndSample();
Profiler.BeginSample("2");
if (cached_obj == null) cached_obj = new DotNetManagedObj();
Profiler.EndSample();
Profiler.EndSample();
// Proflie 结果
// 1 方式 直接 new .net 的托管对象,都会有 GC.Alloc,当不断的 GC.Alloc,就会让 ManagedHeap.UnusedSize 越来越小,小到一定程度,就会触发 GC.Collect 回收垃圾
// 而 GC.Collect 是很耗時的,所以尽力避免不必要的 GC.Alloc
// 2 方式是缓存了对象,因此只有第一次 new 有 Alloc,后续复用改对像就没有 Alloc 了
}
private void Check_GetComponentAndTryGetComponent()
{
Profiler.BeginSample("Check_GetComponentAndTryGetComponent");
Profiler.BeginSample("1");
{
var checker = go.GetComponent();
}
Profiler.EndSample();
Profiler.BeginSample("2");
{
go.TryGetComponent(out CheckGC checker);
}
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 1 方式[没] GC
// 2 方式[没] GC
// 两种方式都没有,但是再以前有人发现 go.GetComponent 时,在 Editor 下才有 GC,真机上不会有
// 具体可参考:https://zhuanlan.zhihu.com/p/26763624
// 但现在我再 Editor 下测试也是没有的,有可能 Unity 做了优化,我的 Unity 版本是 2019.3.8f1
// 但是 GetComponent 会消耗 CPU,因为原理上是 for 遍历 GameObject 下的所有 MonoBehaviour 组件
// 建议尽可能将 GetComponent 的对象缓存起来,便于后续直接访问
}
private List mesh_render_list = new List();
public static class ListPoolT
{
private static Stack pool = new Stack();
public static List FromPool()
{
return pool.Count > 0 ? pool.Pop() : new List();
}
public static void ToPool(List list)
{
list.Clear();
pool.Push(list);
}
}
private void Check_GetComponentsInChildren()
{
Profiler.BeginSample("Check_GetComponentsInChildren");
Profiler.BeginSample("1");
foreach (var item in go.GetComponentsInChildren())
{
}
Profiler.EndSample();
Profiler.BeginSample("2");
mesh_render_list.Clear();
go.GetComponentsInChildren(false, mesh_render_list);
foreach (var item in mesh_render_list)
{
}
Profiler.EndSample();
Profiler.BeginSample("3");
var list = ListPoolT.FromPool();
go.GetComponentsInChildren(false, list);
foreach (var item in list)
{
}
ListPoolT.ToPool(list);
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 1 方式[有] GC
// 2 方式[没] GC
// 3 方式[没] GC(本质上和 2 方式一样,只不过,这里我将一些简单的封装方式给大家参考)
}
private TestingCls ReturnCls()
{
return new TestingCls(); // 返回的是托管堆中的内存
}
private TestingStruct ReturnStruct()
{
return new TestingStruct(); // new 了一个执行栈上的内存
}
private void ReturnClsCached(TestingCls ret)
{
//ret.xx = xxx; // 使用的是缓存的对象来存放数据,避免重复的创建托管堆内存对象
}
private TestingCls testing_cls_cached = new TestingCls();
private void Check_ReturnRefOrValue()
{
Profiler.BeginSample("Check_ReturnRefOrValue");
Profiler.BeginSample("1");
ReturnCls();
Profiler.EndSample();
Profiler.BeginSample("2");
ReturnStruct();
Profiler.EndSample();
Profiler.BeginSample("3");
ReturnClsCached(testing_cls_cached);
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 1 方式返回的是引用类型对象,创建对象的内存是在托管堆的,所以有 GC
// 2 方式返回的是值类型的对象,创建对象的内存是再线程执行栈中的数据,再函数声明时入栈,返回时出栈,所以没有托管退管理,也就没有 GC
// 3 方式重复利用缓存对象来作返回数据对象的载体,所以没有 GC
// 其实你也可以理解执行栈也算是一个简单的 GC,只不过,这个 GC 系统的申请与回收的性能极高,因为管理方式很简单,Push, Pop 的方式
}
private StringBuilder sb = new StringBuilder();
private string testing_str = " abc ";
private void Check_ToString_Concat_Trim()
{
Profiler.BeginSample("Check_ToString_Concat_Trim");
Profiler.BeginSample("Check_ToString");
{
const int LOOP_MAX = 100;
Profiler.BeginSample("1");
{
for (int i = 0; i { }); // no gc,但是匿名函数,且非闭包就没有 GC
Profiler.EndSample();
Profiler.BeginSample("555.666");
Action act = (MyType inst) => { }; // no gc,将匿名函数存于一个变量,也没有 GC
TestDefaultParams4(act);
Profiler.EndSample();
Profiler.BeginSample("555.777");
act = Testing; // have gc, 本质上和 555.444 是一样的,只不过尝试将一个预定义的方法指向一个临时的 act 方法变量,结果与 555.444 是一样有 GC 的
TestDefaultParams4(act);
Profiler.EndSample();
Profiler.BeginSample("555.888");
var temp_var = 1; // 让下面行数变成闭包:在匿名函数使用临时变量即可
Action act1 = (MyType inst) => { temp_var++; }; // have gc,一旦函数变成闭包函数,就会有 GC,因此在频繁调用的地方尽量不使用闭包
// 参考:unity 官方手册说明:https://docs.unity3d.com/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html
TestDefaultParams4(act); // have gc
Profiler.EndSample();
Profiler.BeginSample("666");
AddTimer(timer_mgr_same_inst1.SameMethod); // have gc
Profiler.EndSample();
Profiler.BeginSample("777");
AddTimer(Testing); // have gc
Profiler.EndSample();
Profiler.BeginSample("888");
AddTimer1(Testing); // have gc
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 带有: 泛型参数的 callback 作为参数,都会有 GC
// 带有: 泛型参数的匿名函数没有 GC
// 闭包函数,都有 GC
// (如果让一个匿名成为闭包,在匿名函数内容使用到不在闭包函数内的外部的临时变量即可,
// 因为 C# 闭包原理是新建一个匿名类,将临时变量存于类成员中,
// 这点与 IEnumerator + yield 的方式很类似,都是语法糖)
// 在函数的方法参数传参时:
/*
以下说明参考:unity 官方手册说明:https://docs.unity3d.com/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html
IL2CPP 下的匿名方法
目前,通过查看 IL2CPP 所生成的代码得知,对System.Function 类型变量的声明和赋值将会分配一个新对象。无论变量是显式的(在方法/类中声明)还是隐式的(声明为另一个方法的参数),都是如此。
因此,使用 IL2CPP 脚本后端下的匿名方法必定会分配托管内存。在 Mono 脚本后端下则不是这种情况。
此外,由于方法参数的声明方式不同,将导致IL2CPP 显示出托管内存分配量产生巨大差异。正如预期的那样,闭包的每次调用会消耗最多的内存。
预定义的方法在 IL2CPP 脚本后端下作为参数传递时,其__分配的内存几乎与闭包一样多__,但这不是很直观。匿名方法在堆上生成最少量的临时垃圾(一个或多个数量级)。
因此,如果打算在 IL2CPP 脚本后端上发布项目,有三个主要建议:
- 最好选择不需要将方法作为参数传递的编码风格。
- 当不可避免时,最好选择匿名方法而不是预定义方法。
- 无论脚本后端为何,都要避免使用闭包。
* */
}
private int[] arr4linq = { 3, 2, 9 };
private List _check_list = new List();
private void Check_List()
{
Profiler.BeginSample("Check_List");
if (_check_list.Count == 0) _check_list.AddRange(arr4linq); // 内部:List Capacity 不足时会有 GC.Alloc,所以说,如果外部很多 List 需要临时使用的,都建议使用对象池,减少不必要的 GC.Alloc
Profiler.BeginSample("1");
var tolist = _check_list.ToArray(); // 内部 new T[Count],有 GC
Profiler.EndSample();
Profiler.BeginSample("2");
_check_list.Sort(); // 内部有 IComparer 实现对象的 new ,有 GC
Profiler.EndSample();
Profiler.BeginSample("3");
_check_list.Reverse(); // 内部反转索引内容,无 GC
Profiler.EndSample();
Profiler.BeginSample("4");
_check_list.GetRange(0, 1); // 内部 new List,有 GC
Profiler.EndSample();
Profiler.BeginSample("5");
_check_list.GetEnumerator(); // 内部 new Enumerator,但是 Enumerator 是内部的 struct 结构体,所以返回是存于执行栈帧的数据中,所以无 GC
Profiler.EndSample();
Profiler.BeginSample("6");
_check_list.FindAll(v => v > 0); // 内部 new List,有 GC
Profiler.EndSample();
Profiler.BeginSample("7");
_check_list.Capacity = 10; // 当 capcity 不够指定大小时,内部 new T[],然后 Array.Copy _items 到 new T[] 中,有 GC.Alloc
Profiler.EndSample();
Profiler.BeginSample("8");
_check_list.ConvertAll(v => v as object ); // 内部 new List,有 GC
Profiler.EndSample();
Profiler.BeginSample("9");
_check_list.AsReadOnly(); // 内部 new ReadOnlyCollection,有 GC
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 只测试了部分的 API,但是,其实 List(准确的说,.net 中的 API)大多都有 GC 问题,在排查 GC 问题,建议使用 ILSpy 或是 VS 自带的反编译来查看源码功能
// 确定有 GC 后,建议使用缓存方式来避免重复,无意义的 GC.Alloc 而导致 GC.Collect
}
public class TestingCanDispose : IDisposable
{
public void Dispose()
{
GC.SuppressFinalize(this); // 不调用 ~XXX 析构
}
}
private void Check_Using()
{
Profiler.BeginSample("CheckUsing");
using (var obj = new TestingCanDispose()) // using 自动释放只不过时自动调用实现了:IDisposable 的接口,所以 GC 还时肯定有的
{
}
Profiler.EndSample();
}
也可以使用第三方插件写的 MEC (More Efficient Coroutinue :更高效的协程,0 GC),但是我也没用过,只知道有这个东西
private Coroutine _testingCor1;
private IEnumerator _testingCor2;
private UnityCoroutineInst_NoBoxingOperates _testingCor3;
private IEnumerator TestingCor1()
{
yield return 0;
//_testingCor1 = null;
}
private IEnumerator TestingCor2()
{
yield return 0;
//_testingCor2.Reset(); // C# 语法糖生产的没有 Reset 实现,这里会报错
//_testingCor2 = null;
}
private void Check_ReuseCoroutinue()
{
Profiler.BeginSample("Check_ReuseCoroutinue");
Profiler.BeginSample("1");
if (_testingCor1 == null)
{
_testingCor1 = StartCoroutine(TestingCor1());
}
Profiler.EndSample();
Profiler.BeginSample("2");
if (_testingCor2 == null)
{
_testingCor2 = TestingCor2();
}
if (_testingCor2 != null)
{
if (_testingCor2.MoveNext())
{
int v = (int)_testingCor2.Current;
}
else
{
//_testingCor2.Reset(); // C# 语法糖生产的没有 Reset 实现,这里会报错
}
}
Profiler.EndSample();
Profiler.BeginSample("3");
if (_testingCor3 == null)
{
_testingCor3 = new UnityCoroutineInst_NoBoxingOperates();
}
if (_testingCor3 != null)
{
if (_testingCor3.MoveNext())
{
int v = _testingCor3.Current;
}
else
{
_testingCor3.Reset(); // 我们自己实现的 Cortoutine 就可以随心所欲的 Reset,因为自己实现了接口,这样就不用重新 new 一个协程管理对象,也就没有 GC 了
}
}
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 1、2 方式都因为 C# 语法糖内部实际 new 了一个类似 UnityCoroutineInst_NoBoxingOperates 的类来分状态处理,所以每次获取一个 Enumerator 时,都会有 GC
// 3 方式虽然我们也实现了对应的 IEnumerator,但是我们自己可实现对 Reset 接口的处理,所以不用重新 new,因此只有第一次 new 有 GC
// 因此,没事不要频繁的 StartCortoutine ,因为有 GC
// 尽可能使用 Update 函数来处理
}
public enum eThreeType
{
One,
Two,
Three,
}
public enum eTenType
{
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
}
private void Check_EnumGetValues()
{
Profiler.BeginSample("Check_EnumGetValues");
Profiler.BeginSample("1");
{
var arr = Enum.GetValues(typeof(eThreeType));
}
Profiler.EndSample();
Profiler.BeginSample("2");
{
var arr = Enum.GetValues(typeof(eTenType));
}
Profiler.EndSample();
Profiler.EndSample();
// Profile 结果
// 1 方式有 3 次 GC
// 2 方式有 12 次 GC
// 枚举的成员数量越多,GC越多次,因此,最好将 Enum.GetValues(typeof(T)) 的内容缓存到一个 static 对象中,这样就只会缓存一次,也只会在初始化类时GC
}
public delegate void ModVal(ref int v);
private void ModVal_Method(ref int v)
{
v += 1;
}
private void Check_Lambda()
{
Profiler.BeginSample("Check_Lambda");
const int LOOP_MAX = 10000;
{
Profiler.BeginSample("1");
ModVal act = (ref int a) =>
{
a += 1;
};
int v = 0;
for (int i = 0; i
关注
打赏
热门博文
- 3D Assets (Textures & Model & Animations) & Game Design Ideas & DCC Tutorials & TA
- LearnGL - 学习笔记目录
- Unity - Timeline 知识汇总
- Unity Graphics - 知识点目录 - 停止翻译,因为发现官方有中文文档了
- Graphic资料
- Unity Lightmap&LightProbe局部动态加载(亲测2020以及以上版本官方修复了)
- Unity - 踩坑日志 - 低版本线性颜色空间渲染异常的 “BUG”
- Unity Shader - PBR 渲染 SP 导出的素材
- 什么是 3A 游戏?
- Photosohp - 实现 2D MetaBall、MetaFont