1.频繁内存分配
2.垃圾回收
3.分代GC
4.减少GC的方法
1.频繁内存分配
内存分配不是"免费创建对象",而是操作系统在底层执行复杂操作的过程,频繁分配会直接消耗CPU资源,破坏内存效率1).堆内存分配的底层开销(c#引用类型对象)C#中引用类型(如string,List<T>,自定义类)的内存分配在"堆"上(值类型在栈上,分配和释放极快),堆分配的核心开销 来自两方面:a.空间块查找与管理 堆是动态内存区域,分配时需要先查找"大小匹配的空闲内存块",频繁分配小对象会导致堆中产生大量零散的空闲块(内存碎 片化),后续分配时需要遍历更长的空闲链表才能找到合适块,分配耗时逐渐增加 b.元数据与安全检查 每个堆对象都需要附加元数据(如:类型信息,GC标记位,同步锁标识),分配时运行时还要做边界检查,权限验证,这些都 是额外的CPU开销2).破坏CPU缓存局部性 CPU缓存(L1/L2/L3)的核心优势是"访问连续内存时命中率极高"(局部性原理),频繁分配的对象在堆中往往是离散分布的(尤 其是碎片化后),导致CPU访问这些对象时,缓存命中率大幅下降-不得不从速度慢100倍以上的主存中读取数据,直接拖慢 代码执行速度(这也是"频繁new对象"比"复用对象"慢的关键性原因)3).帧时间的累积消耗 Unity的帧循环有严格的时间限制(60帧要求每帧<=16ms,30帧<=33ms),如果每帧都进行多次内存分配(比如:临时string拼接,newList<int>(),LINQ查询隐式创建迭代器),这些分配的底层开销会直接占用帧时间;如 a.每帧分配10个小对象,每个分配耗时0.1ms,仅分配就占用1ms b.若项目本身逻辑复杂(如物理计算,UI渲染),再叠加分配开销,很容易导致帧时间超标,出现"微卡顿"
2.垃圾回收
内存分配只是"前因",真正导致明显卡顿的是后续的GC,因为GC的核心工资机制是"暂停所有线程";Unity的主线程(负责渲 染,输入,逻辑更新)一旦被暂停,画面就会直接卡住
1).GC的核心工作原理 C#的GC是"自动内存回收",但不是"无代价的",其核心流程(以"标记 - 清除"为例)a.标记阶段 暂停所有应用线程(STW),遍历堆中所有对象,标记出"仍被引用的存活对象"(如全局变量,局部变量指向的对象)b.清除阶段 继续STW,回收未被标记的"垃圾对象",释放其占用的堆内存 c.压缩阶段 为了解决碎片化,将存活对象整理成连续的内存块(同样需要STW)
2).STW是卡顿的直接原因 a.暂停主线程 Unity的主线程是"单线程驱动的",所有关键逻辑(Update,LateUpadte,渲染管线,UI布局)都在主线程中指向;GC触发时,主线程会强制暂停-GC执行多久,画面就卡多久 b.触发频率与耗时 频繁内存分配会导致堆内存快速增长,GC触发频率大幅度升高-MinorGC(年轻代回收):回收新分配的短期对象(如每帧创建的临时对象),耗时较短(通常1~3ms),但频繁触发(比如每10帧一次)会累积延迟,导致帧时间波动-MajorGC(老年代回收):回收长期存活的对象,涉及整个堆的遍历和整理,耗时更长(移动平台可能5~20ms 甚至更久)一 次Major GC就足以让60帧画面掉帧(16ms 阈值),出现明显卡顿 c.不确定性 GC的触发时机是运行时动态决定的(如堆内存达到阈值、手动调用GC.Collect()),可能在关键场景(如战斗爆发、UI 切换)突 然触发,卡顿体验更差
3.分代GC
1).分代GC的核心结构 Unity SGen GC将堆分为两个核心区域:年轻代和老年代 a.年轻代-存储对象类型:新分配的短期对象(如临时List,字符串)-回收效率:极快(ms级)-对应回收类型:Minor GC b.老年代-存储对象类型:存活超过多次Minor GC的长期对象(如游戏单例,场景根对象)-回收效率:慢(10ms级)-对应回收类型:Major GC/Full GC 注:"部分大对象(如 > 256kb的数组, 纹理数据)会直接分配到老年代(年轻代存不下), 跳过年轻代阶段"
2).MinorGC(年轻代回收):触发时机(高频,轻量)Minor GC的核心触发逻辑是:年轻代(Nursery)内存不足,无法容纳新分配的对象,这是最主要的,最常见的触发条件,且 几乎都是"自动, 被动"触发 a.核心触发条件(99%的场景)当你在代码中分配新的引用类型对象(如newList<>()、字符串拼接),运行时尝试将对象放入年轻代时:若年轻代剩余空闲空间 ≥ 新对象大小 → 正常分配,不触发GC 若年轻代剩余空闲空间 < 新对象大小 → 立即触发MinorGC ✅ 第一步:暂停主线程(STW),扫描年轻代所有对象,标记"存活对象"(如仍被变量引用的临时对象)✅ 第二步:回收年轻代中"死亡对象"(无引用的垃圾),释放空间 ✅ 第三步:将年轻代中"存活超过1 ~ 2次Minor GC"的对象(如存活了3帧的临时对象)"晋升(Promote)"到老年代 ✅ 第四步:若回收后年轻代有足够空间,分配新对象;若仍不足(极少)则触发Major GC b.辅助触发条件(少见)手动指定回收年轻代:调用GC.Collect(0)参数0代表年轻代,强制触发Minor GC 运行时内部阈值:极少数情况(如年轻代碎片化达到临界值),运行时主动触发Minor GC c.Unity实战中的Minor GC典型场景 每帧创建临时对象(如stringa="hp:"+playerHp;、newVector3()作为返回值),年轻代快速被填满,通常每10~30帧触发一次 Minor GC 战斗场景中频繁创建子弹/粒子临时对象,年轻代5~10帧就满,Minor GC触发频率飙升 注:Minor GC仅扫描年轻代(内存范围小),因此STW耗时极短(移动平台0.5~3ms,PC0.1~1ms),频繁触发会累积帧时 间波动
3).MajorGC(老年代回收)/FullGC(全堆回收,包含年轻代+老年代+压缩)的触发条件更复杂,核心是"老年代内存不足"或"全局内存压力达到临界值",且常与Minor GC联动 a.核心触发条件:老年代内存不足-直接触发:分配大对象(如:1MB以上的数组、游戏场景数据)时,大对象直接进入老年代,若老年代剩余空间不足 → 触发MajorGC(先回收老年代垃圾,再分配)-阈值触发:老年代已用内存占老年代总容量的比例达到阈值(Unity SGen 默认约75%~80%,不同版本/平台略有调整)→ 触发Major GC-碎片化触发:老年代碎片化严重(有大量空闲内存,但无连续大块空间容纳新对象)→ 触发FullGC(含压缩阶段,整理内 存碎片)b.间接触发:Minor GC的"晋升失败"这是Major GC最常见的"间接触发"场景:Minor GC执行后,需要将年轻代中"存活多次的对象"晋升到老年代,但此时老年代 没有足够空间容纳这些晋升对象 → 触发"晋升失败(Promotion Failure)"→ 运行时会先触发Major GC清理老年代空间,再 完成年轻代对象的晋升 c.手动触发(主动控制/风险操作)-调用GC.Collect():无参数,强制触发FullGC(回收年轻代+老年代,且可能执行内存压缩)-调用GC.Collect(1):参数1代表老年代强制触发Major GC-Unity特定操作:手动调用Resources.UnloadUnusedAssets()卸载未使用资源后,通常会触发Full GC清理资源对应的内存 对象 d.系统/运行时强制触发-低内存压力:移动平台(iOS/Android)触发"低内存警告",系统强制Unity回收内存 → 运行时触发Full GC-堆内存耗尽:整个堆(年轻代+老年代)的空闲内存不足以分配新对象,且Minor/Major GC都无法释放足够空间 → 触发FullGC(最后尝试回收,失败则抛出 OOM 内存溢出)-Unity场景卸载:场景切换时,大量长期对象(如场景内的管理器、模型对象)变为垃圾,老年代占比骤升 → 触发Major GC e.Unity实战中的Major GC典型场景 场景切换:卸载旧场景后,大量长期对象进入老年代垃圾,触发MajorGC(移动平台耗时5~20ms)战斗结束:大量战斗相关长期对象(如技能管理器、敌人对象)失效,老年代占比达阈值 → 触发 Major GC 频繁创建大对象:如每帧加载1MB的配置数据数组(直接进老年代),老年代快速填满 → 触发 Major GC 注:Major/Full GC需要扫描老年代(内存范围大),且可能执行内存压缩,STW耗时远高于MinorGC(移动平台5~20ms,PC3~10ms),一次就可能导致明显卡顿
4.减少GC的方法
1).避免在Update中创建对象
// 错误示例:在Update中创建字符串voidUpdate(){stringmessage="Current time: "+Time.time;// 每帧创建新字符串Debug.Log(message);}// 正确示例:预分配字符串privatestringmessageBuffer="";voidUpdate(){messageBuffer=string.Format("Current time: {0}",Time.time);// 复用字符串缓冲区Debug.Log(messageBuffer);}
2).使用StringBuilder处理字符串拼接
// 错误示例:频繁的字符串拼接stringfullName=firstName+" "+lastName+" - Age: "+age;// 正确示例:使用StringBuilderStringBuildersb=newStringBuilder();sb.Append(firstName).Append(" ").Append(lastName).Append(" - Age: ").Append(age);stringfullName=sb.ToString();
3).避免装箱和拆箱操作
// 错误示例:int装箱objectboxedInt=10;intunboxedInt=(int)boxedInt;// 正确示例:使用泛型避免装箱List<int>intList=newList<int>();intList.Add(10);
4).游戏对象池的使用
usingSystem.Collections.Generic;usingUnityEngine;publicclassObjectPool:MonoBehaviour{[SerializeField]privateGameObjectpooledObject;[SerializeField]privateintpoolSize=10;privateList<GameObject>objectPool;voidStart(){objectPool=newList<GameObject>();for(inti=0;i<poolSize;i++){GameObjectobj=Instantiate(pooledObject);obj.SetActive(false);objectPool.Add(obj);}}publicGameObjectGetPooledObject(){foreach(GameObjectobjinobjectPool){if(!obj.activeInHierarchy){obj.SetActive(true);returnobj;}}// 如果池已满,可以创建新对象或扩展池GameObjectnewObj=Instantiate(pooledObject);objectPool.Add(newObj);returnnewObj;}publicvoidReturnToPool(GameObjectobj){obj.SetActive(false);}}
5).类对象池的使用
publicclassPool<T>whereT:class,new(){privateStack<T>objectStack;privateintmaxSize;publicPool(intinitialSize,intmaxSize){objectStack=newStack<T>();this.maxSize=maxSize;for(inti=0;i<initialSize;i++){objectStack.Push(newT());}}publicTGet(){returnobjectStack.Count>0?objectStack.Pop():newT();}publicvoidReturn(Tobj){if(objectStack.Count<maxSize){objectStack.Push(obj);}}}// 使用示例privatestaticPool<List<int>>listPool=newPool<List<int>>(10,100);voidSomeMethod(){List<int>list=listPool.Get();list.Add(1);list.Add(2);// 使用完后归还list.Clear();listPool.Return(list);}
6).预分配内存
// 错误示例:每次调用都分配新数组voidProcessData(List<int>data){int[]array=data.ToArray();// 分配新数组// 处理数组}// 正确示例:预分配数组privateint[]tempArray=newint[1000];voidProcessData(List<int>data){data.CopyTo(tempArray);// 复用预分配的数组// 处理数组}
7).字符串优化 a.避免在循环中使用字符串拼接// 错误示例:在循环中拼接字符串stringresult="";for(inti=0;i<1000;i++){result+=i.ToString();// 每次循环都创建新字符串}// 正确示例:使用StringBuilderStringBuildersb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append(i);}stringresult=sb.ToString();
b.缓存常用字符串// 正确示例:缓存常用字符串privateconststringscorePrefix="Score: ";privateconststringhealthPrefix="Health: ";
c.使用StringBuilder缓存(创建管理器)d.使用struct代替class定义小型数据结构 e.减少ToString()调用// 错误示例:频繁调用ToString()Debug.Log("Position: "+transform.position.ToString());// 正确示例:自定义日志格式Debug.LogFormat("Position: ({0}, {1}, {2})",transform.position.x,transform.position.y,transform.position.z);