|
你问到了Unity内存管理最核心,也是最容易让人混淆的点上。这个问题困扰过几乎每一个深入学习Unity的开发者。网上的资料讲不清楚,是因为这个问题横跨了C#的GC机制和Unity引擎底层的C++对象生命周期,非常微妙。
核心概念
- Unity Object (C++): 场景中的GameObject、Component(如Transform, Rigidbody)等,其核心存在于Unity引擎的C++层,这部分内存是非托管的。它们有自己的生命周期管理,由Unity引擎控制。
- C# Wrapper Object: 当你在C#脚本中访问一个Unity Object时,你实际上是在与一个C#的“包装器”对象(Wrapper Object)交互。这个包装器对象存在于C#的托管堆(Managed Heap)上,它内部持有一个指向C++对象的指针。这个C#包装器对象受GC的管理。
- 垃圾回收 (GC): C#的GC通过可达性分析(Reachability Analysis)来决定一个对象是否可以被回收。它从一组“根”(GC Roots,例如静态变量、线程栈上的局部变量等)开始,遍历所有可达的对象。任何从根不可达的对象,都被认为是垃圾,可以被回收。一个对象只要被任何一个可达对象引用,它自身就是可达的,就不会被回收。
情况一:GetComponent 和 Find 的引用
public class MyTank : MonoBehaviour
{
private Rigidbody rb; // 引用字段
void Start()
{
// 获取对另一个C#包装器对象的引用
// 这个包装器内部指向一个C++ Rigidbody对象
rb = GetComponent<Rigidbody>();
}
void OnDestroy()
{
// rb = null; // 为什么这行不是必须的?
}
}
内存图谱和生命周期分析:
1.引用关系: MyTank的C#实例持有一个对Rigidbody的C#包装器实例的引用。可以表示为:
[C# MyTank Instance] -> [C# Rigidbody Wrapper Instance] -> [C++ Rigidbody Object]
2.当Destroy(this.gameObject)被调用:
- Unity引擎层 (C++): 引擎接收到销毁指令,将GameObject及其所有Component(包括Rigidbody)的C++对象标记为“待销毁”。此时,这些C++对象进入了一种“僵尸”状态。
- C#包装器层: MyTank的C#实例和Rigidbody的C#包装器实例依然存在于托管堆中。MyTank实例中的rb字段依然指向那个Rigidbody包装器。
3.为什么不会内存泄漏?
- 当MyTank的GameObject被销毁后,从GC的根(Unity的场景图可以视为一个根的来源)出发,已经无法再到达MyTank的C#实例了。
- 因此,在下一次GC运行时,MyTank的C#实例会被识别为垃圾。
- 由于MyTank实例是唯一持有对Rigidbody包装器实例引用的“活”对象(在我们的代码上下文中),当MyTank实例被回收后,Rigidbody包装器实例也就变得不可达了,随之也会被GC回收。
- 关键点: MyTank对Rigidbody的引用,是一个即将死去的对象指向另一个即将死去的对象的引用。它不会阻止任何一方被回收。手动将rb设为null只是提前断开了这个引用,但GC最终总能发现它们都是垃圾。
4.MissingReferenceException和== null的魔术:
Unity重载了UnityEngine.Object的==操作符。当你检查rb == null时,它不只是检查C#引用是否为null,还会调用引擎内部接口,查询该引用指向的C++对象是否已被标记为销毁。如果C++对象已销毁,即使C#包装器对象还未被GC回收,== null也会返回true。这就是为什么销毁后引用会“表现得像null”。
情况二:委托事件 (delegate / event)
public class GameManager : MonoBehaviour // 通常是单例或全局对象
{
public static event Action OnGameStart;
}
public class MyPlayer : MonoBehaviour
{
void OnEnable()
.asp&#34;net_copycode&#34;> {
// 订阅事件
GameManager.OnGameStart += PlayerRespawn;
}
void OnDisable()
{
// 如果没有这行,就会发生内存泄漏
// GameManager.OnGameStart -= PlayerRespawn;
}
void PlayerRespawn() { /* ... */ }
}
内存图谱和生命周期分析:
1.引用关系: GameManager.OnGameStart是一个静态事件。静态字段是GC的根。这个事件内部有一个调用列表(invocation list),其中包含了对MyPlayer实例的PlayerRespawn方法的引用(这是一个委托实例,委托实例会隐式持有其目标对象MyPlayer的引用)。
可以表示为:
[GC Root: Static Field GameManager.OnGameStart] -> [Invocation List] -> [Delegate Instance (for PlayerRespawn)] -> [C# MyPlayer Instance]
2.当Destroy(this.gameObject)被调用:
MyPlayer的GameObject和C++组件被标记为销毁。但是,MyPlayer的C#实例的命运完全不同了。
3.为什么会发生内存泄漏?
- GameManager.OnGameStart是一个静态事件,它在整个应用程序生命周期内都是可达的(因为它是一个GC Root)。
- 由于GameManager.OnGameStart的调用列表里还持有对PlayerRespawn委托的引用,而该委托又持有对MyPlayer实例的引用,根据GC的可达性分析,MyPlayer实例是可达的!
- 关键点: 一个长生命周期对象 (GameManager的静态事件) 持有了一个短生命周期对象 (MyPlayer实例) 的引用。只要这个引用不断开,短生命周期对象就永远无法被GC回收,因为它在GC看来是“被需要的”。
- 这就是典型的“观察者模式”内存泄漏。MyPlayer(观察者)在销毁前,没有从GameManager(被观察者)那里注销自己。
情况三:List / Dictionary 集合
public class EnemyManager : MonoBehaviour
{
public List<Enemy> allEnemies = new List<Enemy>();
void RegisterEnemy(Enemy enemy)
{
allEnemies.Add(enemy);
}
// 需要一个方法来移除敌人
public void UnregisterEnemy(Enemy enemy)
{
allEnemies.Remove(enemy);
}
}
内存图谱和生命周期分析:
1.引用关系: EnemyManager实例持有对List对象的引用,List对象内部又持有对多个EnemyC#包装器实例的引用。
[C# EnemyManager Instance] -> [List<Enemy> Instance] -> {[C# Enemy Wrapper 1], [C# Enemy Wrapper 2], ...}
2.当某个Enemy被Destroy():
该Enemy的C++对象被标记销毁。
EnemyManager的List中,指向该EnemyC#包装器的那个“槽位”依然存在。
3.为什么必须手动Remove?
内存泄漏问题? 不完全是。这里的引用关系是EnemyManager -> List -> Enemy。如果EnemyManager本身被销毁,那么整个引用链都会变得不可达,最终都会被GC回收。所以,它不会像事件那样,阻止Enemy被回收。
- 真正的问题在于:
逻辑错误 (Logical Error): EnemyManager的allEnemies列表的“意图”是维护一个存活的敌人列表。当一个Enemy死亡后,它在逻辑上就不应该再在这个列表里了。如果你不移除它,后续代码遍历这个列表(如foreach (var enemy in allEnemies) { enemy.Attack(); })时,就会对一个已被销毁的对象调用方法,抛出MissingReferenceException。
- 数据结构污染 (Data Structure Pollution): 列表里充满了无效的、“已死亡”的引用。这不仅会增加列表的Count,浪费少量内存,更重要的是,它破坏了数据结构的一致性和有效性。
总结
场景 | 引用链 (简化) | 泄漏风险 | 必须手动处理的原因 | GetComponent | A -> B (A和B都将销毁) | 无 | 引用链会随对象一同被GC回收。 | 委托事件 | [GC Root] -> A -> B (A是长生命周期对象) | 高 (内存泄漏) | 长生命周期对象A持有了短生命周期对象B的引用,阻止了B被GC回收。必须切断引用链 A -> B。 | 集合 | A -> List -> {B1, B2, ...} (B1被销毁) | 低 (非典型内存泄漏) | 主要是为了维护数据结构的正确性、一致性,并防止因遍历到无效引用而产生的运行时错误。 |
|
|