跳到主要内容

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 负责托管内存,不负责“及时关闭文件句柄”。实现了 IDisposableIAsyncDisposable 的对象应放进 usingawait 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 改写一个文件读写示例,确保异常时也能释放资源。

延伸阅读