2.2 频繁内存分配和垃圾回收对于CPU性能的影响

1.频繁内存分配

2.垃圾回收

3.分代GC

4.减少GC的方法


1.频繁内存分配

内存分配不是"免费创建对象", 而是操作系统在底层执行复杂操作的过程, 频繁分配会直接消耗CPU资源, 破坏内存效率

1).堆内存分配的底层开销(c#引用类型对象)

C#中引用类型(string, List<T>, 自定义类)的内存分配在"堆"(值类型在栈上, 分配和释放极快), 堆分配的核心开销

来自两方面:

a.空间块查找与管理

堆是动态内存区域, 分配时需要先查找"大小匹配的空闲内存块", 频繁分配小对象会导致堆中产生大量零散的空闲块(内存碎

片化), 后续分配时需要遍历更长的空闲链表才能找到合适块, 分配耗时逐渐增加

b.元数据与安全检查

每个堆对象都需要附加元数据(: 类型信息, GC标记位, 同步锁标识), 分配对象时还要做边界检查, 权限验证,这些都是额

外的CPU开销

2).破坏CPU缓存局部性

CPU缓存(L1/L2/L3)的核心优势是"访问连续内存时命中率极高"(局部性原理), 频繁分配的对象在堆中往往是离散分布的(尤

其是碎片化后), 导致CPU访问这些对象时, 缓存命中率大幅下降 - 不得不从速度慢100倍以上的主存中读取数据, 直接拖慢

代码执行速度(这也是"频繁new对象""复用对象"慢的关键性原因)

3).帧时间的累积消耗

Unity的帧循环有严格的时间限制(60帧要求每帧 <= 16ms, 30<= 33ms), 如果每帧都进行多次内存分配(比如: 临时

string拼接, new List<int>(), LINQ查询隐式创建迭代器), 这些分配的底层开销会直接占用帧时间; 如

a.每帧分配10个小对象, 每个分配耗时0.1ms, 仅分配就占用1ms

b.若项目本身逻辑复杂(如物理计算, UI渲染), 再叠加分配开销, 很容易导致帧时间超标, 出现"微卡顿"

2.垃圾回收

内存分配只是"前因", 真正导致明显卡顿的是后续的GC, 因为GC的核心工作机制是"暂停所有线程"; Unity的主线程(负责渲

染, 输入, 逻辑更新)一旦被暂停, 画面就会直接卡住
1).GC的核心工作原理

C#的GC是"自动内存回收", 但不是"无代价的", 其核心流程("标记 - 清除"为例)

a.标记阶段

暂停所有应用线程(STW), 遍历堆中所有对象, 标记出"仍被引用的存活对象"(如全局变量, 局部变量指向的对象)

b.清除阶段

继续STW, 回收未被标记的"垃圾对象", 释放其占用的堆内存

c.压缩阶段

为了解决碎片化, 将存活对象整理成连续的内存块(同样需要STW)
2).STW是卡顿的直接原因

a.暂停主线程

Unity的主线程是"单线程驱动的", 所有关键逻辑(Update, LateUpadte, 渲染管线, UI布局)都在主线程中执行; GC触发时,

主线程会强制暂停 - GC执行多久, 画面就卡多久

b.触发频率与耗时

"频繁内存分配会导致堆内存快速增长, GC触发频率大幅度升高"

- Minor GC(年轻代回收): 回收新分配的短期对象(如每帧创建的临时对象), 耗时较短(通常1 ~3ms), 但频繁触发(比如每1

0帧一次)会累积延迟, 导致帧时间波动

- Major GC(老年代回收): 回收长期存活的对象, 涉及整个堆的遍历和整理, 耗时更长(移动平台可能5 ~ 20ms甚至更久)一

次Major GC就足以让60帧画面掉帧(16ms 阈值), 出现明显卡顿

c.不确定性

GC的触发时机是运行时动态决定的(如堆内存达到阈值、手动调用GC.Collect()), 可能在关键场景(如战斗爆发、UI 切换)突

然触发, 卡顿体验更差

3.分代GC

1).分代GC的核心结构

Unity SGen GC将堆分为两个核心区域: 年轻代和老年代

a.年轻代

- 存储对象类型: 新分配的短期对象(如临时List, 字符串)

- 回收效率: 极快(ms级)

- 对应回收类型: Minor GC

b.老年代

- 存储对象类型: 存活超过多次Minor GC的长期对象(如游戏单例, 场景根对象)

- 回收效率:(10ms级)

- 对应回收类型: Major GC/Full GC

注: "部分大对象(如 > 256kb的数组, 纹理数据)会直接分配到老年代(年轻代存不下), 跳过年轻代阶段"
2).Minor GC(年轻代回收): 触发时机(高频, 轻量)

Minor GC的核心触发逻辑是: 年轻代(Nursery)内存不足, 无法容纳新分配的对象, 这是最主要的, 最常见的触发条件, 且

几乎都是"自动, 被动"触发

a.核心触发条件(99% 的场景)

当你在代码中分配新的引用类型对象(new List<>()、字符串拼接), 运行时尝试将对象放入年轻代时:

若年轻代剩余空闲空间 ≥ 新对象大小 → 正常分配, 不触发GC

若年轻代剩余空闲空间 < 新对象大小 → 立即触发MinorGC

✅ 第一步: 暂停主线程(STW), 扫描年轻代所有对象, 标记"存活对象"(如仍被变量引用的临时对象)

✅ 第二步: 回收年轻代中"死亡对象"(无引用的垃圾), 释放空间

✅ 第三步: 将年轻代中"存活超过1 ~ 2次Minor GC"的对象(如存活了3帧的临时对象) "晋升(Promote)"到老年代

✅ 第四步: 若回收后年轻代有足够空间, 分配新对象; 若仍不足(极少)则触发Major GC

b.辅助触发条件(少见)

- 手动指定回收年轻代: 调用GC.Collect(0) 参数0代表年轻代, 强制触发Minor GC

- 运行时内部阈值: 极少数情况(如年轻代碎片化达到临界值), 运行时主动触发Minor GC

c.Unity实战中的Minor GC典型场景

每帧创建临时对象(string a = "hp:" + playerHp;new Vector3()作为返回值), 年轻代快速被填满, 通常每10 ~ 30

帧触发一次 Minor GC

战斗场景中频繁创建子弹/粒子临时对象, 年轻代5 ~ 10帧就满, Minor GC触发频率飙升

注: Minor GC仅扫描年轻代(内存范围小), 因此STW耗时极短(移动平台0.5 ~ 3ms, PC 0.1 ~ 1ms), 频繁触发会累积帧时

间波动
3).Major GC(老年代回收)/ Full GC(全堆回收, 包含"年轻代 + 老年代 + 压缩")的触发条件更复杂, 核心是

"老年代内存不足""全局内存压力达到临界值", 且常与Minor GC联动

a.核心触发条件: 老年代内存不足

- 直接触发: 分配大对象(: 1MB以上的数组、游戏场景数据), 大对象直接进入老年代, 若老年代剩余空间不足 → 触发 

Major GC(先回收老年代垃圾, 再分配)

- 阈值触发: 老年代已用内存占老年代总容量的比例达到阈值(Unity SGen 默认约 75%~80%,不同版本 / 平台略有调整) → 

触发Major GC

- 碎片化触发: 老年代碎片化严重(有大量空闲内存, 但无连续大块空间容纳新对象) → 触发 Full GC(含压缩阶段, 整理内

存碎片)

b.间接触发: Minor GC的"晋升失败"

这是Major GC最常见的"间接触发"场景: Minor GC执行后, 需要将年轻代中"存活多次的对象"晋升到老年代, 但此时老年代

没有足够空间容纳这些晋升对象 → 触发"晋升失败(Promotion Failure)" → 运行时会先触发Major GC清理老年代空间, 再

完成年轻代对象的晋升

c.手动触发(主动控制 / 风险操作)

- 调用GC.Collect(): 无参数, 强制触发Full GC(回收年轻代 + 老年代, 且可能执行内存压缩)

- 调用GC.Collect(1): 参数1代表老年代强制触发Major GC

- Unity特定操作: 手动调用Resources.UnloadUnusedAssets()卸载未使用资源后, 通常会触发Full GC清理资源对应的内存

对象

d.系统/运行时强制触发

- 低内存压力: 移动平台(iOS/Android)触发"低内存警告", 系统强制Unity回收内存 → 运行时触发Full GC

- 堆内存耗尽: 整个堆(年轻代 + 老年代)的空闲内存不足以分配新对象, 且Minor/Major GC都无法释放足够空间 → 触发

Full GC(最后尝试回收,失败则抛出 OOM 内存溢出)

- Unity场景卸载: 场景切换时, 大量长期对象(如场景内的管理器、模型对象)变为垃圾, 老年代占比骤升 → 触发Major GC

e.Unity实战中的Major GC典型场景

场景切换: 卸载旧场景后, 大量长期对象进入老年代垃圾, 触发Major GC(移动平台耗时 5~20ms)

战斗结束: 大量战斗相关长期对象(如技能管理器、敌人对象)失效, 老年代占比达阈值 → 触发 Major GC

频繁创建大对象: 如每帧加载1MB的配置数据数组(直接进老年代), 老年代快速填满 → 触发 Major GC

注: Major/Full GC需要扫描老年代(内存范围大), 且可能执行内存压缩, STW耗时远高于Minor GC(移动平台5 ~ 20ms, PC

3 ~ 10ms), 一次就可能导致明显卡顿

4.减少GC的方法

1).避免在Update中创建对象
// 错误示例:在Update中创建字符串
void Update() {
    string message = "Current time: " + Time.time; // 每帧创建新字符串
    Debug.Log(message);
}

// 正确示例:预分配字符串
private string messageBuffer = "";
void Update() {
    messageBuffer = string.Format("Current time: {0}", Time.time); // 复用字符串缓冲区
    Debug.Log(messageBuffer);
}

2).使用StringBuilder处理字符串拼接
// 错误示例:频繁的字符串拼接
string fullName = firstName + " " + lastName + " - Age: " + age;

// 正确示例:使用StringBuilder
StringBuilder sb = new StringBuilder();
sb.Append(firstName).Append(" ").Append(lastName).Append(" - Age: ").Append(age);
string fullName = sb.ToString();

3).避免装箱和拆箱操作
// 错误示例:int装箱
object boxedInt = 10;
int unboxedInt = (int)boxedInt;

// 正确示例:使用泛型避免装箱
List<int> intList = new List<int>();
intList.Add(10);

4).游戏对象池的使用
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour {
    [SerializeField] private GameObject pooledObject;
    [SerializeField] private int poolSize = 10;

    private List<GameObject> objectPool;

    void Start() {
        objectPool = new List<GameObject>();
        for (int i = 0; i < poolSize; i++) {
            GameObject obj = Instantiate(pooledObject);
            obj.SetActive(false);
            objectPool.Add(obj);
        }
    }

    public GameObject GetPooledObject() {
        foreach (GameObject obj in objectPool) {
            if (!obj.activeInHierarchy) {
                obj.SetActive(true);
                return obj;
            }
        }
        // 如果池已满,可以创建新对象或扩展池
        GameObject newObj = Instantiate(pooledObject);
        objectPool.Add(newObj);
        return newObj;
    }

    public void ReturnToPool(GameObject obj) {
        obj.SetActive(false);
    }
}

5).类对象池的使用
public class Pool<T> where T : class, new() {
    private Stack<T> objectStack;
    private int maxSize;

    public Pool(int initialSize, int maxSize) {
        objectStack = new Stack<T>();
        this.maxSize = maxSize;
        for (int i = 0; i < initialSize; i++) {
            objectStack.Push(new T());
        }
    }

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

    public void Return(T obj) {
        if (objectStack.Count < maxSize) {
            objectStack.Push(obj);
        }
    }
}

// 使用示例
private static Pool<List<int>> listPool = new Pool<List<int>>(10, 100);

void SomeMethod() {
    List<int> list = listPool.Get();
    list.Add(1);
    list.Add(2);
    // 使用完后归还
    list.Clear();
    listPool.Return(list);
}

6).预分配内存
// 错误示例:每次调用都分配新数组
void ProcessData(List<int> data) {
    int[] array = data.ToArray(); // 分配新数组
    // 处理数组
}

// 正确示例:预分配数组
private int[] tempArray = new int[1000];
void ProcessData(List<int> data) {
    data.CopyTo(tempArray); // 复用预分配的数组
    // 处理数组
}

7).字符串优化

a.避免在循环中使用字符串拼接

// 错误示例:在循环中拼接字符串
string result = "";
for (int i = 0; i < 1000; i++) {
    result += i.ToString(); // 每次循环都创建新字符串
}

// 正确示例:使用StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.Append(i);
}
string result = sb.ToString();
b.缓存常用字符串

// 正确示例:缓存常用字符串​
private const string scorePrefix = "Score: ";private const string healthPrefix = "Health: ";
c.使用struct代替class定义小型数据结构

d.减少ToString()调用

// 错误示例:频繁调用ToString()
Debug.Log("Position: " + transform.position.ToString());

// 正确示例:自定义日志格式
Debug.LogFormat("Position: ({0}, {1}, {2})", transform.position.x, transform.position.y, transform.position.z);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值