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

WeTest导读

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

内存是手游的硬伤

无论是游戏还是VR应用,内存管理都是其研发阶段的重中之重。然而,90%以上的项目都存在不同程度的内存使用问题。就目前基于Unity引擎开发的移动游戏和移动VR游戏而言,内存的开销无外乎以下三大部分:

1.资源内存占用;

2.引擎模块自身内存占用;

3.托管堆内存占用。

今天我们将针对由Mono分配和管理的托管堆内存,介绍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游戏开发过程中不断改善玩家的体验。目前功能免费开放中。

时间: 2024-08-05 14:37:31

内存是手游的硬伤——Unity游戏Mono内存管理与泄漏的相关文章

【小松教你手游开发】【unity实用技能】角色头部跟随镜头旋转

这个在端游上比较场景,在角色展示的时候,当摄像头在角色身边上下左右旋转时,角色头部跟随镜头旋转.如天涯明月刀等. 这个在手游上比较少见,不过实现也没什么区别. 首先一般情况下,找到模型的头部节点,直接用lookAt指向camera就可以了,不过一般需求不会这么简单. 比如说,超过头部扭动极限,头部需要插值回到原始点:当镜头从外部回到极限内,需要插值回来.这时候lookat就没法使用. 更有情况,头部本身坐标系不在世界坐标轴上, 可能旋转了90多或者输出的prefab就是歪的等等,这些情况都没办法

【小松教你手游开发】【unity实用技能】根据上一个GameObject坐标生成的tips界面

开发游戏,特别是mmo手游的时候经常需要开发的一个需求是,点击某个装备,在它附近的位置生成一个tips界面,介绍装备功能和各种信息. 像上面红色框框里的这个. 这个主要的问题是 根据点击的GameObject对应生成这个详情界面时,详情界面位置需要合理摆放(不能显示不到,不能遮挡等) 基本的思路是, 首先找到GameObject的position, 把手机屏幕大概分成四个象限,知道这个GameObject大概在这个屏幕的哪个象限(左上,左下,右上,右下) 根据象限来判断详情界面应该在GameOb

【小松教你手游开发】【unity系统模块开发】热更

现在的手游项目如果没个热更新迭代根本跟不上, 特别是像我们项目做mmo的更是需要经常改动代码. 而现在的项目一般会选择用lua的方式实现热更新 不过我们项目由于历史原因没有使用,用的是另外一种方案 在项目里的所有GameObject都不挂脚本(NGUI脚本就通过代码的方式挂上),自己写的脚本都不继承Mono并打成dll,然后通过一个启动脚本去打开这些dll. 不过这样就有个问题,ios不能热更... 不管怎么样,先来讲讲这种方案要怎么做. 首先有两部分,一部分是打包,一部分是解包. 而包又分为资

【小松教你手游开发】【unity实用技能】Unity内存申请和释放(转自tnqiang)

这里先声明转自http://www.jianshu.com/p/b37ee8cea04c 1.资源类型 GameObject, Transform, Mesh, Texture, Material, Shader, Script和各种其他Assets. 2.资源创建方式 静态引用,在脚本中加一个public GameObject变量,在Inspector面板中拖一个prefab到该变量上,然后在需要引用的地方Instantiate: Resource.Load,资源需要放在Assets/Reso

【小松教你手游开发】【unity实用技能】Unity Mesh更新的时候增加内存

Unity在Mesh更新的时候会增加内存,一般的方法是使用数组去填入. Vector3[] vertices = new Vector3[segmentCount * 4]; Vector3[] normals = new Vector3[segmentCount * 4]; Vector4[] tangents = new Vector4[segmentCount * 4]; Color[] colors = new Color[segmentCount * 4]; Vector2[] uvs

【小松教你手游开发】【unity系统模块开发】Unity Assetbundle打包笔记

*最近项目更新了Unity5.5.2,顺便更新了项目的ui打包,也更新一下这边的笔记 首先打包分为两部分,一部分是打包成Assetbundle包,一部分是从Assetbundle包解包出来成为可用的资源. 首先说第一部分 打包 所有资源都可以打包,甚至不是资源(一些数据)也可以打包,只要你需要. 打包出来的东西都可以直接用,一个字体,一个Texture,一个Prefab,一个场景,都是一打出来成Assetbundle包就可以直接用,但是为什么大家还是要各自开发自己的打包流程呢? 最重要的原因就是

【小松教你手游开发】【unity实用技能】unity发包优化(android一键发包)

unity本身的发包其实还是挺方便的,国外的游戏基本都用unity本身的发包. 但在国内的游戏有这么多渠道,这个迭代的速度的情况下,就需要一套更高效的发包方式. 接下来讲具体步骤,如果你们项目有热更新会更麻烦一点. 发包优化的目标是做到一键发包,一般发包机会是一台独立的机子,所以 第一步,更新svn 第二步,配置打包信息.根据不同渠道接入不同sdk 第三步,build apk. 因为我们项目暂时还是测试,所以还没做根据不同渠道接入不同sdk.具体思路是写个xml,在上面填写各种配置信息,版本号,

【小松教你手游开发】【unity实用技能】InvalidOperationException: out of sync

在unity开发中出现这个bug. 在网上查了下是在迭代器中直接修改引起的.c#是不允许你在迭代器中直接修改. 改了一下确实解决. 原本是这样 [csharp] view plain copy public void Run() { foreach (var item in timerDict) { if (null != item.Value) { item.Value.Run(); } } } 改成这样: [csharp] view plain copy public void Run()

【小松教你手游开发】【unity实用技能】线性差值计算实现

其实这个unity本身就有的函数Mathf.Lerp(),为什么还要自己实现呢. 有一个原因就是这个函数返回的是float型,float型如果数字非常大,转出int时会有精度丢失,也就是转出来的值不对. 而且非常简单. 看下公式 public int Lerp(int a,int b,int v) { return (int)(a - (0 - v) / (0 - 1) * (a - b)): } 原文地址:http://blog.51cto.com/13638120/2084965