♻️ Unity GC 内存优化完全指南:彻底告别卡顿的终极手册

💡 GC 优化的价值

  • 游戏每隔几秒就卡一下,GC 导致的?
  • 想减少 GC 触发次数,却不知道怎么做?
  • 对象池、缓存、值类型,哪个更适合你的场景?
  • 想深入理解 GC 原理,从根本上解决问题?

这篇文章! 将深入解析 Unity GC 机制,从原理到实践,提供 24 个实战优化技巧,让内存分配更高效!


一、内存管理方式概览

1.1 三种内存管理模式

模式 代表技术 优点 缺点
手动管理 C/C++ malloc/free 速度快,无额外开销 容易内存泄露、野指针
半自动管理 引用计数 (Reference Count) 自动回收,速度快 存在循环引用问题
全自动管理 追踪式 GC (Tracing GC) 无需关心释放 有性能开销
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────────────────────┐
│ Unity GC 工作原理 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Mark 阶段: Sweep 阶段: │
│ ┌─────────┐ ┌─────────┐ │
│ │ GC Root │───标记可达对象──────> │ 回收未标记 │ │
│ └─────────┘ │ 对象 │ │
│ │ └─────────┘ │
│ ▼ │
│ 所有可达对象被标记 │
│ │
└─────────────────────────────────────────────────────────────────────────┘

二、Unity GC 特性

2.1 Boehm-Demers-Weiser GC

Unity 使用的 GC 器具有以下特点:

特性 说明 影响
Stop The World GC 时所有线程必须停止 会造成卡顿
不分代 整个托管堆统一扫描 GC 速度较慢
不压缩 不进行碎片整理 产生内存碎片

⚠️ 重要:即使 Unity 2019+ 的增量式 GC,回收时仍需停止所有线程。

2.2 内存碎片问题

1
2
3
4
5
6
堆内存布局 (不压缩 GC):

┌───┬────┬─┬──────┬─┬──┬────────┬─┬────┐
│ A │ ■ │B│ ■ │C│ ■│ D │ ■│ E │
└───┴────┴─┴──────┴─┴──┴────────┴─┴────┘
■ = 空白间隙 (无法合并)

当申请新对象时,如果没有单个间隙大于对象大小,堆内存就会增加。


三、影响 GC 性能的核心因素

3.1 可达对象数量

减少对象数量的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误:每个元素都是独立对象
class Item {
public int a;
public short b;
}
Item[] items; // GC 时需要遍历每个元素

// ✅ 正确:数组只有一个对象
class Item {
public int[] a;
public short[] b;
}
Item item; // GC 只需遍历一个对象

3.2 托管堆大小

优化策略:

  1. 减少临时分配 - 使用初始化分配和预分配
  2. 减少内存泄露 - 防止孤岛效应和循环引用

四、类与结构体

4.1 内存结构对比

1
2
3
4
5
6
7
8
9
10
11
12
类对象内存布局 (堆中):
┌──────────┬──────────┬────────────────┐
│ vtable │ Monitor │ 字段数据 │
│ (IntPtr) │ (IntPtr) │ (对齐排列) │
└──────────┴──────────┴────────────────┘
4/8字节 4/8字节 根据字段

结构体内存布局 (栈中):
┌──────────────────────┐
│ 字段数据 │
│ (紧凑对齐) │
└──────────────────────┘
类型 存储位置 对齐建议
堆 (实例) 尽量紧凑排列
结构体 栈 / 堆 (装箱) 遵守对齐规则

4.2 装箱拆箱场景

场景 是否装箱 解决方案
结构体 → 接口 ✅ 是 避免接口转换
值类型.GetType() ✅ 是 缓存类型
结构体.ToString() Mono/IL2CPP 不同 重写方法
容器类操作 部分是 使用泛型

五、参数传递优化

5.1 值传递 vs 引用传递

1
2
3
4
5
// ❌ 结构体按值传递 - 复制整个结构体
void Calc1(UnityEngine.Ray ray2) { }

// ✅ 使用 ref 按引用传递 - 只复制引用
void Calc1(ref UnityEngine.Ray ray2) { }

5.2 ref、in、out 对比

关键字 含义 使用场景
ref 可读写引用 大于 IntPtr.Size 的结构体
in 只读引用 不应被修改的参数
out 只写引用 (类似返回值) 多返回值

5.3 ref return 最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Test
{
private UnityEngine.Ray _ray;

// ✅ 推荐:返回只读引用
public ref readonly UnityEngine.Ray ray => ref _ray;

void Calc()
{
// 复制引用,不是结构体
ref UnityEngine.Ray ray1 = ref ray;
Calc1(ref ray1);
}

void Calc1(ref UnityEngine.Ray ray2) { }
}

⚠️ 警告:ref return 一定不要返回局部变量的引用!


六、泛型与装箱优化

6.1 object 参数 vs 泛型

1
2
3
4
5
// ❌ 使用 object 参数 - 值类型会装箱
void Func(object o) { }

// ✅ 使用泛型 - 避免装箱
void Func<T>(T o) { }

6.2 IL2CPP 泛型共享机制

类型 泛型共享 说明
引用类型 ✅ 共享 共享 RuntimeObject
整数/枚举 ✅ 共享 共享 int32_t
其他值类型 ❌ 不共享 每个类型单独生成

七、可变参数优化

7.1 params 的性能问题

1
2
3
4
void Func(params int[] n);

Func(1, 2, 3); // ❌ 等价于 new int[]{1,2,3}
Func(); // ✅ 等价于 Array.Empty<int>

7.2 重载优化方案

1
2
3
4
5
// ✅ 常用参数数量单独实现
public static string Format(string format, object arg0);
public static string Format(string format, object arg0, object arg1);
public static string Format(string format, object arg0, object arg1, object arg2);
public static string Format(string format, params object[] args);

八、条件编译优化

8.1 Debug.Log 的 GC 问题

1
2
3
// ❌ 即使日志关闭,参数仍会造成 GC Alloc
Debug.Log(123);
Debug.Log(string.Format("12{0}", 3));

8.2 Conditional 特性

1
2
3
4
5
6
7
8
9
10
// ✅ 使用 Conditional 特性
[Conditional("UNITY_EDITOR")]
public static void Print(object message)
{
Debug.Log(message);
}

// 真机上不会生成任何代码
Print(123);
Print(string.Format("12{0}", 3));

九、数组内存布局与缓存

9.1 引用类型数组

1
2
3
4
5
6
7
8
9
引用类型数组 (不连续):
┌────┬────┬────┬────┐
│Ref1│Ref2│Ref3│Ref4│ ─┐
└────┴────┴────┴────┘ │ 引用
│ │ │ │ │ (连续)
▼ ▼ ▼ ▼ ┘
Obj Obj Obj Obj ─→ 数据 (不连续)

缓存命中率: 低

9.2 值类型数组

1
2
3
4
5
6
7
值类型数组 (连续):
┌────┬────┬────┬────┬────┬────┐
│ D1 │ D2 │ D3 │ D4 │ D5 │ D6 │
└────┴────┴────┴────┴────┴────┘
数据 (连续内存)

缓存命中率: 高

十、容器数据结构

10.1 List/Stack/Queue 扩容机制

1
2
3
4
5
6
7
// 扩容伪代码
if(Count + 1 > array.Length)
{
var newArray = new T[array.Length * 2]; // 2倍扩容
Array.Copy(array, 0, newArray, array.Length);
array = newArray; // 旧数组成为垃圾
}
容器 扩容策略 建议
List 2倍 预设 Capacity
Stack 2倍 预设 Capacity
Queue 2倍 预设 Capacity
Dictionary >2倍的素数 预设 Capacity
HashSet >2倍的素数 预设 Capacity

10.2 LinkedList 内存特性

1
2
3
4
5
6
LinkedList 内存布局:
┌────┐ ┌────┐ ┌────┐
│Node│───>│Node│───>│Node│
└────┘ └────┘ └────┘
▲ ▼ ▼
└──────────┘ 不连续内存
  • 完全不连续内存
  • 使用节点池可减少 GC
  • 适合频繁插入/删除场景

十一、容器装箱问题

11.1 常见装箱场景

场景 是否装箱 解决方案
foreach (Unity 5.6+) ❌ 已修复 无需处理
接口参数 (ICollection) ✅ 会装箱 避免使用
Dictionary 结构体 Key ✅ 会装箱 实现 IEqualityComparer
Dictionary 枚举 Key .NET 4.x+ 已修复 升级 runtime

11.2 LINQ 注意事项

⚠️ 重要:Linq 要避免使用,除非完全理解其源码!


十二、对象池实现

12.1 UGUI 通用对象池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class ObjectPool<T> where T : new()
{
private readonly Stack<T> m_Stack = new Stack<T>();
private readonly UnityAction<T> m_ActionOnGet;
private readonly UnityAction<T> m_ActionOnRelease;

public int countAll { get; private set; }
public int countActive => countAll - countInactive;
public int countInactive => m_Stack.Count;

public ObjectPool(UnityAction<T> actionOnGet, UnityAction<T> actionOnRelease)
{
m_ActionOnGet = actionOnGet;
m_ActionOnRelease = actionOnRelease;
}

public T Get()
{
T element;
if (m_Stack.Count == 0)
{
element = new T();
countAll++;
}
else
{
element = m_Stack.Pop();
}
m_ActionOnGet?.Invoke(element);
return element;
}

public void Release(T element)
{
if (m_Stack.Count > 0 && ReferenceEquals(m_Stack.Peek(), element))
Debug.LogError("Trying to destroy object that is already released.");
m_ActionOnRelease?.Invoke(element);
m_Stack.Push(element);
}
}

12.2 NGUI BetterListPool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class BetterListPool<T>
{
public const int MAX_COUNT = 16;
PoolStack<T[]>[] pool = new PoolStack<T[]>[MAX_COUNT];

public T[] Alloc(int n)
{
n = NextPowerOfTwo(n);
int pos = GetSlot(n);

if (pos >= 0 && pos < MAX_COUNT)
{
PoolStack<T[]> list = pool[pos];
if (list.size > 0)
return list.Pop();
}
return new T[n];
}

public void Collect(T[] buffer)
{
int pos = GetSlot(buffer.Length);
if (pos >= 0 && pos < MAX_COUNT)
pool[pos].Push(buffer);
}
}

十三、UGUI ListPool

13.1 BaseMeshEffect 优化

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 使用 ListPool 优化 BaseMeshEffect
public override void ModifyMesh(VertexHelper vh)
{
if (!IsActive()) return;

var verts = ListPool<UIVertex>.Get();
vh.GetUIVertexStream(verts);
// ... 修改顶点 ...
vh.Clear();
vh.AddUIVertexTriangleStream(verts);
ListPool<UIVertex>.Release(verts);
}

13.2 Text 特殊处理

⚠️ 注意:UIVertex 大小 76 字节,Text 字数多时会撑大缓存。

1
2
3
4
// ✅ 使用 VertexHelper 内部方法
vh.PopulateUIVertex(ref vert, index);
vh.SetUIVertex(vert, index);
vh.AddVert(vert);

十四、LinkedList 节点池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PooledLinkedList<T> : LinkedList<T>
{
static Stack<LinkedListNode<T>> s_pool = new Stack<LinkedListNode<T>>(10);

private LinkedListNode<T> Create(T t)
{
LinkedListNode<T> node = s_pool.Count > 0
? s_pool.Pop()
: new LinkedListNode<T>(t);

if (s_pool.Count > 0) node.Value = t;
return node;
}

public new void Remove(LinkedListNode<T> n)
{
int count = Count;
base.Remove(n);
if (count != Count) s_pool.Push(n);
}
}

十五、字符串优化

15.1 字符串不可变问题

1
2
3
4
5
6
// ❌ 每次拼接生成新字符串
str = "a" + "b" + "c";

// ✅ 使用 StringBuilder
StringBuilder sb = new StringBuilder();
sb.Append("a").Append("b").Append("c");

15.2 String Intern 池

类型 说明
内置池 编译期字符串,无法清空
自定义池 运行期字符串,可按需清空

15.3 字符串分类处理

类型 示例 缓存策略
整数型 “12s” 常驻池
固定拼接 “gun_ak47” 常驻池
动态内容 “张三击杀了李四” 战斗池

十六、匿名方法

16.1 GC Alloc 场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ 每次调用生成新对象
void Func()
{
int a = 1;
Call(() => a = 2); // 捕获局部变量
}

// ✅ 使用静态变量
static int a = 1;
void Func()
{
Call(() => a = 2); // Mono 中无 GC Alloc
}

// ✅ 缓存委托
int a = 1;
Action action;
void Func()
{
if (action == null)
action = () => a = 2;
Call(action);
}

⚠️ 注意:IL2CPP 中所有匿名形式都会产生 GC Alloc!


十七、协程优化

17.1 WaitForSeconds 缓存

1
2
3
4
5
6
// ❌ 每次创建新对象
yield return new WaitForSeconds(5);

// ✅ 缓存 WaitForSeconds
static readonly WaitForSeconds fiveSeconds = new WaitForSeconds(5);
yield return fiveSeconds;

17.2 自定义 IEnumerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 手动实现可缓存的 IEnumerator
class Coroutine1 : IEnumerator
{
public bool MoveNext() { ... }
public void Reset() { ... }
public object Current { get { ... } }
}

// 使用时
Coroutine1 coroutine1 = new Coroutine1();
while (true)
{
yield return coroutine1;
coroutine1.Reset(); // 手动重置
}

十八、Unity API 优化

18.1 避免的 API

API 问题 替代方案
object.name 产生 GC 缓存名字
object.tag 产生 GC CompareTag()
返回数组的 API 产生新数组 使用 List 版本

18.2 List 模式

1
2
3
4
5
// ✅ 使用 ListPool + List 参数 API
List<Text> texts = ListPool<Text>.Get();
GetComponentsInChildren(texts);
// ... 使用 texts ...
ListPool<Text>.Release(texts);

18.3 NavMesh 优化

1
2
3
4
5
6
7
8
9
10
11
// ✅ 使用 NonAlloc 版本
public static Vector3[] cachedPath = new Vector3[256];
public static int pathCount { get; private set; }
private static NavMeshPath navMeshPath;

public static void CalculatePath(Vector3 startPos, Vector3 endPos)
{
navMeshPath.ClearCorners();
NavMesh.CalculatePath(startPos, endPos, NavMesh.AllAreas, navMeshPath);
pathCount = navMeshPath.GetCornersNonAlloc(cachedPath);
}

18.4 物理 NonAlloc

1
2
3
// ✅ 使用 NonAlloc 版本
RaycastHit[] hits = new RaycastHit[10];
int hitCount = Physics.RaycastNonAlloc(ray, hits);

十九、Protobuf 优化

19.1 避免流复制

1
2
3
4
5
// ❌ 传入 Stream 会复制字节流
message.MergeFrom(stream);

// ✅ 直接使用 byte[]
message.MergeFrom(cachedBytes);

19.2 消息对象池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class NetMsgInStream
{
public CodedInputStream codedStream { get; private set; }
public MemoryStream memoryStream { get; } = new MemoryStream();

public NetMsgInStream()
{
this.codedStream = new CodedInputStream(memoryStream);
}

public static ObjectPool<NetMsgInStream> pool =
new ObjectPool(actionOnRelease: (s) => s.memoryStream.SetLength(0));
}

// 使用
var stream = NetMsgInStream.pool.Get();
message.MergeFrom(stream.codedStream);
NetMsgInStream.pool.Release(stream);

二十、Unsafe 优化

20.1 字符串 ToLower

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ Unsafe 版本 ToLower
public static void ToLower(string str)
{
fixed (char* c = str)
{
int length = str.Length;
for (int i = 0; i < length; ++i)
{
c[i] = char.ToLower(c[i]);
}
}
}

20.2 字符串 Split

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ✅ Unsafe 版本 Split
public static int Split(string str, char split, string[] toFill)
{
if (str.Length == 0)
{
toFill[0] = string.Empty;
return 1;
}

fixed (char* p = str)
{
var start = 0;
int ret = 0;
for (int i = 0; i < str.Length; ++i)
{
if (p[i] == split)
{
toFill[ret++] = new string(p, start, i - start);
start = i + 1;
}
}
if (start < str.Length)
{
toFill[ret++] = new string(p, start, str.Length - start);
}
return ret;
}
}

20.3 修改字符串长度

1
2
3
4
5
6
7
8
9
10
// ⚠️ 危险操作:修改字符串长度
public static void SetLength(this string str, int length)
{
fixed (char* s = str)
{
int* ptr = (int*)s;
ptr[-1] = length; // 修改长度
s[length] = '\0'; // 添加终止符
}
}

二十一、非托管堆

21.1 UnsafeList 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public unsafe struct UnsafeList<T> where T : unmanaged
{
static int alignment = UnsafeUtility.AlignOf<T>();
static int elementSize = UnsafeUtility.SizeOf<T>();
const int MIN_SIZE = 4;
ArrayInfo* array;

public UnsafeList(int capacity)
{
capacity = Mathf.Max(MIN_SIZE, capacity);
array = (ArrayInfo*)UnsafeUtility.Malloc(
UnsafeUtility.SizeOf<ArrayInfo>(),
UnsafeUtility.AlignOf<ArrayInfo>(),
Allocator.Persistent);
array->capacity = capacity;
array->count = 0;
array->ptr = UnsafeUtility.Malloc(
elementSize * capacity,
alignment,
Allocator.Persistent);
}

public void Dispose()
{
UnsafeUtility.Free(array->ptr, Allocator.Persistent);
UnsafeUtility.Free(array, Allocator.Persistent);
}

public void Add(T t)
{
EnsureCapacity(array->count + 1);
*((T*)array->ptr + array->count) = t;
++array->count;
}
}

unsafe struct ArrayInfo
{
public int count;
public int capacity;
public void* ptr;
}

21.2 Allocator 类型

Allocator 生命周期 性能 用途
Temp 一帧 临时数据
TempJob 4帧 较快 Job System
Persistent 手动释放 较慢 长期数据

二十二、stackalloc 与 Span

22.1 stackalloc

1
2
3
4
5
6
// ✅ 栈上快速分配
void Calculate()
{
Vector3* s = stackalloc Vector3[10];
// 作用域结束自动释放
}

22.2 Span vs Memory

类型 特性 限制
Span<T> ref struct,不能作字段 不能跨越 yield/await
Memory<T> 可作字段 需要转换
1
2
3
// ✅ Span 操作托管/非托管/栈内存
Span<int> stackSpan = stackalloc int[10];
Span<int> managedSpan = new int[10].AsSpan();

二十三、优化总结

23.1 快速检查清单

检查项 说明
缓存组件 缓存 GetComponent 结果
避免 string 使用 StringBuilder 或缓存
避免装箱 使用泛型、避免 object 参数
对象池 复用频繁创建的对象
预分配容器 设置 List/Dictionary 初始容量
使用 NonAlloc API 避免数组分配
缓存 WaitForSeconds 静态只读字段
避免匿名方法 IL2CPP 会产生 GC

23.2 工具推荐

工具 用途
Unity Profiler 查看 GC Alloc
Deep Profiler 详细调用堆栈
Memory Profiler 内存快照对比

二十四、参考资料


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