找回密码
 立即注册
查看: 93|回复: 5

Unity做游戏为什么当物体销毁后其下的各种引用不用手动释放内存而委托事件必需打消订阅以防内存泄露?

[复制链接]
发表于 2025-6-30 10:37 | 显示全部楼层 |阅读模式
物体挂载的脚本里往往会用GetComponent和FindGameObject之类的方式拿到各种引用Reference,比如引用本身的rigid body(用于物理运动的实现),引用母体布局下的某子布局(像坦克炮管和底座分袂控制),引用此外物体(像方针单元和碰撞物体等)。在该物体被销毁Destroy时,Unity自动用GC把内存释放掉,因此使用者不用在OnDestroyed的时候,手动把各引用逐个Clear掉或设为Null。
但是委托事件Delegate相关的引用必需本身Unsubscribe在OnDestroyed时候才能确保物体销毁时没有内存泄露。还有List Dictionary也要Remove被销毁物体。那么同样是引用的道理,为什么Reference没这个顾虑,其它的就要本身手动措置一下呢?
都要疯了,网上资料也讲不清楚,此刻感觉一见到引用相关的东西,无论有无必要OnDestroyed的时候都下意识手动清理一下,以防内存泄露,但是真的很麻烦。
发表于 2025-6-30 10:37 | 显示全部楼层
你了解一下objective c早期或者cocos早期的手动rc,就会很好理解这个问题了。
你前面那些所谓的引用,只是简单的引用,但是并没有影响他的rc值。
其实原理很简单
比如,每一个对象,都有一个reference count的值,简称rc,默认是0。用于记录该对象被其他对象持有的次数。
如果rc为0,表示这个对象没有被其他对象持有,所以会被系统删除。
(这里就遇到了第一个问题,对象刚被创建出来的时候,rc就是0,那这个对象不是就该被销毁了吗?
这个地方有一个策略,是多引入一个值,叫auto rc,对auto rc的加减,会同步加减到rc。但是auto rc每一帧会自己减1,到0为止。
所以对象创建时,给他一个auto rc,这个时候rc是1,auto rc是1,这样可以保证这个对象至少存在一帧。到下一帧如果还没有被持有,auto rc减1,rc也减1,这个对象就可以被销毁了。)
那么什么时候应该来加减这个rc呢?
最简单的时候,就是add child的时候,parent去add child,那就说明是要持有这个child对象,所以这个时候应该对child的rc加1。
如果这个地方不加child的rc会出现什么情况?下一帧,或者后面你要用到这个child的时候,你会发现你的宝宝可能不见了,null pointer exception了。
同理,remove child的时候,应该对child 的rc减1。
而你用这个child去赋值各种变量的时候,这个rc是不应该操作的,因为你并没有真正持有他。
这叫解释了第一个问题,delete对象的时候,他会对所有child执行remove操作,确保rc正常。但是你那些变量“引用”,并没有真正的“拥有”过这个对象。
然后就是delegate,你可以简单的吧delegate理解为
class delegate
{
        public Object target;
        public function func;
}
在invoke这个delegate的时候,实际上是
target.func( )
那这种时候,delegate显然要持有这个target,让target的rc加1,确保他不会销毁,才能让这个事情正常运转。不然你要用的时候,可能发现target没了。
所以这个delegate如果不销毁,他就会一直持有target,从而让target的rc至少有1,也就不会被销毁。
发表于 2025-6-30 10:38 | 显示全部楼层
在该物体被销毁Destroy时,Unity自动用GC把内存释放掉,因此使用者不用在OnDestroyed的时候,手动把各引用逐个Clear掉或设为Null。
这段理解实际上是错的。Iterator的回答写的比较详细。简单来说Destroy和GC事实上是两套回收机制,Destroy表示对象不再可用(正常操作下也不再会访问的到),GC表示这个对象所占内存被释放了。
也就是一个对象的生命周期会有3个阶段:
有效 --Destroy--> 无效 --GC--> 被释放无效对象不该被使用,但可能被访问。而不可访问的对象迟早被GC。
<hr/>首先通过Unity提供的api通过内部处理以及重载operator ==,你每次调用GetComponent都能安全地获得有效的对象。
而需要手动Remove的主要原因就在于,无效对象可能被访问。这个问题的高发场景就是事件。发布者和监听者的生命周期往往没有任何关系,当监听者比发布者早destroy,那么发布者就可能访问到无效对象,List同理。因此只能在destroy时手动unsubscribe。
事实上不是集合也可能有这个问题,假设我有两个对象挂着以下两个脚本,3秒后一样会在控制台看到错误信息。
class Parent : MonoBehaviour
{
    [SerializeField] Child _child;
    void Update() => Debug.Log(child.transform.position);
}

class Child : MonoBehaviour
{
    void Update() => /* 3秒后Destroy(gameObject) */;
}
只是这种场景并不多,A直接引用B的情况,往往最终是A和B会一起被destroy。大家都是无效对象就不会有访问无效对象的问题。
而Parent管理一个Child列表/事件,Child自己destroy的场景就多了,也不易察觉。
<hr/>理论上,其实不移除也没关系,问题在于有效对象访问无效对象,那么我只需要在遍历时判断一下对象是否有效就可以了。这么搞其实程序也能跑,但是有几个问题。

  • 列表里会有大量无效对象,徒增遍历消耗,Count等属性也没用了。
  • 如果事件发布者/List持有对象是静态对象,那么在整个程序运行阶段,集合只增不减,也不会被释放,内存爆炸。
我们不会希望这种事情发生。
<hr/>题外话。我认为所有开发人员应该去学c,先搞懂指针就理解九成的引用问题。
失效监听者问题也和RAII类似,主打谁负责注册,谁就负责注销。
发表于 2025-6-30 10:38 | 显示全部楼层
很有意思的问题,题主应该是接触Unity不久的新人,所以这个回答不会谈关于内存、GC等这些深刻的问题
只讨论引用
先来理一下逻辑,然后一一实验

  • ‘Unity Reference’不需要手动清理
  • Delegate 需要手动清理
首先是关于第1点
‘Unity Reference’ 不需要手动清理......吗?
做一个实验
// 首先有这么一个MonoBehaviour
public class TestReference : MonoBehaviour
{
    public int someINT;
}

// 这是一个别的脚本
public class SomeMonoBehaviour : MonoBehaviour
{
    // 使用到了TestReference
    public TestReference test;
    [Button]
    private void Test()
    {
        // 添加脚本
        this.test = this.gameObject.AddComponent<TestReference>();
        // 写入值
        this.test.someINT = 100;
        // 输出100
        Debug.Log($"TestReference.someINT: {this.test.someINT}");

        // 销毁脚本
        Component.DestroyImmediate(this.test);
        // 输出...还是 100
        Debug.Log($"TestReference.someINT After Destroy: {this.test.someINT}");

        // 引用置空
        this.test = null;
        // 芜湖~报错咯
        Debug.Log($"TestReference.someINT After Destroy: {this.test.someINT}");
    }
}
输出结果如下:
TestReference.someINT: 100
// 它怎么还在?
TestReference.someINT After Destroy: 100
// 芜湖~报错咯
NullReferenceException: Object reference not set to an instance of an object由此可见,第1点就已经错了。
继续试验
报错了我们该怎么办呢,于是把代码变成了下面这个样子,防止报错
// 这是一个别的脚本
public class SomeMonoBehaviour : MonoBehaviour
{
    public TestReference test;
    [Button]
    private void Test()
    {
        // 添加脚本
        this.test = this.gameObject.AddComponent<TestReference>();
        // 写入值
        this.test.someINT = 100;
        if (test == null)
        {
            Debug.Log("无了");
        }
        else
        {
            // 输出100
            Debug.Log($"TestReference.someINT: {this.test.someINT}");
        }


        // 销毁脚本
        Component.DestroyImmediate(this.test);
        if (this.test == null)
        {
            Debug.Log("无了");
        }
        else
        {
            Debug.Log($"TestReference.someINT After Destroy: {this.test.someINT}");
        }


        // 引用置空
        this.test = null;
        if (this.test == null)
        {
            Debug.Log("无了");
        }
        else
        {
            Debug.Log($"TestReference.someINT After Destroy: {this.test.someINT}");
        }
    }
}
输出结果如下:
TestReference.someINT: 100
// 它怎么又没了?
无了
无了坏了,这个this.test,处于一个薛定谔的状态!你不观察它,它就在。但是你一观察它,它就无了!
完了, “Unity学“不存在
试验结果令你无法接受,世界观开始崩塌,双手止不住地颤抖,开始胡乱地敲击键盘和鼠标,突然!你不经意间左手按到了‘Ctrl’,右手将鼠标移动到了‘this.test == null’的'=='号上,还不小心地点了鼠标左键(哎呀,跳转到==号的定义)
你打开了‘新世界’
你看到了如下代码:
namespace UnityEngine;

// UnityEngine.Object
public class Object
{
    private IntPtr m_CachedPtr;

    public static bool operator ==(Object x, Object y)
    {
        return CompareBaseObjects(x, y);
    }

    public static bool operator !=(Object x, Object y)
    {
        return !CompareBaseObjects(x, y);
    }

    private static bool CompareBaseObjects(Object lhs, Object rhs)
    {
        bool flag = (object)lhs == null;
        bool flag2 = (object)rhs == null;
        if (flag2 && flag)
        {
            return true;
        }

        if (flag2)
        {
            return !IsNativeObjectAlive(lhs);
        }

        if (flag)
        {
            return !IsNativeObjectAlive(rhs);
        }

        return lhs.m_InstanceID == rhs.m_InstanceID;
    }
    private static bool IsNativeObjectAlive(Object o)
    {
        if (o.GetCachedPtr() != IntPtr.Zero)
        {
            return true;
        }

        if (o is MonoBehaviour || o is ScriptableObject)
        {
            return false;
        }

        return DoesObjectWithInstanceIDExist(o.GetInstanceID());
    }
}
鸭!Unity这个坏东西!偷偷把==号重载了!
简单翻译一下,在判断一个UnityEngine.Object是否为空的时候,判断的不是object(C#层面)空不空,而是判断UnityEngine.Object在C++层面上的‘替身’还有没有
所以这就可以解释上面的奇怪现象了
this.test在销毁后,继续访问this.test.someINT不会报错,这才正常,因为C#对象还在,而一旦判空,Unity则会帮你检查对应C++层的对象还‘在不在’
到此,可以总结了
关于第2点的试验已经不需要做了
题主的想法其实有点反了,应该是先知道C#要清引用,而后发现UnityObject在某些情况里不需要清理引用,然后再探究为什么UnityObject不需要
当UnityObject对象被销毁,持有引用的使用者不清理引用(且不检查空)的话,调用非Unity的API或者访问非Unity的字段。是不会报错的!这很重要!
销毁只代表C#对象和C++对象断开连接了!不代表C#对象没了!

最后
看到这个问题让我想起了刚开始学Unity时的简单快乐,害,上班哪有不疯的

题主加油哟,谢谢~
发表于 2025-6-30 10:39 | 显示全部楼层
这个问题还是在于题主计算机基础比较薄弱啊,类似这种问题并不叫“内存泄漏”,而是“悬垂引用” (Dangling References)。
首先你要知道的事就是 Unity 不是完全由 GC 自动管理内存的。(C# 本身也不完全是)
你获取了某个对象 objA 的引用,然后在某个地方 Destroy,并不是 GC 把内存释放然后把所有对它的引用都置 null,而是你先释放了内存,留下了一堆引用,这是一种手动管理内存的形式。此时 GameObject 的 managed 部分(脚本处)并不一定被释放,而实际(引擎内)对象的生命周期确已结束,只是 Unity 重载了 GameObject 的 equality 运算符,让它在与 null 比较时根据实际情况返回对应的结果。而你可能无意识地 GetComponent 后判空规避了问题,实际问题并不在此。
所以类似的,你在订阅事件、或是什么别的地方“注册”来触发行为,都是留下了一个引用,而这些地方并不会在对对象触发调用前判空,所以在对象被 Destroy 后,再操作就会炸掉你的程序。究其原因,Unity 的 GameObject 判空本身就是一个开销比较大的操作,开销大到你很可能接受不了每一帧运行很多这个判断。
<hr/>GC 的行为恰恰与以上相反,GC 里是没有 Destroy 的,你要自己把所有对这一对象的引用置 null,GC才会此后把这个对象回收。(更确切的说是从一个“根”出发,回收不可达的对象)

所以 GC 确实可以知道你引用了一个对象,但是

  • 它管不了由引擎控制的 “unmanaged” 内存/对象(诸如贴图等资源忘记释放才真的是“泄漏”了)
  • 它也不能自动为你撤销你对这个对象的更改。你给对象里添加一个自身的引用,和你让对象向右移动 5cm 两个操作在 GC 看来没有任何区别。
发表于 2025-6-30 10:40 | 显示全部楼层
你问到了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"net_copycode">    {
        // 订阅事件
        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,浪费少量内存,更重要的是,它破坏了数据结构的一致性和有效性
总结

场景引用链 (简化)泄漏风险必须手动处理的原因
GetComponentA -> B (A和B都将销毁)引用链会随对象一同被GC回收。
委托事件[GC Root] -> A -> B (A是长生命周期对象)高 (内存泄漏)长生命周期对象A持有了短生命周期对象B的引用,阻止了B被GC回收。必须切断引用链 A -> B。
集合A -> List -> {B1, B2, ...} (B1被销毁)低 (非典型内存泄漏)主要是为了维护数据结构的正确性、一致性,并防止因遍历到无效引用而产生的运行时错误。
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2025-6-30 15:18 , Processed in 0.165610 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表