Chapter 8 内存管理

1 内存域概念

1.1 托管域(Managed Domain)

这是 Mono 平台工作的地方,也是我们重点关注的域。C# 类都在此域实例化对象,GC(垃圾回收)在此域工作。

1.2 本地域(Native Domain)

Unity 底层使用,主要管理内部内存空间分配,也是大多数内置 Unity 类保存其数据的地方(Transform 和 Rigidbody 组件等)。对于一般游戏开发者来说,只能间接地与其交互。

托管桥

托管域也包含存储在本地域中对象描述的包装器(使得开发者在 C# 中访问 Transform 等组件的信息)。因此,当和 Transform 组件交互时,大多数指令会请求 Unity 进入它的本地代码,在那里生成结果,然后复制结果回托管域,这一过程称之为托管桥。当两个域对相同实体有自己的描述时,跨越托管桥需要进行上下文切换,而这会带来很多严重的性能问题。所以需要尽可能最小化此行为(例如在第二章讲的,不要直接访问 tag,而要使用 CompareTag())。

1.3 外部库

例如 DirectX 和 OpenGL 等图形库,在 C# 中引用也会产生内存上下文切换。

2 GC 垃圾回收

2.1 垃圾回收完整周期(最坏情况)

  1. 验证是否有足够的连续空间用于分配新对象。
  2. 如果没有足够空间,迭代所有已知的直接和间接引用,标记它们是否可达。
  3. 再次迭代所有这些引用,标识未标记的对象用于回收。
  4. 迭代所有被标识对象,检查回收一些对象是否能为新对象创建足够大的连续空间。
  5. 如果没有,从操作系统请求新的内存块,以便扩展堆。
  6. 在新分配的块前面分配新对象,并返回给调用者。

2.2 回收策略

2.2.1 回收时机

垃圾回收一般会自动执行。但是为了避免回收时突然性能下降打断游戏,可以选择在性能不敏感期进行手动垃圾回收(System.GC.Collect),比如加载场景时和暂停游戏时。

2.2.2 指定回收

一般的 MonoBehaviour 或实现了 IDisposable 接口的对象,都可以通过调用 Dispose() 来及时释放内存。比如通过网络拉取的大数据集,在获取后可能希望立刻析构,来腾出内存空间。

2.2.3 加快回收

因为垃圾回收要多次遍历所有引用对象,所以减少场景中不必要的对象可以提升回收速度。

2.3 值类型和引用类型

值类型保持在栈上,释放时非常快,不会参与垃圾回收。
引用类型在堆上,释放时会触发垃圾回收。

如果使用类的唯一目的是在程序中向某处发送数据块,且数据的持续存在时间不需要超过当前作用域,那么可以使用 struct 代替 class,避免垃圾回收。

但是注意,如果传递的值是极大数据集并且多次传递,因为 struct 在传递时会复制整个数据,而 class 只是传递引用,此时 struct 在性能表现上可能非常差(复制的开销过大)。

2.4 字符串

  • 字符串本质是字符数组,所以是引用类型。在堆上分配内存。
  • 字符串生成后,不可改变。每次拼接、修改等操作都是生成新的字符串。旧字符串如没引用则要进行垃圾回收。
  • 所以尽量避免多次使用 “+” 号进行字符串拼接。每次拼接都会产生新字符串,然后丢弃之前的字符串。例如以下代码:
void CreateFloatingDamageText(DamageResult result) {
 string outputText = result.attacker.GetCharacterName() + "
 dealt " + result.totalDamageDealt.ToString() + " " +
 result.damageType.ToString() + " damage to " +
 result.defender.GetCharacterName() + " (" +
 result.damageBlocked.ToString() + " blocked)";
 // ...
}
  • 最终会产生多个字符串:
    “3 blocked)”
    “ (3 blocked)”
    “Orc (3 blocked)”
    “ damage to Orc (3 blocked)”
    “Slashing damage to Orc (3 blocked)”
    “ Slashing damage to Orc (3 blocked)”
    “15 Slashing damage to Orc (3 blocked)”
    “ dealt 15 Slashing damage to Orc (3 blocked)”
    “Dwarf dealt 15 Slashing damage to Orc (3 blocked)”
  • 使用 string.Format()string.Join()string.Concat() 等方法,一步完成,没有多余的字符串分配。
  • 如果能够基本估计字符串的长度范围,并且此字符串变化频繁,可使用 StringBuilder,并在 new 时分配缓冲区。
using System.Text;
// ...
StringBuilder sb = new StringBuilder(100);
sb.Append(result.attacker.GetCharacterName());
sb.Append(" dealt " );
sb.Append(result.totalDamageDealt.ToString());
// etc.
string result = sb.ToString();

2.5 装箱(Boxing)

C# 中万物皆对象,值类型也能用 System.Object 表达。
对象强制转换为 System.Object 时就是装箱。
装箱后,值类型会被视为引用对象,导致堆分配。

所以尽量避免使用 object 作为参数传入,而多使用泛型来代替类似需求。

2.6 数据布局

我们希望将大量引用类型和大量值类型分开。如果结构体中有一个引用类型,那么 GC 将关注整个对象。当发生标记-清除时,GC 必须验证对象的所有字段。如果将不同类型分配到不同的数组中,那么 GC 可跳过大量数据。
例如:

public struct MyStruct {
	 int myInt;
	 float myFloat;
	 bool myBool;
	 string myString;
	}
MyStruct[] arrayOfStructs = new MyStruct[1000];

因为 myString 是引用。所以GC会迭代所有成员。
现在改为:

int[] myInts = new int[1000];
float[] myFloats = new float[1000];
bool[] myBools = new bool[1000];
string[] myStrings = new string[1000];

那么GC只会检查字符串数组。

2.7 Unity API 中的数组

Unity API 中,所有返回数组数据的指令都会导致在堆上分配内存。
例如:

GetComponents<T>(); // (T[])
Mesh.vertices; // (Vector3[])
Camera.allCameras; // (Camera[])

所以需要避免频繁进行此类调用,或者缓存结果。

2.8 Foreach 问题

foreach 在 Unity 旧版本中会产生不必要的堆分配。但在 2018.1 之后,使用 Mono 4.0,这个 Bug 被修复了。
不过还是不建议在类似 Update() 中使用 foreach,因为至少每次还是会分配一个 Enumerator 对象在堆上。

2.9 协程

每个协程都会产生堆分配,所以要尽量避免太多的短时间协程。一些计时功能可以用一个 MonoBehaviour 单例实现。例如一个简单的计时器:计时器代码

使用对象池

对象池是一种预先分配对象并在需要时重复使用,而不是频繁创建和销毁对象的技术。它能显著减少运行时的堆分配和 GC 压力。

简单对象池示例

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool<T> where T : class, new() {
    private Queue<T> _pool = new Queue<T>();
    private int _maxSize;

    public ObjectPool(int initialSize = 0, int maxSize = int.MaxValue) {
        _maxSize = maxSize;
        for (int i = 0; i < initialSize; i++) {
            _pool.Enqueue(new T());
        }
    }

    public T Get() {
        return _pool.Count > 0 ? _pool.Dequeue() : new T();
    }

    public void Return(T item) {
        if (item == null) return;
        if (_pool.Count < _maxSize) {
            _pool.Enqueue(item);
        }
    }
}

Prefab Pooling

对于 GameObject 实例,可以使用 Prefab Pool 来避免 Instantiate()Destroy() 带来的内存分配:

using System.Collections.Generic;
using UnityEngine;

public class PrefabPool : MonoBehaviour {
    [SerializeField] private GameObject _prefab;
    [SerializeField] private int _initialSize = 10;
    private Queue<GameObject> _inactive = new Queue<GameObject>();
    private HashSet<GameObject> _active = new HashSet<GameObject>();

    void Start() {
        for (int i = 0; i < _initialSize; i++) {
            SpawnInactive();
        }
    }

    private GameObject SpawnInactive() {
        GameObject go = Instantiate(_prefab);
        go.SetActive(false);
        _inactive.Enqueue(go);
        return go;
    }

    public GameObject Spawn(Vector3 position, Quaternion rotation) {
        GameObject go = _inactive.Count > 0 ? _inactive.Dequeue() : SpawnInactive();
        go.transform.position = position;
        go.transform.rotation = rotation;
        go.SetActive(true);
        _active.Add(go);
        return go;
    }

    public bool Despawn(GameObject go) {
        if (!_active.Contains(go)) return false;
        go.SetActive(false);
        _active.Remove(go);
        _inactive.Enqueue(go);
        return true;
    }
}

使用对象池时需要注意:

  • 重置对象状态:在重新激活对象前,清理速度、计时器、事件订阅等状态。
  • 预生成数量:根据实际需求预生成足够数量,避免运行时频繁 Instantiate。
  • 内存上限:设置池子最大容量,避免无限制增长。
  • 场景切换:在加载新场景前清空对象池,避免引用已被销毁的对象。

Closures(闭包)

匿名方法和 lambda 表达式可能捕获局部变量,导致编译器生成额外的类对象,从而产生堆分配。在性能敏感的代码中(如每帧调用的委托),应避免使用捕获外部变量的 lambda。

临时工作缓冲区

避免频繁创建临时数组或列表。可以复用一个预先分配的缓冲区,或者使用 ArrayPool<T> / ListPool<T>(如果项目使用相关库)。

.NET 库函数

一些 .NET 库函数会在堆上分配内存(如 LINQ)。在性能关键路径上,尽量用显式循环替代 LINQ,避免不必要的分配。

IL2CPP 和 WebGL 优化

  • IL2CPP:相比 Mono Runtime,IL2CPP 通常能提供更好的性能,尤其是在 iOS 等禁止 JIT 的平台上。可以关注 Unity 官方博客关于 IL2CPP 优化的系列文章。
  • WebGL:WebGL 有特殊的内存限制和 GC 行为。Unity 官方博客有专门讨论 WebGL 内存管理的文章,建议 WebGL 开发者阅读。

Copyright © 原书作者与译者。基于 Unity Game Optimization 第3版中文翻译整理。

This site uses Just the Docs, a documentation theme for Jekyll.