C# 内存模型与垃圾回收深入
大约 10 分钟约 3058 字
C# 内存模型与垃圾回收深入
简介
C# 运行在 .NET CLR 之上,其内存管理由垃圾回收器(GC)自动完成。深入理解内存模型、托管堆的分代机制、大对象堆(LOH)以及 GC 触发条件,对于编写高性能应用至关重要。本文将深入剖析 CLR 内存管理的底层原理。
特点
托管堆与分代机制
分代回收原理
// .NET GC 采用分代假设(Generational Hypothesis):
// 1. 新对象死亡率高 → Gen0 频繁回收
// 2. 存活越久越可能继续存活 → Gen2 很少回收
// 3. 代龄提升:Gen0 → Gen1 → Gen2
// 查看对象的代龄
var obj = new { Name = "Test" };
int generation = GC.GetGeneration(obj); // 0(新生成对象在 Gen0)
// 手动触发 GC(通常不推荐)
GC.Collect(); // Full GC
GC.Collect(0); // 只回收 Gen0
GC.Collect(1); // 回收 Gen0 + Gen1
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
// 查看各代内存使用
Console.WriteLine($"Gen0: {GC.CollectionCount(0)} 次");
Console.WriteLine($"Gen1: {GC.CollectionCount(1)} 次");
Console.WriteLine($"Gen2: {GC.CollectionCount(2)} 次");
Console.WriteLine($"总内存: {GC.GetTotalMemory(forceFullCollection: false) / 1024.0:F2} KB");GC 触发条件
// GC 触发的几种情况:
// 1. Gen0 预算耗尽(最常见)
// 2. 调用 GC.Collect()
// 3. 操作系统内存不足(OOM 前的最后尝试)
// 4. LOH 阈值到达
// 5. AppDomain 卸载
// 监控 GC 暂停时间
using System.Diagnostics;
var sw = new Stopwatch();
sw.Start();
// 模拟产生大量垃圾
for (int i = 0; i < 1_000_000; i++)
{
_ = new byte[100]; // 短命对象,Gen0 回收
}
sw.Stop();
Console.WriteLine($"耗时: {sw.ElapsedMilliseconds}ms, Gen0回收: {GC.CollectionCount(0)} 次");
// 使用 GC.TryStartNoGCRegion 减少关键路径上的 GC
if (GC.TryStartNoGCRegion(10 * 1024 * 1024)) // 10MB 预算
{
try
{
// 关键路径代码,不会触发 GC
ProcessCriticalData();
}
finally
{
GC.EndNoGCRegion();
}
}大对象堆(LOH)
LOH 机制与碎片
// LOH: 85,000 字节以上的对象直接分配在 LOH
// LOH 只在 Gen2 回收时清理,且不自动压缩 → 碎片化
// LOH 碎片问题示例
void CreateLOHFragmentation()
{
var buffers = new List<byte[]>();
// 分配大对象
for (int i = 0; i < 100; i++)
{
buffers.Add(new byte[100_000]); // 每个约 100KB → LOH
}
// 释放间隔的对象 → 产生空洞
for (int i = 0; i < buffers.Count; i += 2)
{
buffers[i] = null; // 释放偶数位置
}
// LOH 出现碎片:有空闲空间但无法分配连续块
// 解决:ArrayPool 复用大数组
}
// 使用 ArrayPool 减少 LOH 分配
using System.Buffers;
byte[] RentLargeBuffer()
{
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(200_000); // 租用而非 new
try
{
ProcessBuffer(buffer);
return buffer;
}
finally
{
pool.Return(buffer); // 归还到池
}
}
// .NET 8+ LOH 自动压缩
// GCSettings.LargeObjectHeapCompactionMode 可手动触发压缩
System.Runtime.GCSettings.LargeObjectHeapCompactionMode =
System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();MemoryPool 与 IMemoryOwner
using System.Buffers;
using System.Memory;
// MemoryPool<T> — 管理 IMemoryOwner<T> 的池
public class BufferProcessor
{
private readonly MemoryPool<byte> _pool = MemoryPool<byte>.Shared;
public IMemoryOwner<byte> RentBuffer(int size)
{
return _pool.Rent(size);
}
public void ProcessLargeFile(string path)
{
using var owner = _pool.Rent(1024 * 1024); // 1MB
Memory<byte> buffer = owner.Memory;
// 安全地传递 Memory<T>,无需担心 GC 压力
int bytesRead = ReadIntoBuffer(path, buffer.Span);
ProcessBuffer(buffer.Span[..bytesRead]);
}
private int ReadIntoBuffer(string path, Span<byte> span) => 0;
private void ProcessBuffer(Span<byte> span) { }
}GC 模式选择
Workstation vs Server GC
// Workstation GC(默认桌面应用)
// - 单个 GC 线程
// - 并发 GC(Background GC)
// - 内存占用较低
// Server GC(服务端应用)
// - 每个 CPU 核心一个 GC 线程
// - 每个核心独立的堆
// - 吞吐量更高,内存占用更大
// 在 csproj 中配置
// <ServerGarbageCollection>true</ServerGarbageCollection>
// 运行时查看 GC 配置
Console.WriteLine($"GC 模式: {System.Runtime.GCSettings.IsServerGC ? "Server" : "Workstation"}");
Console.WriteLine($"GCLatencyMode: {System.Runtime.GCSettings.LatencyMode}");
// 低延迟模式(适合游戏、实时交易)
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.LowLatency;
// 模式说明:
// Batch — 最大吞吐量,允许长暂停
// Interactive — 平衡吞吐量和响应
// LowLatency — 减少暂停,适合短时间关键操作
// SustainedLowLatency — 长时间低延迟(.NET 4.5+)GC 配置最佳实践
// ASP.NET Core 中的 GC 配置(csproj)
// <PropertyGroup>
// <ServerGarbageCollection>true</ServerGarbageCollection>
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
// <RetainVMGarbageCollection>true</RetainVMGarbageCollection>
// </PropertyGroup>
// runtimeconfig.json
// {
// "configProperties": {
// "System.GC.Server": true,
// "System.GC.Concurrent": true,
// "System.GC.HeapHardLimit": 1073741824, // 1GB 硬限制
// "System.GC.HeapCount": 4, // 堆数量
// "System.GC.NoAffinitize": false // GC 线程绑定 CPU
// }
// }
// Docker 中运行时指定
// docker run -e DOTNET_gcServer=1 -e DOTNET_gcConcurrent=1 ...诊断与调优
使用 dotnet-counters 监控 GC
# 实时监控 GC 指标
dotnet-counters monitor -p <pid> --counters System.Runtime
# 关键指标:
# gc-heap-size — 托管堆总大小
# gen-0-gc-count — Gen0 回收次数
# gen-1-gc-count — Gen1 回收次数
# gen-2-gc-count — Gen2 回收次数
# gen-0-size — Gen0 大小
# gen-1-size — Gen1 大小
# gen-2-size — Gen2 大小
# loh-size — LOH 大小
# poh-size — POH 大小(.NET 5+)
# alloc-rate — 分配速率
# gc-fragmentation — 碎片率使用 dotnet-gcdump 分析内存
# 捕获 GC 堆转储
dotnet-gcdump collect -p <pid>
# 分析最大的对象和类型
dotnet-gcdump report <file.gcdump>
# 使用 dotnet-dump 深入分析
dotnet-dump collect -p <pid>
dotnet-dump analyze <file.dmp>
# SOS 命令
# dumpheap -stat — 按类型统计堆大小
# dumpheap -mt <MT> — 查看指定类型的对象
# gcroot <address> — 查看对象引用链
# gchandlleaks — 查找 GC Handle 泄漏减少分配的策略
// 1. 使用 Span<T> 和 stackalloc 避免堆分配
ReadOnlySpan<char> ProcessName(string name)
{
// 字符串切片不再分配新字符串
return name.AsSpan().Trim();
}
// 2. 使用 ValueTask 减少异步分配
ValueTask<int> ReadAsync(Stream stream, byte[] buffer)
{
// 同步完成时不分配 Task 对象
if (stream.Read(buffer, 0, buffer.Length) is int read && read > 0)
return new ValueTask<int>(read);
return new ValueTask<int>(stream.ReadAsync(buffer, 0, buffer.Length));
}
// 3. 使用对象池
var pool = new DefaultObjectPool<StringBuilder>(new DefaultPooledObjectPolicy<StringBuilder>());
var sb = pool.Get();
try
{
sb.Append("Hello");
return sb.ToString();
}
finally
{
sb.Clear();
pool.Return(sb);
}
// 4. 使用 struct 减少堆分配
// record struct Point(double X, double Y); // 栈分配优点
缺点
内存分配模式分析
Finalizer 与 Dispose 模式
/// <summary>
/// 终结器(Finalizer)与 Dispose 的关系
/// </summary>
// 终结器 — GC 在回收对象前调用的方法
public class ResourceHolder : IDisposable
{
private IntPtr _handle; // 非托管资源
private bool _disposed;
public ResourceHolder()
{
_handle = NativeMethods.AllocateResource();
}
// 终结器 — 安全网,防止忘记调用 Dispose
~ResourceHolder()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 避免终结器被调用
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// 释放托管资源
}
// 释放非托管资源
if (_handle != IntPtr.Zero)
{
NativeMethods.FreeResource(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
}
// 终结器对性能的影响:
// 1. 有终结器的对象需要额外的一次 GC 才能回收(F-Reachable Queue)
// 2. 终结器在专用线程执行,可能延迟 GC
// 3. 终结器执行顺序不确定
// ✅ 使用 SafeHandle 替代手动终结器
public class SafeResourceHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeResourceHandle() : base(true) { }
protected override bool ReleaseHandle()
{
NativeMethods.FreeResource(handle);
return true;
}
}GC Handle 与内存泄漏
/// <summary>
/// GC Handle 类型及其对 GC 的影响
/// </summary>
// GCHandleType 枚举:
// Normal — 普通句柄,防止 GC 回收对象
// Pinned — 固定对象,防止 GC 移动
// Weak — 弱引用,GC 可以回收
// WeakTrackResurrection — 跟踪复活
// Normal 句柄 — 导致内存泄漏
var obj = new byte[1024 * 1024]; // 1MB
var handle = GCHandle.Alloc(obj, GCHandleType.Normal);
obj = null;
GC.Collect(); // obj 不会被回收!handle 持有引用
handle.Free(); // 现在可以回收了
GC.Collect(); // obj 被回收
// Pinned 句柄 — 固定对象防止移动(影响 GC 压缩)
var array = new byte[1024];
var pinned = GCHandle.Alloc(array, GCHandleType.Pinned);
// array 现在被固定在内存中,GC 无法移动它
// 使用 pointer 可以安全地访问 array
unsafe
{
byte* ptr = (byte*)pinned.AddrOfPinnedObject();
ptr[0] = 42;
}
pinned.Free(); // 解除固定
// WeakReference — 弱引用,不阻止 GC
var weakRef = new WeakReference(new byte[1024 * 1024]);
GC.Collect();
Console.WriteLine(weakRef.IsAlive); // False(如果 GC 回收了对象)
// 适用于缓存场景 — 内存紧张时自动释放
// WeakReference<T> — 泛型弱引用
var weakRef2 = new WeakReference<byte[]>(new byte[1024]);
if (weakRef2.TryGetTarget(out var target))
{
// 对象仍然存活
Console.WriteLine(target.Length);
}对象池与减少分配
/// <summary>
/// 使用 ObjectPool 减少堆分配
/// </summary>
// .NET 内置 ObjectPool<T>
using Microsoft.Extensions.ObjectPool;
// 1. StringBuilder 池
var sbPool = new DefaultObjectPool<StringBuilder>(
new StringBuilderPooledObjectPolicy { InitialCapacity = 256 }, 20);
void ProcessWithPool()
{
var sb = sbPool.Get();
try
{
sb.Append("Hello");
sb.Append(" World");
string result = sb.ToString();
// 使用 result...
}
finally
{
sb.Clear(); // 清空后归还
sbPool.Return(sb);
}
}
// 2. 自定义对象池
public class BufferPool
{
private readonly ConcurrentBag<byte[]> _buffers = new();
public byte[] Rent(int minSize)
{
if (_buffers.TryTake(out var buffer) && buffer.Length >= minSize)
return buffer;
return GC.AllocateUninitializedArray<byte>(minSize);
}
public void Return(byte[] buffer)
{
if (buffer.Length <= 1024 * 1024) // 限制最大缓存大小
_buffers.Add(buffer);
}
}
// 3. ArrayPool<T> — BCL 内置数组池
var sharedPool = ArrayPool<byte>.Shared;
byte[] rented = sharedPool.Rent(8192); // 租用(可能返回更大的数组)
try
{
rented[0] = 42;
Console.WriteLine(rented.Length); // 可能大于 8192
}
finally
{
sharedPool.Return(rented, clearArray: true); // 归还并清空
}总结
.NET GC 采用分代回收机制(Gen0/Gen1/Gen2),基于"新对象死亡率高"的假设优化回收效率。大对象(≥85KB)直接进入 LOH,只在 Gen2 回收时处理。Server GC 适合服务端高吞吐场景,Workstation GC 适合桌面应用。低延迟场景可使用 LowLatency 模式或 NoGCRegion。减少堆分配的核心策略:Span<T>、ValueTask、对象池、stackalloc。诊断工具链:dotnet-counters 实时监控、dotnet-gcdump 堆转储、dotnet-dump 深度分析。
关键知识点
- 先明确这个主题影响的是语法层、运行时层,还是性能与可维护性层。
- 学习时要同时关注语言表面写法和编译器、JIT、GC 等底层行为。
- 真正有价值的是知道“为什么这样写”和“在什么边界下不能这样写”。
项目落地视角
- 把示例改成最小可运行样例,并观察编译输出、运行结果和异常行为。
- 如果它会进入团队代码规范,最好同步补充命名约定、禁用场景和替代方案。
- 涉及性能结论时,优先用 Benchmark 或实际热点链路验证,而不是凭感觉判断。
常见误区
- 只记语法糖,不知道底层成本。
- 把适用于小样例的写法直接搬到高并发或大对象场景里。
- 忽略框架版本、语言版本和运行时差异,导致结论失真。
进阶路线
- 继续向源码、IL、JIT 行为和 BCL 实现层深入。
- 把知识点和代码评审、性能诊断、面试复盘结合起来。
- 把同类主题做横向对比,例如值类型与引用类型、迭代器与 async 状态机、反射与 Source Generator。
适用场景
- 当你准备把《C# 内存模型与垃圾回收深入》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合在需要理解语言特性、运行时行为或 API 边界时阅读。
- 当代码开始出现性能瓶颈、可维护性问题或语义歧义时,这类主题会直接影响实现质量。
落地建议
- 先写最小可运行样例,再把结论迁移到真实业务代码。
- 同时记录这个特性的收益、限制和替代方案,避免为了“高级”而使用。
- 涉及内存、并发或序列化时,最好配合调试器或基准测试验证。
排错清单
- 先确认问题属于编译期、运行期还是语义误用。
- 检查是否存在隐式转换、装箱拆箱、闭包捕获或上下文切换等隐藏成本。
- 查看异常栈、日志和最小复现代码,优先排除使用姿势问题。
复盘问题
- 如果把《C# 内存模型与垃圾回收深入》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《C# 内存模型与垃圾回收深入》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《C# 内存模型与垃圾回收深入》最大的收益和代价分别是什么?
