Appearance
内存与 GC
C# 的 GC 是什么?
C# 的 GC 是 .NET 的垃圾回收器,负责自动回收托管堆上不再被使用的对象内存。
c
var user = new User();像这种 new 出来的引用类型对象通常在托管堆上,由 GC 管理生命周期。你不用像 C/C++ 那样手动 free / delete。
GC 大致做三件事:
TIP
- 找根对象
比如局部变量、静态字段、线程栈、GC Handle 等。
- 做可达性分析
从根对象出发,能一路引用到的对象,说明还活着;找不到的对象,就是垃圾。
- 回收和整理内存
不可达对象会被回收,部分情况下还会压缩内存,减少碎片。
分代回收
GC 把对象分成几代:
| 分代 | 含义 |
|---|---|
| Gen 0 | 新创建的短生命周期对象 |
| Gen 1 | 中间过渡代 |
| Gen 2 | 存活较久的对象 |
核心思想是:大多数对象很快就不用了,所以 GC 会更频繁地回收 Gen 0,减少成本。
面试加分点
GC 管的是托管内存,不是所有资源。比如文件句柄、数据库连接、Socket、Unity 里的纹理/Native 资源等,通常要靠:
c
using
IDisposable
Dispose()收尾可以这样说:
GC 通过可达性分析自动回收托管堆中不再使用的对象,并用分代机制提升效率。但 GC 不能替你及时释放非托管资源,所以涉及文件、网络、数据库、Unity Native 资源时,要正确使用
Dispose或using。
托管内存和非托管内存区别是什么?
托管内存由 .NET GC 管理;非托管内存或非托管资源不由 GC 直接管理,需要开发者显式释放。
托管内存
比如普通 C# 对象:
c
var user = new User();
var list = new List<int>();这些对象通常在托管堆上,GC 会判断它们是否还可达;不可达后,GC 会自动回收它们占用的内存。
非托管内存 / 非托管资源
比如:
c
文件句柄
Socket
数据库连接
Native 内存指针
GDI 句柄
Unity 里的 Texture、Mesh、NativeArray 等 Native 资源GC 不知道这些资源该怎么释放,也不能保证及时释放,所以要用:
c
using var stream = File.OpenRead(path);或者:
c
resource.Dispose();关键区别
| 对比点 | 托管内存 | 非托管内存/资源 |
|---|---|---|
| 谁管理 | GC | 程序员 / Dispose / SafeHandle |
| 常见对象 | class、数组、string、集合 | 文件、Socket、Native 指针、Unity Native 资源 |
| 是否自动回收 | 是 | 不保证 |
| 风险 | GC 压力、内存占用 | 资源泄漏、句柄耗尽、Native 内存泄漏 |
面试收尾:
GC 负责回收托管对象的内存,但不等于所有资源都自动释放。涉及文件、网络、数据库、Unity Native 资源时,要实现或调用
IDisposable.Dispose(),优先用using,必要时用SafeHandle管理非托管句柄。
Unity 中哪些写法容易产生 GC?
Unity 中容易产生 GC 的写法,本质都是在运行时产生了新的托管对象,尤其是在 Update、FixedUpdate、OnGUI 这类高频函数里。
常见来源:
| 写法 | 为什么容易 GC |
|---|---|
每帧 new 对象/数组/集合 | 直接分配托管对象 |
字符串拼接、插值、ToString() | string 不可变,会生成新字符串 |
| LINQ | 常产生迭代器、委托、临时集合 |
| Lambda 捕获外部变量 | 可能生成闭包对象 |
| 装箱 | 值类型转 object / 接口时可能装箱 |
foreach 走接口枚举 | 可能产生枚举器/装箱,视类型和版本而定 |
| 返回数组的 Unity API | 每次返回新数组 |
协程里 new WaitForSeconds() | 创建等待对象 |
| 事件/委托重复订阅 | 可能产生委托对象和泄漏引用 |
Debug.Log 拼接字符串 | 日志字符串也会分配 |
例子:
c
void Update()
{
var list = new List<int>(); // 每帧分配
string text = "HP: " + hp; // 每帧新字符串
var result = enemies.Where(e => e.IsAlive).ToList(); // LINQ 分配
}更好的方向:
c
private readonly List<int> buffer = new List<int>(128);
void Update()
{
buffer.Clear(); // 复用集合
}Unity API 里也要注意:
c
Physics.RaycastAll(...) // 返回数组,可能分配
Physics.RaycastNonAlloc(...) // 复用数组,减少分配协程:
c
yield return new WaitForSeconds(1f); // 会分配如果等待时间固定,可以缓存:
c
private static readonly WaitForSeconds WaitOneSecond = new WaitForSeconds(1f);
yield return WaitOneSecond;面试收尾:
Unity 优化 GC 的核心是减少高频路径上的托管分配。少在
Update里new,少用 LINQ 和闭包,字符串 UI 更新要控制频率,集合和数组尽量复用,Unity API 优先选择NonAlloc版本。最终要用 Profiler 看GC Alloc,不要只凭感觉优化。
foreach 会不会产生 GC?
foreach 本身不一定产生 GC;是否分配,取决于遍历对象和枚举器实现。
通常无 GC 的情况:
c
int[] arr = { 1, 2, 3 };
foreach (var x in arr) { }
List<int> list = new List<int>();
foreach (var x in list) { }数组会被编译器优化成类似 for;List<T>、Dictionary<K,V>、HashSet<T> 这类具体泛型集合的枚举器通常是 struct,直接遍历一般不产生堆分配。
容易产生 GC 的情况:
c
IEnumerable<int> nums = list;
foreach (var x in nums)
{
}因为通过 IEnumerable<T> 接口遍历时,List<T> 的结构体枚举器可能被装箱成接口,产生分配。
还有这些也要小心:
c
foreach (var x in list.Where(x => x > 0)) { } // LINQ 可能分配
foreach (var x in GetItems()) { } // yield 迭代器通常是对象
foreach (var x in list)
{
Action a = () => Console.WriteLine(x); // 闭包可能分配
}Unity 里可以这样记:
foreach遍历数组、List<T>这种具体集合,现代 Unity/C# 下一般不用太怕;但在Update里遍历IEnumerable<T>、使用 LINQ、闭包、装箱、返回新数组的 API,就要警惕 GC Alloc。
性能敏感处可以写成:
c
for (int i = 0; i < list.Count; i++)
{
var item = list[i];
}面试收尾:
foreach是否产生 GC 不是语法决定的,而是枚举器实现决定的。具体集合的 struct enumerator 通常没分配;通过接口、LINQ、yield、闭包等路径就可能有分配。Unity 中最终要用 Profiler 看GC Alloc。
字符串拼接为什么可能产生 GC?
字符串拼接可能产生 GC,因为 string 是引用类型但不可变,拼接不是原地修改,而是创建新的字符串对象,旧的临时字符串等待 GC 回收。
c
string s = "";
s += "A"; // 创建新字符串 "A"
s += "B"; // 创建新字符串 "AB"
s += "C"; // 创建新字符串 "ABC"如果在循环或 Unity 的 Update 里频繁这样写:
c
for (int i = 0; i < list.Count; i++)
{
text += list[i]; // 可能产生大量临时 string
}就容易产生很多短生命周期对象,造成 GC Alloc,最终触发 GC 卡顿。
更合适的写法是:
c
var sb = new StringBuilder();
foreach (var item in list)
{
sb.Append(item);
}
string result = sb.ToString();补充面试点:少量字符串拼接没问题;编译期常量如 "A" + "B" 会被编译器合并;单个表达式的多段拼接通常会优化成 string.Concat。真正要警惕的是循环、高频 UI 更新、Debug.Log("hp=" + hp) 这类热点路径。
StringBuilder 适合什么场景?
StringBuilder 适合“运行时、多次、动态拼接同一段字符串”的场景,尤其是循环拼接、大文本构建、Unity 高频路径减少 GC。
c
var sb = new StringBuilder();
foreach (var item in items)
{
sb.Append(item.Name);
sb.Append(",");
}
string result = sb.ToString();它的优势是:Append 通常是在内部可变缓冲区上追加内容,不像 string += xxx 那样每次都创建新的字符串对象。
适合场景:
c
// 循环拼接
for (int i = 0; i < 1000; i++)
{
sb.Append(i);
}
// 构建日志、CSV、报表、UI 大段文本
sb.Append("Name:");
sb.Append(name);
sb.Append(", Score:");
sb.Append(score);不太需要的场景:
c
string s = "HP: " + hp; // 少量拼接,没必要上 StringBuilder
string msg = $"HP: {hp}"; // 简单插值也可以
string x = "A" + "B"; // 编译期常量会被合并面试加分点:StringBuilder 不是完全不分配内存,内部缓冲区扩容会分配,最后 ToString() 也会创建最终 string。如果能预估长度,可以用:
var sb = new StringBuilder(capacity: 1024);口诀:少量拼接直接 string,循环/大文本/高频动态拼接用 StringBuilder。
对象池为什么能减少 GC?
对象池能减少 GC,是因为它把“频繁创建对象、用完丢弃”改成“取出对象、使用、归还、复用”,从而减少堆上的临时垃圾。
没有对象池时:
c
var bullet = new Bullet();
bullet.Fire();
// 用完后不再引用
// 等待 GC 回收如果子弹、特效、飘字、伤害数字、UI Item 频繁创建销毁,就会产生很多短生命周期对象,增加 GC 压力。
有对象池时:
c
Bullet bullet = pool.Get();
bullet.Fire();
pool.Release(bullet);对象没有真正销毁,而是回到池子里,下次继续用。这样可以减少:
new带来的托管堆分配- 临时对象变垃圾的数量
- Unity 中频繁
Instantiate/Destroy的开销 - GC 触发频率和卡顿概率
Unity 常见用法:
c
GameObject obj = pool.Get();
obj.SetActive(true);
// 用完
obj.SetActive(false);
pool.Release(obj);注意:对象池不是完全不产生 GC。池子预热、扩容、内部集合增长、对象状态重置不当、事件没解绑,都可能继续产生问题。
面试收尾可以说:对象池的核心价值不是让对象消失,而是延长对象生命周期,通过复用减少短命对象,从而降低 GC 压力。
IDisposable 是什么?
IDisposable 是 C# 用来“手动、确定性释放资源”的接口,核心方法只有一个:
c
public interface IDisposable
{
void Dispose();
}GC 主要负责回收托管内存,但很多资源不能只等 GC,比如:
- 文件句柄
- 数据库连接
- Socket
- 非托管内存
- Unity 里的
NativeArray、ComputeBuffer等需要释放的资源
所以这些对象会实现 IDisposable,让你用完后主动释放:
c
var stream = new FileStream("a.txt", FileMode.Open);
// 使用 stream
stream.Dispose();更推荐写法是 using:
c
using var stream = new FileStream("a.txt", FileMode.Open);
// 离开作用域时自动调用 stream.Dispose()它大概等价于:
c
FileStream stream = null;
try
{
stream = new FileStream("a.txt", FileMode.Open);
}
finally
{
stream?.Dispose();
}重点区别:Dispose() 不是“立刻销毁对象”,而是释放对象持有的资源;对象本身的内存仍然由 GC 回收。
面试收尾可以这样说:GC 管托管内存,IDisposable 管资源释放时机;using 是调用 Dispose() 的语法糖。
using 语句的作用是什么?
using 语句的作用是:保证实现了 IDisposable 的对象在离开作用域时自动调用 Dispose(),及时释放资源。
典型写法:
c
using (var stream = new FileStream("a.txt", FileMode.Open))
{
// 使用 stream
}
// 离开 using 块后,自动调用 stream.Dispose()C# 8 之后也可以写成:
c
using var stream = new FileStream("a.txt", FileMode.Open);
// 当前作用域结束时,自动调用 stream.Dispose()它本质上接近于:
c
FileStream stream = null;
try
{
stream = new FileStream("a.txt", FileMode.Open);
// 使用 stream
}
finally
{
stream?.Dispose();
}重点:using 不是立刻让 GC 回收对象,而是确定性地释放资源,比如文件、数据库连接、Socket、非托管资源、Unity 的某些 Native 容器等。
另外要区分两个 using:
c
using System; // 引入命名空间,不是释放资源
using (var obj = new SomeDisposable()) // 释放资源的 using 语句
{
}面试收尾:using 是 try/finally + Dispose() 的语法糖,用来防止资源忘记释放,尤其能保证异常发生时也会执行清理逻辑。
如何排查 Unity 中的 GC Alloc?
排查 Unity 的 GC Alloc:用 Profiler 选中分配异常的帧,在 CPU Usage 里按 GC Alloc 排序,找到具体函数和调用栈,再回代码定位是哪种写法产生了托管堆分配。
基本流程:
- 打开
Window > Analysis > Profiler - 进入目标场景,录制真实操作
- 看
CPU Usage,选中有GC Alloc或 GC 尖峰的帧 - 切到
Hierarchy/Raw Hierarchy - 按
GC Alloc列排序 - 展开调用栈,找到具体脚本函数
- 回代码检查是否有分配写法
- 优化后重新录制验证
常见来源:
c
// 字符串拼接
text.text = "HP: " + hp;
// LINQ
var result = list.Where(x => x.active).ToList();
// 闭包
button.onClick.AddListener(() => Use(id));
// 装箱
object o = 123;
// 临时集合
var temp = new List<int>();
// 数组复制
var arr = list.ToArray();Unity 高频场景尤其要看:
Update/LateUpdate/FixedUpdate- UI 文本刷新
Debug.Log字符串拼接- 子弹、特效、飘字、UI Item 创建销毁
- LINQ、闭包、协程临时对象
foreach是否经过接口枚举导致装箱
注意:GC Alloc 表示这一帧发生了托管堆分配,不等于这一帧一定执行了 GC;但持续分配会增加后续 GC 触发概率。
实战建议:先不要开 Deep Profile,它开销大,可能改变性能表现。普通 Profiler 定位不到时,再短时间打开辅助确认。真机问题最好连 Player 测,不要只看 Editor,因为 Editor 自己也会产生分配噪音。
面试收尾:Profiler 找证据,GC Alloc 找来源,代码里重点查字符串、LINQ、闭包、装箱、临时集合和频繁 Instantiate;优化后一定复测。
参考:Unity 官方 CPU Profiler 文档说明 Hierarchy 可按脚本内存分配排序,GC Alloc 表示当前帧分配的脚本堆内存;官方托管内存优化文档也建议避免频繁触发 GC,并使用对象池复用频繁对象。 来源:Unity CPU Usage Profiler、Unity managed memory optimization