Unity Leaked Managed Shell

记录一个Unity内存泄漏的Tips。一个很小的点,但开发中极其容易被忽略。

当我们用C#创建一个“原生”对象时,Unity实际上会存在“两套”内存。一部分是我们所使用的C#托管代码内存(Managed Shell),另外一部分是Native层所创建的实际数据内存。

Texture2D tex;

void Start()
{
	tex = new Texture2D(128, 128);
	Destroy(tex);
}

上面这段代码中,tex 是托管代码内存,而Native层会有一份“原生”的 Texture2D 数据。当我们执行 Destroy(tex) 的时候,实际上是将Native层中的内存清除掉了。而托管层的 tex 并没有被清除。如果此时你打印 tex == null ,则会发现结果为 true。你可能会感觉奇怪,命名是 null,那么所谓的内存泄漏在哪里呢?

请看Unity针对 Object 对象的一段神奇代码。

public static bool operator ==(Object x, Object y) => Object.CompareBaseObjects(x, y);

private static bool CompareBaseObjects(Object lhs, Object rhs)
{
  bool flag1 = (object) lhs == null;
  bool flag2 = (object) rhs == null;
  if (flag2 & flag1)
    return true;
  if (flag2)
    return !Object.IsNativeObjectAlive(lhs);
  return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}

private static bool IsNativeObjectAlive(Object o)
{
  if (o.GetCachedPtr() != IntPtr.Zero)
    return true;
  return !(o is MonoBehaviour) && !(o is ScriptableObject) && Object.DoesObjectWithInstanceIDExist(o.GetInstanceID());
}

好了,可以理解为当Native层的对象指针为空时,确实托管对象可以理解为 null

通过内存分析器中,我们可以通过查询来看到这些泄漏的托管对象。如下图:

带有Leaked Managed Shell标记的对象则属于上面这种情况的内存泄漏。你可能会觉得这些内存泄漏所占用的内存并不大,每个对象仅64B。但请不要忘记,这些泄漏的对象一旦存在其他“大体积”的对象引用,则会发生连锁反应。会造成更多的内存泄漏。

由于Leaked Managed Shell类型的泄漏对象,对于C#的垃圾回收机而言属于活跃使用中的对象,所以并不会被识别为待回收对象。随着生命周期的推移,其结果可想而知。

Unity官方对于这种设计在14年是有一篇Blog(Custom == operator, should we keep it?)进行解释和记录的,但10年过去了,这种隐疾依然没有更好的解决办法。

避免这种内存泄漏的解决办法也非常简单,我们只需要在代码中显示的将托管对象设置为 null 即可。像这样,

void Start()
{
	tex = new Texture2D(128, 128);
	Destroy(tex);
	tex = null;
}

最后,如果你想更多的了解C#的垃圾回收相关知识,推荐查阅垃圾回收的基本知识