C# 高级教程 - 05 内存管理与 GC

.NET 拥有自动内存管理机制——垃圾回收(GC),但这并不意味着开发者可以完全忽视内存问题。高性能应用、游戏、桌面程序和长时间运行的服务都需要深入理解 GC 的工作方式以及非托管资源的管理策略。本文将从 GC 内部机制出发,讲解代龄模型、GC 模式、IDisposable 模式、弱引用以及内存泄漏的排查方法。

GC 的工作原理

.NET GC 是一种分代式、标记-压缩的垃圾回收器。

代龄模型

代龄 描述 回收频率
Gen 0 新分配的小对象 最频繁,通常几 MB 后触发
Gen 1 Gen 0 幸存的对象 较少,作为 Gen 0 到 Gen 2 的缓冲区
Gen 2 Gen 1 幸存 + 大对象堆 最少,整个进程生命周期可能只回收几次
1
2
3
4
5
// 查看对象所属代龄
object obj = new();
int gen = GC.GetGeneration(obj); // 0
GC.Collect(0); // 仅回收 Gen 0
gen = GC.GetGeneration(obj); // 1

大对象堆(LOH)

大于 85,000 字节的对象被分配到大对象堆(Large Object Heap)。LOH 有以下特点:

  • 默认不压缩(性能开销大)
  • 属于 Gen 2 级别
  • 碎片化问题显著
1
2
3
4
5
byte[] large = new byte[100_000]; // 进入 LOH

// .NET 4.5.1+ 支持 LOH 压缩(谨慎使用)
GCSettings.LargeObjectHeapCompactionMode = GCSettings.LargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

GC 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 工作站模式(默认)—— 低延迟交互式应用
GCSettings.LatencyMode = GCLatencyMode.Interactive;

// 服务器模式 —— 高吞吐量服务端应用(通过 runtimeconfig.json 或 app.config 启用)
// <PropertyGroup><ServerGarbageCollection>true</ServerGarbageCollection></PropertyGroup>

// 低延迟模式 —— 避免 GC 中断关键操作
GCSettings.LatencyMode = GCLatencyMode.LowLatency;

// 完全禁止 GC(极限场景,.NET 6+)
// GCLatencyMode.NoGCRegion
if (GC.TryStartNoGCRegion(100_000_000)) // 分配 100MB 不触发 GC
{
// 执行敏感操作
GC.EndNoGCRegion();
}

IDisposable 与 Dispose 模式

非托管资源(文件句柄、网络连接、数据库链接、GDI 对象)不会被 GC 自动回收,需要手动释放。

标准 Dispose 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ResourceHolder : IDisposable
{
private IntPtr _nativeResource; // 非托管资源
private SafeHandle? _managedResource; // 托管资源

private bool _disposed;

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 避免重复调用终结器
}

protected virtual void Dispose(bool disposing)
{
if (_disposed) return;

if (disposing)
{
// 释放托管资源(实现了 IDisposable 的字段)
_managedResource?.Dispose();
}

// 释放非托管资源
if (_nativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativeResource);
_nativeResource = IntPtr.Zero;
}

_disposed = true;
}

~ResourceHolder()
{
Dispose(false); // 终结器只释放非托管资源
}
}

using 声明(C# 8.0+)

1
2
3
4
5
6
7
8
9
// 传统方式(花括号作用域)
using (var file = new StreamReader("data.txt"))
{
// ...
}

// 新式声明(块级作用域)
using var reader = new StreamReader("data.txt");
// reader 在代码块结束时自动释放

SafeHandle 包装

直接使用 IntPtr 携带非托管资源容易漏释放。推荐使用 SafeHandle 派生类:

1
2
3
4
5
6
7
8
9
public class MySafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public MySafeHandle() : base(true) { }

protected override bool ReleaseHandle()
{
return NativeMethods.FreeResource(handle) == 0;
}
}

弱引用(WeakReference)

弱引用允许引用一个对象但不阻止 GC 回收它,适用于缓存场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ExpensiveData { public byte[] Data = new byte[1024 * 1024]; }

// 弱引用缓存
var cache = new Dictionary<string, WeakReference<ExpensiveData>>();

void AddToCache(string key, ExpensiveData data)
{
cache[key] = new WeakReference<ExpensiveData>(data);
}

bool TryGetFromCache(string key, out ExpensiveData? data)
{
if (cache.TryGetValue(key, out var weakRef))
{
return weakRef.TryGetTarget(out data);
}
data = null;
return false;
}

WeakReference<T>(C# 7+)是类型安全的版本。对于小对象,弱引用本身(约 32 字节)的开销可能超过缓存带来的收益,应仅缓存大对象。

内存泄漏常见原因

1. 事件泄漏

1
2
3
4
5
6
7
public class LeakyPublisher
{
public event EventHandler? SomethingHappened;
}

// 订阅者不被释放
publisher.SomethingHappened += subscriber.OnSomethingHappened;

修复:取消订阅、使用弱事件模式,或改用 IObserver<T>

2. 静态集合无限增长

1
2
3
static List<object> _cache = new();

void CacheData(object data) => _cache.Add(data); // 从不清理

修复:设置上限、结合弱引用、或使用 MemoryCacheMicrosoft.Extensions.Caching)。

3. 匿名方法捕获变量

1
2
3
4
5
6
7
8
9
10
class Leak
{
public event Action? OnAction;

public void Subscribe()
{
var local = new byte[1024 * 1024];
OnAction += () => Console.WriteLine(local.Length); // 闭包持有 local 的引用
}
}

4. 未释放的 Timer

1
2
3
// System.Threading.Timer 保持回调对象活跃
var timer = new Timer(_ => { }, null, 0, 1000);
timer.Dispose(); // 必须销毁

内存分析工具

工具 平台 用途
Visual Studio Diagnostic Tools Windows 实时内存/CPU 分析
dotnet-counters 跨平台 实时性能计数器
dotnet-dump 跨平台 离线内存分析(类似 WinDBG)
JetBrains dotMemory Windows 深度内存泄漏分析
BenchmarkDotNet 跨平台 精确性能基准
1
2
3
4
5
6
# 实时监视 GC 指标
dotnet-counters monitor --process-id 1234 System.Runtime

# 收集转储文件
dotnet-dump collect --process-id 1234
dotnet-dump analyze dump_20250427.dmp

减少 GC 压力的编码技巧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 重用数组/集合(对象池)
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(1024);
try { /* 使用 buffer */ }
finally { pool.Return(buffer); }

// 2. 使用结构体避免堆分配
struct Point { public int X; public int Y; }

// 3. 避免 LINQ 中的闭包分配
var items = Enumerable.Range(1, 100);
int threshold = 50;
items.Where(i => i > threshold).ToList(); // 闭包分配
// 替代:传参(.NET 6+)
items.Where(i => i > 50).ToList(); // 编译为静态方法,零分配

// 4. 使用 StringBuilder 代替字符串拼接
// 5. ValueTask 代替 Task(异步热路径优化)

总结:理解 GC 的分代模型和内存分配策略是编写高性能 .NET 应用的基础。正确实现 Dispose 模式确保非托管资源及时释放,弱引用为缓存场景提供安全方案,而掌握内存分析工具则能在出现问题时快速定位根源。在 .NET 中,”自动内存管理”不等于”无需关心内存”。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: