腾讯WeTest 内存是手游的硬伤——Unity 游戏 Mono 内存管理与泄漏

腾讯WeTest · 2016年08月05日 · 最后由 陈子昂 回复于 2016年08月06日 · 2039 次阅读

WeTest 导读

内存是游戏的硬伤,如果没有做好内存的管理问题,游戏极有可能会出现卡顿,闪退等影响用户体验的现象。本文介绍了在腾讯游戏在 Unity 游戏开发过程中常见的 Mono 内存管理问题,并介绍了一系列解决的策略和方法。

什么是 Mono 内存

对于目前绝大多数基于 Unity 引擎开发的项目而言,其托管堆内存是由 Mono 分配和管理的。“托管” 的本意是 Mono 可以自动地改变堆的大小来适应你所需要的内存,并且适时地调用垃圾回收(Garbage Collection)操作来释放已经不需要的内存,从而降低开发人员在代码内存管理方面的门槛。
Unity 游戏在运行时的内存占用情况可以用下图表示:

目前绝大部分 Unity 游戏逻辑代码所使用的语言为 C#,C# 代码所占用的内存又称为 mono 内存,这是因为 Unity 是通过 mono 来跨平台解析并运行 C# 代码的,在 Android 系统上,游戏的 lib 目录下存在的 libmono.so 文件,就是 mono 在 Android 系统上的实现。C# 代码通过 mono 解析执行,所需要的内存自然也是由 mono 来进行分配管理,下面就介绍一下 mono 的内存管理策略以及内存泄漏分析。

Mono 内存管理策略

Mono 通过垃圾回收机制(Garbage Collect,简称 GC)对内存进行管理。Mono 内存分为两部分,已用内存(used)和堆内存(heap),已用内存指的是 mono 实际需要使用的内存,堆内存指的是 mono 向操作系统申请的内存,两者的差值就是 mono 的空闲内存。当 mono 需要分配内存时,会先查看空闲内存是否足够,如果足够的话,直接在空闲内存中分配,否则 mono 会进行一次 GC 以释放更多的空闲内存,如果 GC 之后仍然没有足够的空闲内存,则 mono 会向操作系统申请内存,并扩充堆内存,具体如下图所示。

通过上文可知,GC 的主要作用在于从已用内存中找出那些不再需要使用的内存,并进行释放。Mono 中的 GC 主要有以下几个步骤:
1.停止所有需要 mono 内存分配的线程。
2.遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
3.释放被标记的内存到空闲内存。
4.重新开始被停止的线程。
除了空闲内存不足时 mono 会自动调用 GC 外,也可以在代码中调用 GC.Collect() 手动进行 GC,但是,GC 本身是比较耗时的操作,而且由于 GC 会暂停那些需要 mono 内存分配的线程(C# 代码创建的线程和主线程),因此无论是否在主线程中调用,GC 都会导致游戏一定程度的卡顿,需要谨慎处理。另外,GC 释放的内存只会留给 mono 使用,并不会交还给操作系统,因此 mono 堆内存是只增不减的。

Mono 内存泄漏分析

Mono 是如何判断已用内存中哪些是不再需要使用的呢?是通过引用关系的方式来进行的。Mono 会跟踪每次内存分配的动作,并维护一个分配对象表,当 GC 的时候,以全局数据区和当前寄存器中的对象为根节点,按照引用关系进行遍历,对于遍历到的每一个对象,将其标记为活的(alive)。

如上图所示,假设 A 是处于全局数据区的一个对象,那么在 GC 的时候将作为根节点进行遍历,由于 B、C、D 对象都可以由 A 遍历到,因此被标记为活的,E、F 对象则没有被标记。注意,由于引用关系是单向的,A 引用了 B 并不代表 B 也引用了 A,所以遍历也只能单向进行。

由于 GC 以全局数据区和当前寄存器中的对象为根节点进行遍历,所以对象的被标记意味着该对象可以通过全局对象或者当前上下文访问到,而没有被标记的对象则意味着该对象无法通过任何途径访问到,即该对象 “失联” 了,GC 最终会将所有 “失联” 的对象内存进行回收,上图中的 E 和 F 将会在 GC 过程中被回收。

既然 mono 已经有了完善的 GC 机制,那是否还会存在内存泄漏呢?答案是肯定的,只是此处的内存泄漏需要重新定义一下,我们把对象已经不再需要使用却没有被 GC 回收的情况称为 mono 内存泄漏。Mono 内存泄漏会使空闲内存减少,GC 频繁,mono 堆不断扩充,最终导致游戏内存占用的升高。下图就是一个 mono 内存泄漏的例子。

解决办法

对于 mono 内存泄漏,一般只能通过猜测 + 不断修改代码测试的方法来修复问题,效率很低,腾讯 Wetest 平台的 Cube 工具提供了 mono 内存快照对比的功能,并包括对象分配堆栈,对象引用关系等详细信息,是定位 mono 内存泄漏问题的一大利器。下面结合具体的代码尝试使用 Cube 定位 mono 内存泄漏问题。
首先我们定义类 A,并在 A 的构造函数中申请了一块 int[1000] 大小的内存。

接着我们定义 A 类型的静态变量 objectA,在游戏界面上绘制一个按钮,并在按钮点击事件中给 objectA 赋值,此时新生成了 new int[1000] 对象,并由 objectA 引用。

使用 Cube 的 mono 内存检测功能,并在按钮按下之前和按下之后分别进行一次快照,对比两次快照,查看快照间新增对象。

可以看到,按钮按下前后新增的最大对象即为代码中生成的 new int[1000] 对象,并且该对象被引用的次数为 1,为了查看详细的引用关系,下载快照文件 snapshot2,其中有这样两行数据:

第一行说明在 OnGUI 函数中生成了一个 A 类型的对象,其指针为 1533098928,第二行说明在 OnGUI()->A:.cotr() 中生成了一个 Int32[] 类型的对象,并且该对象被指针为 1533098928 的对象引用。即 new int[1000] 对象被 objectA 引用,这也是导致 new int[1000] 对象无法被 GC 回收的原因。而 objectA 本身是一个静态对象,是 GC 的根节点,因此没有对象引用。
如果需要生成的 new int[1000] 对象被回收怎么做呢?很简单,将 objectA.a 设置为 null,没有了 objectA 对其的引用,自然会被 GC 回收了。需要说明的是,将 objectA.a 设置为 null 只是断绝了引用关系,真正对象的回收要等到 GC 的时候才会进行,Cube 在获取内存快照的时候会首先进行一次 GC,防止由于没有及时调用 GC 导致的误判。

游戏中大部分 mono 内存泄漏的情况都是由于静态对象的引用引起的,因此对于静态对象的使用需要特别注意,尽量少用静态对象,对于不再需要的对象将其引用设置为 null,使其可以被 GC 及时回收,但是由于游戏代码过于复杂,对象间的引用关系层层嵌套,真正操作起来难度很大。可以首先使用Cube工具进行分析,根据 mono 内存趋势找出泄漏的具体场景,然后再使用快照对比功能进行详细分析。

腾讯游戏品质管理团队专门打造的工具“Cube”目前已经可以使用,“Cube”可以帮助开发者发现 Unity 手游内分类资源的占用情况,尤其是对 Unity 游戏场景中的 FPS、CPU、PSS 的变化趋势重点关注,帮助在 Unity 游戏开发过程中不断改善玩家的体验。目前功能免费开放中。

共收到 1 条回复 时间 点赞

内存泄露还是说明的很清楚的。这个对纹理内存控制也是有帮助。
但是 unity 很复杂,加载资源好几种方式,很多东西手动去查都不是那么容易的,工具只是一部分。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册