前言
内存是游戏的硬伤,如果没有做好内存的管理问题,游戏极有可能会出现卡顿甚至闪退等极其影响用户体验的现象。
什么是 Mono 内存
对于目前绝大多数基于 Unity 引擎开发的项目而言,其托管堆内存是由 Mono 分配和管理的。"托管" 的本意是 Mono 可以自动地改变堆的大小来适应所需要的内存 ,并且适时地调用垃圾回收 (Garbage Collection )操作来释放已经不需要的内存,从而降低开发人员在代码内存管理方面的门槛。
Unity 游戏在运行时的内存占用情况可以用下图表示:
目前绝大部分 Unity 游戏逻辑代码所使用的语言为 C#,C# 代码所占用的内存又称为 Mono 内存,这是因为 Unity 是通过 Mono 来跨平台解析并运行 C# 代码的(详细见Unity 系列 -- Unity 跨平台原理及编译过程)C# 代码通过 Mono 解析执行,所需要的内存自然也是由 Mono 来进行分配管理
占用内存的是什么
值类型存在栈中,引用类型存在堆中。
一般引用类型占用的内存很大,如 string
、int[]
、List
等,它们放在堆中,并使用指针(值类型,放在栈中)来记住它的位置。
如果能通过指针引用到变量,那么这个变量就要一直占用着堆内存,不能被垃圾回收机制回收掉
垃圾回收机制(GC)
Mono 向操作系统申请得到堆内存 = 已用内存 + 空闲内存
每当 Mono 需要分配内存时,会先查看空闲内存是否足够,如果足够的话,直接在空闲内存中分配,否则 Mono 会进行一次 GC 以释放更多的空闲内存,如果 GC 之后仍然没有足够的空闲内存,则 Mono 会向操作系统申请内存,并扩充堆内存,具体如下图所示:
要点:
- 需要分配内存 + 空闲内存不够 => GC 才会自动运行
- GC 能把不再使用 (如不能被引用到的变量)的已用内存释放为空闲内存
- GC 释放的空闲内存只会留给 Mono 自己使用,并不会交还给操作系统
- GC 运行后空闲内存还是不够才会去向操作系统申请更多内存,操作系统内存爆了就会把 APP 杀掉(闪退)
垃圾回收具体流程
- 停止所有需要 Mono 内存分配的线程。
- 遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
- 释放被标记的内存到空闲内存。
- 重新开始被停止的线程。
手动执行垃圾回收
除了空闲内存不足时 Mono 会自动调用 GC 外,也可以在代码中调用GC.Collect()
手动进行 GC,但是,GC 本身是比较耗时 的操作,而且由于 GC 会暂停 那些需要 Mono 内存分配的线程(C# 代码创建的线程和主线程),因此无论是否在主线程中调用,GC 都会导致游戏一定程度的卡顿,需要谨慎处理。
垃圾回收原理
是通过引用关系的方式来进行的。Mono会跟踪每次内存分配的动作,并维护一个分配对象表,当GC的时候,以全局数据区和当前寄存器中的对象为根节点,按照引用关系进行遍历,对于遍历到的每一个对象,将其标记为活的(alive)
如上图所示,假设 A 是处于全局数据区 的一个对象,那么在 GC 的时候以 A 为根节点进行遍历,由于 B、C、D 对象都可以由 A 遍历到,E、F 对象则没有被标记,它们被认为是"失联"了,GC 会将所有"失联"的对象内存进行回收,最终上图中的 E 和 F 会在 GC 过程中被回收
示例:
csharp
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public int score;
void Start() {
int res = 0;
Debug.Log(res);
}
void Update() {
string scoreText = score.ToString();
Debug.Log(scoreText);
}
}
- Start:仅当启用脚本实例后,才会在第一次帧更新之前调用
- Update:每帧调用一次
在 Start 函数里的 res 变量在新一轮 GC 中不会被遍历到,所以 res 变量所占用的内存会在新一轮 GC 被回收;相反,private 变量 score 每帧都会被访问到,即在新一轮 GC 会被遍历到,所以不会被 GC 回收。
从根源上避免垃圾产生
例子1:int 数组转成字符串
优化前:
csharp
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void ConcatExample(int[] intArray) {
string line = intArray[0].ToString();
for (i = 1; i < intArray.Length; i++) {
line += ", " + intArray[i].ToString();
}
return line;
}
}
- 存在问题:每次调用这个函数,line 变量的先前内容变为死亡状态(变成垃圾),如果字符串特别长,那将会浪费很多没有必要的堆内存空间
- 优化方式:直接用 string.Join() 这个方法即可
csharp
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void ConcatExample(int[] intArray) {
return string.Join(", ", intArray);
}
}
例子2:分数显示
优化前:
csharp
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public int score;
void Update() {
string scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
}
- 存在问题:在每次调用 Update 时都会分配新的字符串
scoreText
,由于 Update 每帧执行一次,更新频率极高,会生成源源不断的垃圾。 - 优化方式:通过 仅在 score 发生变化时 才更新 text,可避免大部分的垃圾:
csharp
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public string scoreText;
public int score;
public int oldScore;
void Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
}
例子3:产生多个随机数组成的数组
优化前:
csharp
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
float[] RandomList(int numElements) {
var result = new float[numElements];
for (int i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
}
- 存在问题:每次调用这种函数,都会为 result 这个数组分配全新的内存。由于数组可能非常大,因此空闲堆空间可能会迅速耗尽,导致频繁进行垃圾收集
- 优化方式:传入参数由随机数的数量改成数组,实现重复利用数组,这样每次调用该函数时不会产生任何新的垃圾
csharp
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void RandomList(float[] arrayToFill) {
for (int i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
}
例子4:发射子弹的功能
优化前:
-
使用
Instantiate
方法生成子弹 -
使用
Destroy
方法销毁子弹 -
存在问题:每生成一颗子弹都会为其分配新的堆内存,被销毁后的大量子弹游戏对象会短时间内占用很多内存空间;另外,频繁地调用 Instantiate 和 Destroy 方法性能会很差,容易造成卡顿
-
优化方式:采用可重用的对象池,详情见文章 Unity 系列 -- 可重用的对象池