【原理】Unity GC 对比 C# GC
【从UnityURP开始探索游戏渲染】专栏-直达
Unity GC(基于Boehm-Demers-Weiser算法)与标准C# GC(.NET CLR分代算法)的核心对比及优化方向:
⚙️ 一、核心机制差异
分代回收策略
- C# GC:采用分代模型(Gen0/1/2),优先回收短期对象(Gen0),Gen2回收频率低但耗时长(可达秒级)。
- Unity GC:不分代,每次触发均为Full GC,需遍历整个堆内存,对象越多性能消耗越大。
内存碎片处理
- C# GC:标记后执行内存压缩(Mark-Compact),消除碎片。
- Unity GC:仅标记清除(Mark-Sweep),不压缩内存,长期运行易产生碎片,可能触发额外内存申请。
线程与暂停机制
- C# GC:支持后台并行回收(Background GC),减少主线程阻塞。
- Unity GC:强制 Stop-The-World ,暂停所有线程直至GC完成(即使增量式GC仍需分帧暂停)。(停止增量GC时会这样。默认开启
Incremental GC
)
⏱️ 二、性能影响对比
维度 | C# GC | Unity GC |
---|---|---|
触发耗时 | Gen0:毫秒级;Gen2:秒级 | 随堆内存增长线性上升 |
卡顿风险 | 可控(Gen2低频触发) | 高频且不可预测 |
内存效率 | 高(压缩减少碎片) | 低(碎片可能引发OOM) |
三、优化策略差异
C# GC优化重点
- 减少Gen2回收:避免长期存活对象频繁修改(如静态集合)。
- 控制LOH分配:大型对象(>85KB)直接进入LOH,需重用或使用
ArrayPool
。 - 避免装箱拆箱:泛型容器替代
ArrayList
减少堆分配 。
Unity GC优化重点
- 降低堆分配频率:
- 避免字符串拼接、闭包、非必要迭代器。
- 值类型替代引用类型(如
struct
替代class
)。
- 对象池技术:复用游戏对象(如子弹、特效)。
- 增量GC配置:
- Unity 2019+支持分帧执行GC(
Incremental GC
),但仅缓解卡顿,不减少总耗时。
- Unity 2019+支持分帧执行GC(
💎 四、关键结论
- Unity GC更依赖开发者主动优化:因缺乏分代和压缩机制,需严格管控堆内存分配。
- 性能敏感场景规避GC:
- 使用栈内存(
stackalloc
)或预分配内存池。 - 在加载场景或菜单界面手动触发GC(
System.GC.Collect()
)。
- 使用栈内存(
- 监控工具优先级:
- Unity Profiler关注
GC Alloc
与触发帧率骤降点。
- Unity Profiler关注
⚠️ 注意:Unity 2021+逐步集成.NET Core CLR,未来可能引入分代GC,但现有项目仍需按当前机制优化
https://docs.unity3d.com/cn/2022.3/Manual/performance-incremental-garbage-collection.html
c# 中如果A对象和B对象循环引用了,那么GC会在什么阶段释放他们。
在C#中,即使对象A和对象B存在循环引用(即A引用B,B引用A),只要它们没有任何来自GC根(如静态变量、局部变量、CPU寄存器等)的有效引用,垃圾回收器(GC)仍然会在下一次回收其所属代龄时正常释放它们。具体释放阶段取决于对象所在的代龄和回收触发条件,与循环引用本身无关。
📌 关键机制说明:
标记阶段无视循环引用
GC使用标记-压缩算法(Mark-Sweep-Compact),从根对象(Roots)出发遍历所有可达对象并标记。若A和B均不被任何根对象直接或间接引用(即不可达),即使二者互相引用,也会被识别为垃圾对象。
循环引用不影响GC对对象可达性的判断,因为标记过程仅依赖从根出发的引用链,而非对象间的孤立引用环。
释放发生在所属代龄的回收阶段
GC采用分代模型(0代、1代、2代),对象根据存活时间被分配到不同代:
- 新对象(如局部变量创建的A、B)通常分配在0代;
- 若0代堆空间不足,触发0代回收,此时若A、B不可达,则在此阶段被释放;
- 若A、B在一次回收后存活,会升代(如升至1代),后续释放需等待其所属代龄的回收被触发。
释放时机依赖回收触发条件
触发条件 | 回收范围 | 循环引用对象释放时机 |
---|---|---|
0代堆满 | 仅0代 | 若在0代且不可达,立即释放 |
1代堆满 | 0代+1代 | 若在0/1代且不可达,此轮释放 |
2代堆满/主动调用GC.Collect | 0代+1代+2代(或指定代龄) | 若在目标代且不可达,此轮释放 |
💡 注意事项:
- 根引用切断是前提:必须确保所有指向A或B的外部引用(如局部变量、静态字段)已被置
null
或超出作用域,否则GC仍会将其视为可达对象。 - 析构函数(Finalizer)延迟回收:若A或B实现了析构函数,即使不可达,首次回收时会被移至终结队列,由独立线程调用析构函数后再真正释放,可能导致延迟。
- 大对象堆(LOH)的特殊性:若循环引用对象大小超过85,000字节,会被分配到大对象堆(属于2代),其回收频率较低,释放可能延迟。
💎 总结:
循环引用对象会在其所属代龄的下一次垃圾回收中被释放,只要它们从GC根不可达。GC的标记算法天然规避了循环引用导致的内存泄漏问题,开发者无需额外处理引用环。唯一需关注的是及时解除对象与根之间的引用关系。
【从UnityURP开始探索游戏渲染】专栏-直达
(欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)