Skip to content

内存与 GC

C# 的 GC 是什么?

csharp-gc-explained

C# 的 GC 是 .NET 的垃圾回收器,负责自动回收托管堆上不再被使用的对象内存。

c
var user = new User();

像这种 new 出来的引用类型对象通常在托管堆上,由 GC 管理生命周期。你不用像 C/C++ 那样手动 free / delete

GC 大致做三件事:

TIP

  1. 找根对象

比如局部变量、静态字段、线程栈、GC Handle 等。

  1. 做可达性分析

从根对象出发,能一路引用到的对象,说明还活着;找不到的对象,就是垃圾。

  1. 回收和整理内存

不可达对象会被回收,部分情况下还会压缩内存,减少碎片。

分代回收

GC 把对象分成几代:

分代含义
Gen 0新创建的短生命周期对象
Gen 1中间过渡代
Gen 2存活较久的对象

核心思想是:大多数对象很快就不用了,所以 GC 会更频繁地回收 Gen 0,减少成本。

面试加分点

GC 管的是托管内存,不是所有资源。比如文件句柄、数据库连接、Socket、Unity 里的纹理/Native 资源等,通常要靠:

c
using
IDisposable
Dispose()

收尾可以这样说:

GC 通过可达性分析自动回收托管堆中不再使用的对象,并用分代机制提升效率。但 GC 不能替你及时释放非托管资源,所以涉及文件、网络、数据库、Unity Native 资源时,要正确使用 Disposeusing

托管内存和非托管内存区别是什么?

managed-vs-unmanaged-memory托管内存由 .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-alloc-patterns

Unity 中容易产生 GC 的写法,本质都是在运行时产生了新的托管对象,尤其是在 UpdateFixedUpdateOnGUI 这类高频函数里。

常见来源:

写法为什么容易 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 的核心是减少高频路径上的托管分配。少在 Updatenew,少用 LINQ 和闭包,字符串 UI 更新要控制频率,集合和数组尽量复用,Unity API 优先选择 NonAlloc 版本。最终要用 Profiler 看 GC Alloc,不要只凭感觉优化。

foreach 会不会产生 GC?

foreach-gc-alloc

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) { }

数组会被编译器优化成类似 forList<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?

string-concat-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-scenarios

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?

object-pool-reduce-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-explained

IDisposable 是 C# 用来“手动、确定性释放资源”的接口,核心方法只有一个:

c
public interface IDisposable
{
    void Dispose();
}

GC 主要负责回收托管内存,但很多资源不能只等 GC,比如:

  • 文件句柄
  • 数据库连接
  • Socket
  • 非托管内存
  • Unity 里的 NativeArrayComputeBuffer 等需要释放的资源

所以这些对象会实现 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-statement-purpose

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 语句
{
}

面试收尾:usingtry/finally + Dispose() 的语法糖,用来防止资源忘记释放,尤其能保证异常发生时也会执行清理逻辑。

如何排查 Unity 中的 GC Alloc?

unity-gc-alloc-debug-workflow

排查 Unity 的 GC Alloc:用 Profiler 选中分配异常的帧,在 CPU Usage 里按 GC Alloc 排序,找到具体函数和调用栈,再回代码定位是哪种写法产生了托管堆分配。

基本流程:

  1. 打开 Window > Analysis > Profiler
  2. 进入目标场景,录制真实操作
  3. CPU Usage,选中有 GC Alloc 或 GC 尖峰的帧
  4. 切到 Hierarchy / Raw Hierarchy
  5. GC Alloc 列排序
  6. 展开调用栈,找到具体脚本函数
  7. 回代码检查是否有分配写法
  8. 优化后重新录制验证

常见来源:

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 ProfilerUnity managed memory optimization

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0