Unity GC 与临时分配¶
一次间歇性掉帧¶
有一次战斗场景每帧只分配几百字节,平均帧率看起来正常,但每隔几秒会掉一帧。用 Profiler 打开 GC Alloc 才看到,血条文本和伤害飘字一直在生成临时字符串。
这类问题单看总内存不明显,真正影响手感的是短时间内的分配峰值,以及随后触发的 GC。
Unity 用的是什么 GC¶
Unity 的 Mono 和 IL2CPP 后端都使用 Boehm-Demers-Weiser 垃圾回收器。现在默认开启增量模式,GC 工作会拆到多帧执行,默认每片约 3 ms。1
这里有两个常见误解:
- 切到 IL2CPP 不代表没有托管堆,也不会自动消除 GC。
- 开启 Incremental GC 只是把一次长暂停拆短,托管分配本身仍然存在。
当内存压力过高,或者分配速度太快,运行时仍可能执行完整的非增量回收。所以优化的重点还是减少热点路径上的分配。
常见分配来源¶
字符串¶
// 每帧都会生成新字符串
hpText.text = "HP: " + currentHp + " / " + maxHp;
// 插值字符串也一样
hpText.text = $"HP: {currentHp} / {maxHp}";
可以缓存不变化的部分,只在数值变化时更新。StringBuilder 能减少中间字符串,但最后的 ToString() 仍然会分配,不要把它当成零 GC。
闭包¶
for (var i = 0; i < buttons.Count; i++)
{
var index = i;
buttons[i].onClick.AddListener(() => Select(index));
}
这里每次循环都会生成闭包对象。按钮不多时影响不大,频繁创建和销毁列表时就会累积。
如果只是传一个固定值,可以缓存对应的委托,或者改成集中处理点击事件,减少为每个按钮单独建闭包。
装箱¶
值类型放进 object、非泛型集合或某些接口参数时,会发生装箱。优先使用 List<int>、Dictionary<int, T> 这类泛型集合。
临时数组和集合¶
RaycastAll 每次都会创建数组,LINQ 链也会生成中间对象。高频路径可以用 Physics.RaycastNonAlloc、预先分配的 Buffer,或者把筛选拆成显式循环。
迭代器¶
yield 方法会生成状态机对象。它适合低频加载流程,不适合每帧遍历大量对象。热点路径优先使用普通循环。
怎么确认分配来源¶
在 Profiler 里打开 CPU 模块,选择当前帧,再按 GC Alloc 排序。注意看调用栈,不要只看最终 API。
也可以把 GC 分配接入运行时监控:
using Unity.Profiling;
private static readonly ProfilerRecorder GcRecorder =
ProfilerRecorder.StartNew(
ProfilerCategory.Memory,
"GC Allocated In Frame");
private void Update()
{
if (GcRecorder.Valid)
_gcBytes = GcRecorder.LastValue;
}
ProfilerRecorder 适合把数据接到调试面板或自动化测试里。GC.GetTotalMemory(false) 只能看一个大致趋势,不适合定位单帧分配。
对象池的边界¶
对象池主要解决重复 Instantiate 和 Destroy 的成本,不能解决所有内存问题。
public Projectile Rent()
{
var instance = _idle.Count > 0
? _idle.Pop()
: Object.Instantiate(_prefab);
instance.gameObject.SetActive(true);
return instance;
}
public void Return(Projectile instance)
{
instance.ResetState();
instance.gameObject.SetActive(false);
_idle.Push(instance);
}
归还时一定要重置速度、计时器、事件订阅和引用。池里的对象如果还持有别的对象,GC 仍然会认为它可达。
在加载阶段推进 GC¶
如果项目开启了增量 GC,可以在切场景或加载界面的空档里主动推进一点回收:
using UnityEngine.Scripting;
if (GarbageCollector.isIncremental)
GarbageCollector.CollectIncremental(2_000_000);
这里的参数是纳秒,表示最多花 2 ms 左右推进一次回收。它不是硬性上限,内存压力大时仍然可能触发完整回收,所以只适合放在确实可以卡一下的加载阶段。