Unity 系列 -- 内存管理

前言

内存是游戏的硬伤,如果没有做好内存的管理问题,游戏极有可能会出现卡顿甚至闪退等极其影响用户体验的现象。

什么是 Mono 内存

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

Unity 游戏在运行时的内存占用情况可以用下图表示:

目前绝大部分 Unity 游戏逻辑代码所使用的语言为 C#,C# 代码所占用的内存又称为 Mono 内存,这是因为 Unity 是通过 Mono 来跨平台解析并运行 C# 代码的(详细见Unity 系列 -- Unity 跨平台原理及编译过程)C# 代码通过 Mono 解析执行,所需要的内存自然也是由 Mono 来进行分配管理

占用内存的是什么

值类型存在栈中,引用类型存在堆中。

一般引用类型占用的内存很大,如 stringint[]List 等,它们放在堆中,并使用指针(值类型,放在栈中)来记住它的位置。

如果能通过指针引用到变量,那么这个变量就要一直占用着堆内存,不能被垃圾回收机制回收掉

垃圾回收机制(GC)

Mono 向操作系统申请得到堆内存 = 已用内存 + 空闲内存

每当 Mono 需要分配内存时,会先查看空闲内存是否足够,如果足够的话,直接在空闲内存中分配,否则 Mono 会进行一次 GC 以释放更多的空闲内存,如果 GC 之后仍然没有足够的空闲内存,则 Mono 会向操作系统申请内存,并扩充堆内存,具体如下图所示:

要点:

  • 需要分配内存 + 空闲内存不够 => GC 才会自动运行
  • GC 能把不再使用如不能被引用到的变量)的已用内存释放为空闲内存
  • GC 释放的空闲内存只会留给 Mono 自己使用,并不会交还给操作系统
  • GC 运行后空闲内存还是不够才会去向操作系统申请更多内存,操作系统内存爆了就会把 APP 杀掉(闪退)

垃圾回收具体流程

  1. 停止所有需要 Mono 内存分配的线程。
  2. 遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
  3. 释放被标记的内存到空闲内存。
  4. 重新开始被停止的线程。

手动执行垃圾回收

除了空闲内存不足时 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 系列 -- 可重用的对象池

参考文章

相关推荐
机器人天才一号39 分钟前
C#从入门到放弃
开发语言·c#
吾与谁归in44 分钟前
【C#设计模式(10)——装饰器模式(Decorator Pattern)】
设计模式·c#·装饰器模式
冷眼Σ(-᷅_-᷄๑)7 小时前
Path.Combine容易被忽略的细节
c#·.net
SongYuLong的博客13 小时前
C# (定时器、线程)
开发语言·c#
百锦再15 小时前
详解基于C#开发Windows API的SendMessage方法的鼠标键盘消息发送
windows·c#·计算机外设
无敌最俊朗@16 小时前
unity3d————协程原理讲解
开发语言·学习·unity·c#·游戏引擎
程序设计实验室16 小时前
在网页上调起本机C#程序
c#
Crazy Struggle19 小时前
.NET 8 强大功能 IHostedService 与 BackgroundService 实战
c#·.net·.net core
fs哆哆19 小时前
C#编程:优化【性别和成绩名次】均衡分班
开发语言·c#
fathing21 小时前
c# 调用c++ 的dll 出现找不到函数入口点
开发语言·c++·c#