112.内存优化之基础内存优化技巧

112.内存优化-内存管理-基础内存优化技巧


112.1 知识点

垃圾回收时机

首先尽量避免垃圾回收,因为 GC 会带来极大的 CPU 开销。实在不得不做时,建议在合适的时机手动触发,降低玩家的感知度。

常见时机:游戏暂停、过场景、读条界面等。总之要在玩家察觉不到,或者不关心短暂性能下降、不影响游戏行为的时间点进行。

GC.Collect();
Resources.UnloadUnusedAssets();

结构体参数传递时的考虑

由于值传递与引用传递的差异,大结构体在函数之间传递时,值传递往往比引用传递更耗性能。因此在传递大型结构体数据时,可以权衡是否使用 ref 传递。

public class Lesson112TestClass
{
    int myInt;
    float myFloat;
    bool myBool;
    string myString;
}

public struct Lesson112TestStruct
{
    int myInt;
    float myFloat;
    bool myBool;
    string myString;
}

void TestStructFunction(ref Lesson112TestStruct testStruct)
{
}

void TestClassFunction(Lesson112TestClass testClass)
{
}

用 StringBuilder 替代 string

只要你要对字符串做大量修改(追加、拼接、替换等),并且循环次数不确定或较大,就应该使用 StringBuilder

示例:

string str = "";
for (int i = 0; i < 10000; i++)
{
    str += i; // 糟糕
}

// 如果一个字符串频繁变化,使用 string 去存储它,那么在变化的过程中会产生很多垃圾
// 由于 string 的规则:每次变化都会分配新内容、拷贝旧内容,产生内存垃圾
// 10000 次循环,就是 10000 次分配和拷贝,以及垃圾的产生

var stringBuilder = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    stringBuilder.Append(i);
}

str = stringBuilder.ToString();
// 使用它不仅可以减少性能消耗,还可以减少垃圾的产生

使用准则:

  • StringBuilder 的一般情况
    • 多次循环拼接(成千上万)
    • 大量 Replace(替换)
    • 不确定长度的输入流,可能频繁分配时
    • 记录日志信息时
  • string 的一般情况
    • 字符串常量
    • 几次简单拼接
    • 少量参数的 Format 拼接

避免装箱拆箱

C# 中一切皆对象,万物之父都是 System.Object

装箱

把值类型(int、float、struct)放进一个 object 变量时,需要创建一个新的堆对象,把值类型的值拷贝进去,这个过程称为装箱。

主要成本:创建新的堆对象(需要 GC 管理),会给 GC 带来压力。

int x = 10;
object obj = x;

拆箱

object 再提取出值类型。拆箱不是“取值”,而是先检查类型,再从堆对象里拷贝出内部值。

主要成本:类型检查 + 值拷贝回栈。

int y = (int)obj;

如何避免装箱拆箱:

  • 使用泛型
  • 正确使用内置 API
Debug.Log(\"Frame \" + Time.frameCount); // 无装箱
Debug.LogFormat(\"Frame {0}\", Time.frameCount); // 装箱

数据内存布局的重要性

对大量数据的频繁遍历来说,内存布局会显著影响 CPU 缓存命中率,从而影响性能。

方式一:class 数组(引用分散)

Lesson112TestClass[] 在堆上是引用数组,引用指向的位置分散且随机。

  • CPU 缓存命中率差
  • 堆上分配,需要 GC 管理
  • 访问元素需要指针追逐
  • 内存占用最大(对象头等额外开销)

方式二:struct 数组(数据更连续)

Lesson112TestStruct[] 的元素在数组内更连续。

  • 缓存命中更友好
  • 遍历更快
  • 内存占用更齐次(注意对齐)

方式三:数据数组(SoA)

把字段拆成多条数组(Structure of Arrays),每个字段完全连续。

  • CPU 缓存命中非常好,并且更容易对齐
  • 性能最强
  • 内存占用最少

注意不要一上来就用数据数组:这是针对特定热点的优化写法。日常开发能用 class 就先用 class;性能不够时先换成 struct;再不够,再对最核心那段逻辑引入数据数组。

Unity API 中的数组

Unity API 中很多指令会导致堆内存分配,例如:

  • GetComponents<T>()
  • Mesh.vertices
  • renderer.materials
  • Camera.allCameras
  • Physics.RaycastAll(...)

使用这些 API 时 Unity 内部会分配一个全新的数组对象。应尽量避免频繁调用,或者在使用时把数据缓存下来,而不是反复获取。


112.2 知识点代码

Lesson112_内存优化_内存管理_基础内存优化技巧.cs

using System;
using System.Reflection;
using System.Text;
using UnityEngine;

public class Lesson112TestClass
{
    int myInt;
    float myFloat;
    bool myBool;
    string myString;
}

public struct Lesson112TestStruct
{
    int myInt;
    float myFloat;
    bool myBool;
    string myString;
}

public class Lesson112_内存优化_内存管理_基础内存优化技巧 : MonoBehaviour
{
    void Start()
    {
        #region 知识点一 垃圾回收时机

        //首先,我们应该尽量避免垃圾回收,因为垃圾回收将带来极大的CPU开销
        //但是如果我们不得不进行垃圾回收时,都建议在合适的时机手动进行
        //降低玩家的感知度
        //比如:
        //1.游戏暂停时
        //2.游戏过场景时
        //3.游戏显示读条界面时
        //等等
        //总之
        //需要在玩家察觉不到或不关心突然的性能下降而打断游戏行为时手动调用垃圾回收

        GC.Collect();
        Resources.UnloadUnusedAssets();

        #endregion

        #region 知识点二 结构体参数传递时的考虑

        //由于值传递和引用传递的区别
        //导致如果大结构体在函数之间传递时比起引用传递是更耗性能的
        //因此在进行大型结构体数据传递时,我们可以权衡是否使用ref传递

        #endregion

        #region 知识点三 用StringBuilder替代String

        //只要你要对字符串做大量修改
        //比如追加、拼接、替换等等
        //并且循环次数不确定或较大
        //就必须用 StringBuilder
        //举例:
        string str = "";
        for (int i = 0; i < 10000; i++)
        {
            str += i; // 糟糕
        }

        //如果一个字符串频繁变化,使用string去存储它
        //那么在变化的过程中会产生很多垃圾
        //由于string的规则
        //每次变化都会分配新内容,拷贝旧内容,产生内存垃圾
        //10000次循环,就是10000次分配和拷贝,以及垃圾的产生
        //而如果我们使用StringBuilder
        var stringBuilder = new StringBuilder();
        for (int i = 0; i < 10000; i++)
        {
            stringBuilder.Append(i);
        }

        str = stringBuilder.ToString();
        //使用它不仅可以减少性能消耗,还可以减少垃圾的产生

        //使用准则
        //用StringBuilder的一般情况
        //1.多次循环拼接(成千上万)
        //2.大量Replace(替换)
        //3.不确定长度的输入流,可能频繁分配时
        //4.记录日志信息时

        //用string的一般情况
        //1.字符串常量
        //2.几次简单拼接
        //3.少量参数的Format拼接

        #endregion

        #region 知识点四 避免装箱拆箱

        //C#中一切皆对象
        //万物之父都是System.Object
        
        //装箱:
        int x = 10;
        object obj = x;
        //把一个值类型(int、float、struct)放进一个 object 变量
        //值类型在栈上,引用类型(包括 object)在堆上
        //所以当值类型要被当成 object 使用时
        //就需要创建一个新的堆对象,把值类型的值拷贝进去
        //就好像,做一个盒子,把值类型放进去的感觉,所以称为装箱
        //主要成本:
        //此时obj指向堆,10被赋值到堆中
        //原本x仍在栈中
        //整个过程创建了一个新的堆对象(需要GC管理)
        //成倍很高,会给GC带来压力

        //拆箱:
        int y = (int)obj;
        //从 object 再提取出值类型
        //拆箱不是取值,而是先检查类型,再从堆对象里拷贝出内部值
        //主要成本:
        //类型检查和值拷贝回栈
        //成倍消耗中等

        //如何避免装箱拆箱
        //1.使用泛型
        //2.正确使用内置API
        Debug.Log("Frame " + Time.frameCount); // 无装箱
        Debug.LogFormat("Frame {0}", Time.frameCount); // 装箱
        //等等

        #endregion

        #region 知识点五 数据内存布局的重要性

        //方式一
        Lesson112TestClass[] array = new Lesson112TestClass[1000];
        //内存布局是不连续的,极度分散的,因为引用在堆上的位置是随机的
        //特点:
        //1.CPU的缓存命中率是极差的
        //2.堆上分配,需要GC管理
        //3.每次访问元素都需要指针定位追逐
        //4.内存占用也最大,有对象头

        //方式二
        Lesson112TestStruct[] array2 = new Lesson112TestStruct[1000];
        //特点:
        //1.缓存命中时友好的
        //2.遍历是快速的
        //3.内存占用齐次,需要内存对齐

        //方式三
        int[] myInt = new int[1000];
        float[] myFloat = new float[1000];
        bool[] myBool = new bool[1000];
        string[] myString = new string[1000];
        //特点:
        //1.每个字段都是完全连续的
        //2.CPU缓存命中非常好,并且会缓存对齐
        //3.是性能最强的数据布局方式
        //4.内存占用最少

        //注意:
        //不要一上来就上数据数组
        //它不是日常写法,只是用来优化特定情况的写法
        //日常开发时
        //能用 class 就先用 class
        //性能不够时,先换成 struct
        //再不够,再对最核心那段逻辑引入数据数组

        #endregion

        #region 知识点六 Unity API中的数组

        //UnityAPI中很多指令会导致堆内存分配
        //比如:
        //1.GetComponents<T>() 等
        //2.Mesh.vertices 等
        //3.renderer.materials 等
        //4.Camera.allCameras
        //5.Physics.RaycastAll(...)等
        //等等
        //使用这些API时Unity内部会分配该数组的全新数组对象
        //我们应该避免使用,或者使用时将数据缓存,而不是频繁调用这些API获取数据

        #endregion
    }

    void TestStructFunction(ref Lesson112TestStruct testStruct)
    {
    }

    void TestClassFunction(Lesson112TestClass testClass)
    {
    }
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏