帧级预算与 GC 生命周期策略
篇章:08-核心技术-性能优化篇 |阅读时间:约 45 分钟
一、引言
在前面的章节中,我们逐一分析了各种 GC 分配源和零分配技术。但在实际项目中,追求绝对零分配既不现实也不经济。本章将从宏观角度建立 GC 管理的工程框架:帧级 GC 预算模型、对象池化策略、分代回收配合、场景加载时的 GC 控制,以及完整的 GC 生命周期管理策略。这是性能优化的"顶层设计"章节。
帧级 GC 预算的核心理念是:不追求零分配,而是追求可控分配。通过将 GC 分配限制在每帧的预算内,让 Gen 0 GC 高效回收短命对象,避免不可控的 GC 触发导致帧率抖动。
在展开之前先把本章的定位说清楚。08-01 到 08-09 分析了各种具体的 GC 分配源和优化技术,本章将它们整合为一个完整的工程框架。08-05 值类型篇的 struct 优化、08-07 Span 篇的零分配技术、08-08 Unity API 篇的陷阱清单,都是本章预算模型的底层实现手段。
二、帧级 GC 预算模型
2.1 GC 预算的理论基础
在深入预算模型之前,先理解为什么 GC 预算能够工作。这需要从 .NET 的分代 GC 机制说起。
.NET 的 GC 将堆分为三代:Gen 0(短命)、Gen 1(中等寿命)、Gen 2(长命)。Gen 0 GC 是最快的回收操作,通常耗时 1-5 毫秒。当每帧的 GC 分配量控制在合理范围内时,Gen 0 GC 可以在一帧内完成回收,不会跨越到下一帧。
帧级 GC 预算的核心思想是:将 GC 分配限制在每帧的预算内,让 Gen 0 GC 在每帧结束时自然触发,而不是在帧中间意外触发。
这种策略的优势:
- 可预测的帧时间:GC 在每帧结束时触发,帧时间稳定。
- 小批量回收:每次 Gen 0 GC 回收的对象少,耗时短。
- 避免 Gen 1/Gen 2 晋升:短命对象在 Gen 0 被回收,不会晋升到更代。
2.2 为什么需要预算
GC 本身不是敌人——它是自动内存管理的必要机制。问题是不可控的 GC 触发时机导致帧率抖动。通过建立帧级 GC 预算,我们将"不可控的 GC"转化为"可控的 GC"。
不可控 GC 的问题: ┌─────────────────────────────────────────────────┐ │ 帧时间线(60 FPS = 16.67ms/帧) │ │ │ │ 帧 1: [逻辑 10ms][GC 5ms][逻辑 1.67ms] ← GC 导致掉帧 │ │ 帧 2: [逻辑 10ms][逻辑 6.67ms] │ │ 帧 3: [逻辑 10ms][GC 8ms][逻辑 0ms] ← GC 导致严重掉帧 │ │ │ │ 问题:GC 触发时机不可控,可能出现在任何帧 │ │ 结果:帧时间从 16.67ms 飙升到 25ms+,帧率下降 │ └─────────────────────────────────────────────────┘ 可控 GC 的预算模型: ┌─────────────────────────────────────────────────┐ │ 帧时间线(60 FPS = 16.67ms/帧,预算 4KB/帧) │ │ │ │ 帧 1: [逻辑 10ms][逻辑 6.67ms] ← 预算内,无 GC │ │ 帧 2: [逻辑 10ms][GC 1ms][逻辑 5.67ms] ← 预算内 GC │ │ 帧 3: [逻辑 10ms][逻辑 6.67ms] ← 预算内,无 GC │ │ │ │ 优势:GC 在可控时机触发,帧时间稳定在 11-12ms │ │ 结果:帧率稳定在 83-90 FPS,无掉帧 │ └─────────────────────────────────────────────────┘2.2 预算值的设定
不同游戏类型有不同的 GC 预算需求:
// 典型预算值(60 FPS = 16.67ms/帧): // - 高性能游戏(竞技类): ≤ 1KB/帧 → 60KB/秒 // - 中等性能游戏(RPG): ≤ 4KB/帧 → 240KB/秒 // - 休闲游戏: ≤ 16KB/帧 → 960KB/秒 // - 加载/过场场景: ≤ 1MB/帧 → 一次性 // 30 FPS 场景(33.33ms/帧): // - 高性能游戏: ≤ 2KB/帧 → 60KB/秒 // - 中等性能游戏:≤ 8KB/帧 → 240KB/秒 // 120 FPS 场景(8.33ms/帧): // - 高性能游戏: ≤ 0.5KB/帧 → 60KB/秒 // - 中等性能游戏:≤ 2KB/帧 → 240KB/秒预算设定的原则:
- 越低越稳定:预算越低,GC 频率越高,但每次 GC 的代价越小。
- 越高越高效:预算越高,GC 频率越低,但每次 GC 的代价越大。
- 找到平衡点:通过 Profiler 实测,找到帧时间稳定的最大预算值。
2.3 GC 预算监控器
public class GCBudgetMonitor : MonoBehaviour { [Header("Budget Settings")] [SerializeField] private float _budgetKBPerFrame = 4f; // 4KB/帧 [SerializeField] private bool _logViolations = true; [SerializeField] private int _alertThreshold = 3; // 连续 3 帧超标才报警 private long _lastGCCount; private long _lastHeapSize; private float _budgetBytes; private int _violationCount; void Awake() { _budgetBytes = _budgetKBPerFrame * 1024; _lastGCCount = GC.CollectionCount(0); _lastHeapSize = GC.GetTotalMemory(false); } void Update() { long currentHeap = GC.GetTotalMemory(false); long heapDelta = currentHeap - _lastHeapSize; // 检查是否超预算 if (heapDelta > _budgetBytes) { _violationCount++; if (_violationCount >= _alertThreshold && _logViolations) { Debug.LogWarning( $"[{Time.frameCount}] GC Budget Violation: " + $"{heapDelta / 1024f:F1}KB allocated " + $"(budget: {_budgetKBPerFrame}KB)"); } } else { _violationCount = 0; } _lastHeapSize = currentHeap; _lastGCCount = GC.CollectionCount(0); } // 手动触发 GC(在安全时机) public void CollectAtSafeTime() { // 在场景加载、过场动画等安全时机手动触发 GC.Collect(0, GCCollectionMode.Optimized, false); } }2.4 Unity Profiler 预算验证
// 使用 ProfilerRecorder 监控 GC 分配 using Unity.Profiling; public class ProfilerGCTracker : MonoBehaviour { private ProfilerRecorder _gcAllocRecorder; private ProfilerRecorder _gcCollectRecorder; private float _threshold = 4096f; // 4KB void OnEnable() { _gcAllocRecorder = ProfilerRecorder.StartNew( ProfilerCategory.Memory, "GC.Alloc", 1); _gcCollectRecorder = ProfilerRecorder.StartNew( ProfilerCategory.Memory, "GC.Collect", 1); } void OnDisable() { _gcAllocRecorder?.Dispose(); _gcCollectRecorder?.Dispose(); } void Update() { if (_gcAllocRecorder.Valid) { long allocBytes = _gcAllocRecorder.LastValue; if (allocBytes > _threshold) { Debug.LogWarning( $"Frame {Time.frameCount}: GC Alloc {allocBytes} bytes"); } } } }2.5 预算设定的实践原则
在实际项目中,预算值的设定需要结合项目特点和性能目标。以下是预算设定的实践原则:
- 从低到高:从较低的预算值(如 1KB/帧)开始,逐步增加直到找到帧时间稳定的最大值。
- 考虑目标帧率:60 FPS 游戏的预算应低于 30 FPS 游戏,因为每帧可用时间更短。
- 区分场景类型:加载场景的预算可以远高于运行场景,因为加载期允许较长的帧时间。
- 监控 Gen 0 GC 频率:Gen 0 GC 的频率是预算是否合理的关键指标。每秒超过 10 次 Gen 0 GC 说明预算过高。
三、对象池化策略
3.1 通用对象池
public class ObjectPool<T> where T : class, new() { private readonly Stack<T> _pool; private readonly Action<T>? _onRent; private readonly Action<T>? _onReturn; private readonly int _maxSize; public ObjectPool(int initialSize, int maxSize = 1024, Action<T>? onRent = null, Action<T>? onReturn = null) { _pool = new Stack<T>(initialSize); _maxSize = maxSize; _onRent = onRent; _onReturn = onReturn; // 预热:预创建 initialSize 个对象 for (int i = 0; i < initialSize; i++) { var item = new T(); _onReturn?.Invoke(item); // 初始化时调用 Return 回调 _pool.Push(item); } } public T Rent() { T item = _pool.Count > 0 ? _pool.Pop() : new T(); _onRent?.Invoke(item); // 租用时调用初始化回调 return item; } public void Return(T item) { if (_pool.Count < _maxSize) { _onReturn?.Invoke(item); // 归还时调用清理回调 _pool.Push(item); } // 超过最大容量则丢弃,让 GC 回收 } public int Count => _pool.Count; public int Capacity => _pool.Count; // 池中对象数 }3.2 Unity GameObject 池
public class GameObjectPool { private readonly Stack<GameObject> _pool; private readonly GameObject _prefab; private readonly Transform _parent; private readonly int _maxSize; public GameObjectPool(GameObject prefab, int initialSize, int maxSize = 64, Transform? parent = null) { _prefab = prefab; _parent = parent ?? new GameObject($"Pool_{prefab.name}").transform; _maxSize = maxSize; _pool = new Stack<GameObject>(initialSize); // 预热 for (int i = 0; i < initialSize; i++) { var go = Object.Instantiate(prefab, _parent); go.SetActive(false); _pool.Push(go); } } public GameObject Rent(Vector3 position, Quaternion rotation) { GameObject go = _pool.Count > 0 ? _pool.Pop() : Object.Instantiate(_prefab, _parent); go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } public void Return(GameObject go) { if (_pool.Count < _maxSize) { go.SetActive(false); go.transform.SetParent(_parent, false); _pool.Push(go); } else { Object.Destroy(go); // 超出容量,销毁 } } public void Prewarm(int count) { while (_pool.Count < count) { var go = Object.Instantiate(_prefab, _parent); go.SetActive(false); _pool.Push(go); } } public int AvailableCount => _pool.Count; public int ActiveCount => _parent?.childCount ?? 0; }3.3 List 池化
// 避免频繁 new List<T> 和扩容 public static class ListPool<T> { [ThreadStatic] private static Stack<List<T>>? _pool; private static Stack<List<T>> Pool => _pool ??= new Stack<List<T>>(); public static List<T> Rent() { return Pool.Count > 0 ? Pool.Pop() : new List<T>(64); } public static void Return(List<T> list) { list.Clear(); // 清空但保留内部数组容量 Pool.Push(list); } // 便捷方法:租用→操作→归还→返回结果 public static T[] RentProcessAndReturn(Func<List<T>, T[]> process) { var list = Rent(); try { return process(list); } finally { Return(list); } } } // 使用 var array = ListPool<Enemy>.RentProcessAndReturn(list => { for (int i = 0; i < enemies.Count; i++) { if (enemies[i].IsAlive) list.Add(enemies[i]); } return list.ToArray(); // 仅一次数组分配 });3.4 池化策略决策
| 对象类型 | 是否池化 | 原因 |
|---|---|---|
| 子弹/投射物 | 是 | 高频创建/销毁 |
| 粒子特效 | 是 | 高频创建/销毁 |
| 敌人/NPC | 是 | 战斗场景高频 |
| 拾取物 | 是 | 中频创建/销毁 |
| UI 面板 | 是 | 场景切换频繁 |
| 场景配置数据 | 否 | 低频创建,生命周期长 |
| 日志/调试对象 | 否 | 低频,调试时允许分配 |
3.5 池化策略的权衡
对象池化虽然能有效减少 GC 分配,但也引入了额外的复杂性。以下是池化策略的权衡分析:
池化的优势:
- 消除分配:池化对象在首次创建后不再分配,后续租用/归还不产生 GC。
- 内存预分配:所有池化对象在初始化时分配,运行时无内存波动。
- 性能可预测:租用/归还操作的时间复杂度为 O(1),性能可预测。
池化的代价:
- 初始化开销:预热池需要预先创建对象,增加初始化时间。
- 内存占用:池化对象始终占用内存,即使暂时不需要。
- 复杂度:需要管理池的生命周期、容量限制、归还逻辑。
池化决策原则:
- 高频创建/销毁的对象:应该池化(如子弹、特效)。
- 低频创建的对象:不需要池化(如场景配置)。
- 大对象:谨慎池化(占用 Gen2 空间)。
- 小对象:不需要池化(Gen 0 GC 回收成本低)。
四、分代回收配合策略
4.1 理解分代 GC
// CLR 的分代 GC 策略: // Gen0: 短命对象,频繁回收(~1ms) // - 每帧分配的新对象默认进入 Gen0 // - GC.Collect(0) 只回收 Gen0 // Gen1: 中等寿命,较少回收(~5ms) // - Gen0 存活对象晋升到 Gen1 // - 作为 Gen0 和 Gen2 之间的缓冲区 // Gen2: 长命对象,很少回收(~50ms+) // - Gen1 存活对象晋升到 Gen2 // - 单例、缓存、场景配置等 // LOH: 大对象(≥85KB),直接进入 Gen2 // - 回收代价极高,需要压缩 // 优化原则: // 1. 让短命对象在 Gen0 被回收 → 控制每帧分配量 // 2. 避免对象晋升到 Gen1/Gen2 → 控制引用持有时间 // 3. 大对象避免频繁分配 → LOH 回收代价极高 // 4. 在安全时机手动触发 Gen0 GC → 避免运行时意外触发4.2 分代 GC 配合对象池
// 对象池的目的是让对象不进入 GC 回收周期 // 池化的对象始终被强引用,不会被 GC 回收 // 但需要注意:池化对象不能太大,否则占用 Gen2/LOH 空间 // 池化对象的代际管理: // 1. 对象首次创建 → Gen0 // 2. 归还到池 → 仍被池引用,停留在当前代 // 3. 池本身是长命对象(Gen2)→ 池化对象最终晋升到 Gen2 // 4. 池化对象在 Gen2 中,不会频繁回收,但也无法回收 // 因此:池化适合中等大小、高频复用的对象 // 不适合超大对象(会占用 Gen2 空间)4.3 手动 GC 触发时机
public class GCTrigger : MonoBehaviour { private float _gcTimer = 0; private const float GC_INTERVAL = 5f; // 每 5 秒检查一次 void Update() { _gcTimer += Time.deltaTime; // 在 Loading 屏幕、过场动画等安全时机触发 if (_gcTimer >= GC_INTERVAL && IsSafeTime()) { // 仅触发 Gen0 GC,开销最小 GC.Collect(0, GCCollectionMode.Optimized, false); _gcTimer = 0; } } private bool IsSafeTime() { // 判断当前是否是安全时机 // 1. 不在战斗中 // 2. 不在过场动画 // 3. 帧时间充裕 return Time.deltaTime < 0.016f * 0.8f; // 帧时间低于预算 80% } // 场景切换时触发完整 GC public void FullGCOnSceneLoad() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); // 清理所有代,释放未使用内存 } }4.4 分代 GC 与内存压力的关系
当系统内存压力增大时(如 Windows 通知内存不足),.NET GC 会自动调整行为以释放内存。这种调整包括:
- 降低 Gen 0 预算:更频繁地触发 Gen 0 GC。
- 提高压缩阈值:更早触发 Gen 2 压缩。
- 回收 LOH:回收大对象堆中的死对象。
理解这种自动调整机制有助于正确设置 GC 预算——在内存富余时预算可以稍高,在内存紧张时预算应该降低。
五、场景加载的 GC 控制
5.1 场景加载的 GC 特点
// 场景加载是 GC 分配的高峰期: // 1. 反序列化 Prefab/ScriptableObject → 大量对象分配 // 2. Instantiate → GameObject + Component 分配 // 3. 资源加载 → Texture/Mesh/AudioClip 分配 // 4. 初始化逻辑 → 集合/缓存分配 // 策略:将加载期 GC 与运行期 GC 隔离 // 1. 加载期允许大量 GC 分配 // 2. 加载完成后触发完整 GC // 3. 运行期严格控制 GC 预算5.2 分阶段加载
public class SceneLoader : MonoBehaviour { public IEnumerator LoadSceneAsync(string sceneName) { // 阶段 1:异步加载场景 var op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive); op.allowSceneActivation = false; while (op.progress < 0.9f) yield return null; // 阶段 2:预加载资源 yield return PreloadAssets(); // 阶段 3:激活场景 op.allowSceneActivation = true; yield return null; // 阶段 4:初始化场景对象 yield return InitializeScene(); // 阶段 5:触发 GC 清理加载垃圾 yield return new WaitForEndOfFrame(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); // 阶段 6:预热对象池 PrewarmPools(); } private IEnumerator PreloadAssets() { // 预加载常用资源到缓存 var handles = new List<UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle>(); foreach (var path in _preloadPaths) { var handle = Addressables.LoadAssetAsync<GameObject>(path); handles.Add(handle); } yield return Addressables.LoadAssetAsync<GameObject>(_preloadPaths[0]); foreach (var h in handles) _assetCache.Add(h); } private void PrewarmPools() { // 预热对象池,避免运行时 Instantiate foreach (var pool in _pools) pool.Prewarm(pool.InitialSize); } }5.3 场景卸载的 GC 控制
public IEnumerator UnloadSceneAsync(Scene scene) { // 阶段 1:释放资源引用 ReleaseAssetReferences(); // 阶段 2:清空对象池 ClearPools(); // 阶段 3:异步卸载场景 var op = SceneManager.UnloadSceneAsync(scene); while (!op.isDone) yield return null; // 阶段 4:触发 GC 清理 yield return null; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); // 阶段 5:可选 - 释放未使用的资源 yield return Resources.UnloadUnusedAssets(); }5.4 场景加载 GC 控制的工程实践
场景加载的 GC 控制在工程实践中需要与项目的加载流程紧密结合。以下是场景加载 GC 控制的工程实践建议:
- 加载前 GC:在场景加载前触发一次 Gen 0 GC,释放上一场景的残留对象。
- 加载后 Full GC:场景加载完成后触发 Full GC,清理所有代。
- 异步加载:使用 Addressables 或 SceneManager 的异步加载 API,将 GC 分散到多帧。
- 对象池预热:在场景加载完成后预热新场景需要的对象池,避免运行时 Instantiate。
六、GC 生命周期管理策略
6.1 对象生命周期分类
// 按生命周期将对象分为四类,分别管理: // 1. 永久对象(Permanent) // - 单例管理器、配置数据、核心系统 // - 生命周期 = 应用生命周期 // - 策略:静态引用或 DontDestroyOnLoad // 2. 场景对象(Scene-scoped) // - 场景中的 GameObject、场景配置 // - 生命周期 = 场景生命周期 // - 策略:场景加载时分配,卸载时释放 // 3. 帧对象(Frame-scoped) // - 临时计算结果、碰撞检测结果 // - 生命周期 = 单帧 // - 策略:预分配缓冲区,栈上分配 // 4. 池化对象(Pooled) // - 子弹、特效、敌人等频繁创建/销毁的对象 // - 生命周期 = 可复用 // - 策略:对象池管理6.2 生命周期管理框架
public class LifetimeManager : MonoBehaviour { // 永久对象注册 private readonly HashSet<object> _permanentObjects = new(); // 场景对象跟踪 private readonly List<GameObject> _sceneObjects = new(256); // 帧缓冲区(预分配) private readonly Collider[] _frameOverlapBuffer = new Collider[64]; private readonly RaycastHit[] _frameRayBuffer = new RaycastHit[32]; // 对象池 private readonly Dictionary<Type, object> _pools = new(); public T RentPooled<T>() where T : class, new() { if (!_pools.TryGetValue(typeof(T), out var pool)) { pool = new ObjectPool<T>(32); _pools[typeof(T)] = pool; } return ((ObjectPool<T>)pool).Rent(); } public void ReturnPooled<T>(T item) where T : class, new() { if (_pools.TryGetValue(typeof(T), out var pool)) ((ObjectPool<T>)pool).Return(item); } public ref Collider[] FrameOverlapBuffer => ref _frameOverlapBuffer; public ref RaycastHit[] FrameRayBuffer => ref _frameRayBuffer; public void OnSceneLoaded() { _sceneObjects.Clear(); // 注册新场景对象 } public void OnSceneUnloaded() { // 释放场景对象引用 _sceneObjects.Clear(); // 清空池化对象 foreach (var pool in _pools.Values) { // 清空池,让 GC 回收 } _pools.Clear(); // 触发 GC GC.Collect(); } }七、完整 GC 优化策略总结
7.1 分层优化策略
┌─────────────────────────────────────────────────┐ │ Layer 1: 零分配编程(热路径) │ │ - Span/stackalloc │ │ - 预分配缓冲区 │ │ - 值类型替代引用类型 │ │ - 静态委托缓存 │ ├─────────────────────────────────────────────────┤ │ Layer 2: 对象池化(中频路径) │ │ - GameObject 池 │ │ - List/数组池 │ │ - StringBuilder 池 │ │ - 事件/委托池 │ ├─────────────────────────────────────────────────┤ │ Layer 3: GC 预算控制(全局) │ │ - 帧级 GC 预算监控 │ │ - 安全时机手动 GC │ │ - 分代回收配合 │ ├─────────────────────────────────────────────────┤ │ Layer 4: 场景级 GC 管理(宏观) │ │ - 加载/卸载 GC 隔离 │ │ - 分阶段加载 │ │ - 资源预加载与预热 │ └─────────────────────────────────────────────────┘7.2 GC 优化检查清单
| 层级 | 检查项 | 状态 |
|---|---|---|
| L1 | Update 中无字符串拼接 | ☐ |
| L1 | Update 中无 LINQ | ☐ |
| L1 | Update 中无闭包/lambda | ☐ |
| L1 | Physics 使用 NonAlloc | ☐ |
| L1 | Mesh 数据已缓存 | ☐ |
| L1 | Material 使用 PropertyBlock | ☐ |
| L1 | Animator 参数使用 Hash | ☐ |
| L2 | 子弹/特效使用对象池 | ☐ |
| L2 | List/数组复用 | ☐ |
| L2 | StringBuilder 池化 | ☐ |
| L3 | GC 预算监控器部署 | ☐ |
| L3 | 安全时机 GC 触发 | ☐ |
| L3 | Profiler GC Alloc < 4KB/帧 | ☐ |
| L4 | 场景加载后 Full GC | ☐ |
| L4 | 对象池预热 | ☐ |
| L4 | 资源预加载 | ☐ |
八、实战:完整的 GC 管理系统
public class GCManager : MonoBehaviour { public static GCManager Instance { get; private set; } = null!; [Header("Budget")] [SerializeField] private float _budgetKBPerFrame = 4f; [SerializeField] private bool _enableAutoGC = true; // 预分配帧缓冲区 public readonly Collider[] OverlapBuffer = new Collider[128]; public readonly RaycastHit[] RayBuffer = new RaycastHit[64]; public readonly List<Enemy> EnemyBuffer = new(128); // 对象池 private GameObjectPool? _effectPool; private GameObjectPool? _projectilePool; // GC 监控 private long _lastHeapSize; private float _gcTimer; void Awake() { Instance = this; _lastHeapSize = GC.GetTotalMemory(false); } void Start() { // 预热对象池 _effectPool = new GameObjectPool(_effectPrefab, 32, 128); _projectilePool = new GameObjectPool(_projectilePrefab, 64, 256); } void Update() { MonitorGC(); MaybeTriggerGC(); } private void MonitorGC() { long currentHeap = GC.GetTotalMemory(false); long delta = currentHeap - _lastHeapSize; float deltaKB = delta / 1024f; if (deltaKB > _budgetKBPerFrame) { Debug.LogWarning( $"[{Time.frameCount}] GC alloc {deltaKB:F1}KB " + $"exceeds budget {_budgetKBPerFrame}KB"); } _lastHeapSize = currentHeap; } private void MaybeTriggerGC() { if (!_enableAutoGC) return; _gcTimer += Time.deltaTime; if (_gcTimer >= 10f && Time.deltaTime < 0.012f) { GC.Collect(0, GCCollectionMode.Optimized, false); _gcTimer = 0; } } // 场景加载时调用 public void OnSceneTransition() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); _effectPool?.Prewarm(32); _projectilePool?.Prewarm(64); } }九、总结与实践建议
9.1 核心要点回顾
帧级预算与 GC 生命周期策略是 GC 优化的顶层设计:
- 建立帧级 GC 预算:每帧分配控制在 4KB 以内。
- 部署 GC 监控器:实时检测预算违规。
- 对象池化中频对象:子弹/特效/列表复用。
- 预分配帧缓冲区:Physics/碰撞检测复用。
- 安全时机手动 GC:避免运行时意外触发。
- 场景加载 GC 隔离:加载期允许分配,完成后 Full GC。
- 分阶段加载:预加载→激活→初始化→GC→预热。
- 分层优化:L1 零分配→L2 池化→L3 预算→L4 场景管理。
9.2 实践建议
- 从监控开始:先部署 GC 预算监控器,了解项目的 GC 基线。
- 逐层优化:从 L1 零分配开始,逐步到 L4 场景管理。
- 建立编码规范:将高风险 API 清单纳入项目规范。
- CI 集成:在 CI 流程中集成 GC 分配检测。
- 定期审计:每 2-4 周进行一次 GC 分配审计。
9.3 GC 优化决策树
需要优化 GC? │ ├─ 第一步:建立基线 │ └─ 部署 GC 预算监控器 │ └─ 记录当前 GC Alloc 基线 │ ├─ 第二步:L1 零分配 │ ├─ 消除 Update 中的字符串操作 │ ├─ 消除 Update 中的 LINQ │ ├─ 使用 NonAlloc Physics API │ └─ 缓存 Mesh/Material/Animator │ ├─ 第三步:L2 池化 │ ├─ 子弹/特效 → GameObject 池 │ ├─ 集合 → ListPool │ └─ 临时缓冲区 → ArrayPool │ ├─ 第四步:L3 预算 │ ├─ 调整 GC 预算值 │ ├─ 部署安全时机 GC │ └─ 配合分代回收 │ └─ 第五步:L4 场景 ├─ 场景加载 Full GC ├─ 分阶段加载 └─ 对象池预热GC 优化不是一次性工作,而是贯穿整个项目生命周期的持续工程。从编码规范到 CI 检测,从 Profiler 监控到发布前审计,需要建立完整的 GC 管理流程。