跳转至

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 value = 42;
var list = new ArrayList();
list.Add(42);

值类型放进 object、非泛型集合或某些接口参数时,会发生装箱。优先使用 List<int>、Dictionary<int, T> 这类泛型集合。

临时数组和集合

var hits = Physics.RaycastAll(ray);
var visible = enemies.Where(IsVisible).ToArray();

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 左右推进一次回收。它不是硬性上限,内存压力大时仍然可能触发完整回收,所以只适合放在确实可以卡一下的加载阶段。