3.4 GC、内存分配与性能基础
.NET 的垃圾回收器会自动释放不再使用的托管对象,但“自动”不等于“没有成本”。全栈项目中,频繁分配对象、大量字符串拼接、一次性读取大文件、忘记释放外部资源,都会变成延迟、吞吐和内存占用问题。
学习目标
- 能解释托管堆、栈、GC 和代际回收的基本关系。
- 能识别常见的高分配代码模式。
- 能正确释放文件、网络连接、数据库连接等非托管资源包装对象。
- 能使用简单指标判断问题更像 CPU、IO、内存还是数据库瓶颈。
应用场景
- Web API 在高并发下响应变慢或内存持续升高。
- 批量导入、导出或报表接口处理大量数据。
- 日志、JSON、字符串处理造成短时间大量分配。
- 线上排查接口延迟、GC 频率和进程内存占用。
核心概念
| 概念 | 说明 |
|---|---|
| 栈 | 保存方法调用、局部变量和部分值类型数据,生命周期通常跟随方法调用结束 |
| 托管堆 | 保存多数引用类型对象,由 GC 负责回收 |
| 代际 GC | 对象按存活时间分代,短命对象通常在 Gen 0 回收,长寿对象可能晋升到 Gen 1/Gen 2 |
| LOH | 大对象堆,通常用于较大的数组、字符串或对象,分配和回收成本更高 |
| 分配压力 | 单位时间内创建对象过多,导致 GC 更频繁运行 |
IDisposable | 表示对象持有需要显式释放的资源,例如文件句柄、网络连接、数据库连接 |
案例:减少导出接口的内存压力
一个任务系统需要导出大量 Todo 标题。低风险做法是先避免无意义的中间集合和字符串重复拼接,再根据数据量决定是否改成流式输出。
public static string BuildCsv(IReadOnlyCollection<TodoExportItem> todos)
{
var builder = new StringBuilder(capacity: todos.Count * 64);
builder.AppendLine("Id,Title,IsCompleted");
foreach (var todo in todos)
{
builder
.Append(todo.Id)
.Append(',')
.Append(EscapeCsv(todo.Title))
.Append(',')
.AppendLine(todo.IsCompleted ? "true" : "false");
}
return builder.ToString();
}
private static string EscapeCsv(string value)
{
return value.Contains(',') || value.Contains('"')
? $"\"{value.Replace("\"", "\"\"")}\""
: value;
}
public sealed record TodoExportItem(int Id, string Title, bool IsCompleted);
这里使用 StringBuilder 是因为循环里的 + 拼接会不断创建新字符串。对于小字符串或一次性拼接,不需要过早优化;对于循环、大文件和高频接口,分配成本才值得关注。
示例:正确释放资源
GC 负责托管内存,不负责“及时关闭文件句柄”。实现了 IDisposable 或 IAsyncDisposable 的对象应放进 using 或 await using。
public static async Task SaveReportAsync(
string path,
string content,
CancellationToken cancellationToken)
{
await using var stream = File.Create(path);
await using var writer = new StreamWriter(stream, Encoding.UTF8);
await writer.WriteAsync(content.AsMemory(), cancellationToken);
}
在 ASP.NET Core 和 EF Core 中,很多资源由依赖注入容器管理生命周期。自己 new 出来的流、连接、压缩器、计时器等对象,通常需要自己释放。
示例:观察分配量
本地排查时,可以先用小实验判断某段代码是否产生了明显分配。这个方法不替代专业基准测试,但适合初步定位问题。
var before = GC.GetAllocatedBytesForCurrentThread();
var titles = todos
.Where(todo => !todo.IsCompleted)
.Select(todo => todo.Title.Trim())
.ToList();
var after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"Allocated bytes: {after - before:N0}");
如果要比较两个实现的真实性能,应使用 BenchmarkDotNet,并在 Release 模式下运行。不要只看一次手工计时结果就下结论。
重点难点
- 值类型不一定总在栈上,引用类型也不等于一定慢;先理解语义,再看具体分配。
- GC 暂停不是唯一性能问题,数据库慢查询、线程池耗尽、外部 HTTP 超时更常见。
string不可变,循环拼接时容易产生大量临时对象。ToList()会立即创建集合;在 LINQ 链中滥用会增加内存占用。- 对象池、数组池和
Span<T>有使用门槛,只有热点路径明确时再引入。
常见误区
| 误区 | 推荐做法 |
|---|---|
| 认为有 GC 就不用关心内存 | 关注高频分配、长寿对象和资源释放 |
为所有对象实现 IDisposable | 只有持有需要释放的资源时才实现 |
看到内存升高就手动 GC.Collect() | 先定位根因,避免破坏运行时自己的回收策略 |
| 用 Debug 模式判断性能 | 使用 Release、真实数据量和稳定基准 |
| 过早使用复杂优化技巧 | 先写清楚,再测量,再优化热点 |
练习
- 把循环字符串拼接改成
StringBuilder,对比分配量。 - 写一个读取大文件前 100 行的方法,避免一次性读取整个文件。
- 找出一个项目中的
ToList(),判断它是否真的需要立即执行。 - 使用
using改写一个文件读写示例,确保异常时也能释放资源。
延伸阅读
- Microsoft Learn:.NET 垃圾回收基础
- Microsoft Learn:
IDisposable和IAsyncDisposable - BenchmarkDotNet 官方文档
- 本手册:集合与 LINQ 和 BCL:JSON、HTTP 与文件