关于 dotnet-gc-memory
面向 .NET 开发者的垃圾回收与内存管理技能,涉及 GC 模式、堆分代、缓冲区池和资源生命周期。也涵盖 Span/Memory 所有权、弱引用及内存分析工具。
主要功能
- 01GC 模式与参数调优
- 02Gen0、Gen1、Gen2 管理
- 03LOH 与 POH 使用
- 04ArrayPool 与 MemoryPool
- 05Span/Memory 所有权模式
- 06弱引用及资源释放
适用场景
- 01调整服务的 GC 模式
- 02减少大型缓冲区分配
- 03管理异步 I/O 缓冲区所有权
- 04分析缓存和非托管资源生命周期
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-gc-memory安装后在 Claude Code 中输入/dotnet-gc-memory确认已加载。
详细介绍
dotnet-gc-memory
面向 .NET 应用程序的垃圾回收和内存管理。涵盖 GC 模式(工作站与服务器、并发与非并发)、大对象堆(LOH)和固定对象堆(POH)、分代调优(Gen0/1/2)、内存压力通知、超越基础用法的 Span/Memory 所有权模式、使用 ArrayPool 和 MemoryPool 进行缓冲区池化、弱引用、终结器与 IDisposable 的比较,以及使用 dotMemory 和 PerfView 进行内存分析。
不在范围内: Span/Memory 语法介绍和基本用法——请参阅 [skill:dotnet-performance-patterns]。微基准测试设置——请参阅 [skill:dotnet-benchmarkdotnet]。CLI 诊断工具(dotnet-counters、dotnet-trace、dotnet-dump)——请参阅 [skill:dotnet-profiling]。Channel 生产者/消费者模式——请参阅 [skill:dotnet-channels]。
交叉引用:[skill:dotnet-performance-patterns] 介绍 Span/Memory 基础知识和 sealed 去虚拟化;[skill:dotnet-profiling] 介绍运行时诊断工具(dotnet-counters、dotnet-trace、dotnet-dump);[skill:dotnet-channels] 介绍与内存管理交互的背压模式;[skill:dotnet-file-io] 介绍 MemoryMappedFile 用法以及文件 I/O 中的 POH 缓冲区模式。
GC 模式和配置
工作站 GC 与服务器 GC
| 方面 | 工作站 | 服务器 |
|---|---|---|
| GC 线程 | 单个线程 | 每个逻辑核心一个线程 |
| 堆段 | 单个堆 | 每个核心一个堆 |
| 暂停延迟 | 较低 | 较高(扫描的内存更多) |
| 吞吐量 | 较低 | 较高 |
| 默认适用对象 | 控制台应用、桌面应用 | ASP.NET Core Web 应用 |
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
// Or in runtimeconfig.json
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
}
}
并发 GC 与非并发 GC
| 模式 | 行为 | 适用场景 |
|---|---|---|
| 并发(默认) | Gen2 回收与应用程序线程并行运行 | 对延迟敏感的场景(Web API、UI) |
| 非并发 | Gen2 回收期间应用程序线程暂停 | 追求最大吞吐量、批处理 |
{
"runtimeOptions": {
"configProperties": {
"System.GC.Concurrent": true
}
}
}
DATAS(动态适应应用程序大小)——.NET 8+
DATAS 会根据应用程序的内存使用模式动态调整 GC 堆大小。在 .NET 8+ 的服务器 GC 模式下,默认启用 DATAS。对于负载变化的应用程序,DATAS 会在低活动期缩小堆,从而减少内存占用。
{
"runtimeOptions": {
"configProperties": {
"System.GC.DynamicAdaptationMode": 1
}
}
}
如果在稳定负载下观察到 GC 频率过高,可将其设为 0 以禁用 DATAS。
GC 区域——.NET 7+
区域取代了较早的基于段的堆管理方式。每个区域都是一个较小的固定大小内存块,GC 可以独立分配和释放。区域在 .NET 7+ 中默认启用,并可提升:
- 使用高峰后将内存归还给 OS 的能力
- 堆压缩效率
- 高核心数机器上的服务器 GC 可扩展性
无需进行配置——区域是默认设置。如需恢复为段(很少需要):
{
"runtimeOptions": {
"configProperties": {
"System.GC.Regions": false
}
}
}
分代 GC(Gen0/1/2)
分代的工作方式
| 代 | 内容 | 回收频率 | 回收成本 |
|---|---|---|---|
| Gen0 | 新分配的对象 | 非常频繁(毫秒级) | 非常低(堆较小) |
| Gen1 | 在 Gen0 中存活的对象 | 频繁 | 较低 |
| Gen2 | 长期存活的对象 | 不频繁 | 较高(扫描整个堆) |
对象在多次回收中存活后,会从 Gen0 晋升到 Gen1,再晋升到 Gen2。Gen0 的 GC 预算会动态调整——Gen0 填满时,就会触发 Gen0 回收。
调优原则
- 将 Gen0 分配速率降至最低 —— 减少热路径上的临时对象创建。每次分配都会增加 Gen0 压力。
- 避免对象在生命周期中段陷入困境 —— 存活时间刚好足以晋升到 Gen1/Gen2、随后却变成垃圾的对象代价最高。它们会躲过开销较低的 Gen0 回收,却需要开销较高的 Gen2 回收才能清理。
- 降低 Gen2 回收频率 —— Gen2 回收造成的暂停时间最长。使用对象池、Span 和值类型,减少长生命周期的堆分配。
监控各代
# Real-time GC metrics
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gc-heap-size]
// Programmatic GC observation
var gen0 = GC.CollectionCount(0);
var gen1 = GC.CollectionCount(1);
var gen2 = GC.CollectionCount(2);
var totalMemory = GC.GetTotalMemory(forceFullCollection: false);
var memoryInfo = GC.GetGCMemoryInfo();
logger.LogInformation(
"GC: Gen0={Gen0} Gen1={Gen1} Gen2={Gen2} Heap={HeapMB:F1}MB",
gen0, gen1, gen2, totalMemory / (1024.0 * 1024));
大对象堆(LOH)和固定对象堆(POH)
LOH
大小 >= 85,000 字节的对象会分配到 LOH。LOH 仅在 Gen2 回收期间进行回收,且默认情况下不会压缩 LOH(这会导致碎片)。
// Force LOH compaction (use sparingly -- expensive)
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
防止 LOH 碎片
| 策略 | 实现方式 |
|---|---|
| 大型数组使用 ArrayPool | ArrayPool<byte>.Shared.Rent(100_000) |
| 对于 IMemoryOwner 模式使用 MemoryPool | MemoryPool<byte>.Shared.Rent(100_000) |
| 预分配并重用 | 在启动时创建大型缓冲区 |
| 避免频繁拼接大型字符串 | 使用 StringBuilder 或 string.Create |
POH(固定对象堆)—— .NET 5+
POH 是专用于必须保持固定内存地址(固定)的对象的堆。在 .NET 5 之前,固定常规堆上的对象会阻止压缩。POH 将固定对象隔离开来,因此它们不会阻碍 Gen0/1/2 堆的压缩。
// Allocate on POH -- useful for I/O buffers passed to native code
byte[] buffer = GC.AllocateArray<byte>(4096, pinned: true);
// The buffer's address will not change, safe for native interop
// and overlapped I/O without explicit GCHandle pinning
在以下场景中使用 POH:
- 传递给本机/非托管代码的 I/O 缓冲区
- 内存映射文件的后备数组
- 与
Socket.ReceiveAsync(重叠 I/O)配合使用的缓冲区
Span/Memory 深入所有权模式
有关 Span/Memory 的介绍和基本切片操作,请参阅 [skill:dotnet-performance-patterns]。本节介绍共享缓冲区的所有权语义和生命周期管理。
用于池化缓冲区的 IMemoryOwner
// Rent from MemoryPool and manage lifetime with IDisposable
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory[..4096]; // Slice to exact size needed
// Pass the Memory<T> to async I/O
int bytesRead = await stream.ReadAsync(buffer, cancellationToken);
Memory<byte> data = buffer[..bytesRead];
// Process the data
await ProcessDataAsync(data, cancellationToken);
// owner.Dispose() returns the buffer to the pool
所有权转移模式
在组件之间转移缓冲区所有权时,使用 IMemoryOwner<T> 明确生命周期责任:
public sealed class MessageParser
{
// Caller transfers ownership -- this method is responsible for disposal
public async Task ProcessAsync(
IMemoryOwner<byte> messageOwner,
CancellationToken ct)
{
using (messageOwner)
{
Memory<byte> data = messageOwner.Memory;
// Parse and process...
await HandleMessageAsync(data, ct);
}
// Buffer returned to pool on dispose
}
}
Span 栈纪律
// Span<T> enforces stack-only usage (ref struct)
// These are compile-time errors:
// Span<byte> field; // Cannot store in class/struct field
// async Task Foo(Span<byte> s); // Cannot use in async method
// var list = new List<Span<byte>>(); // Cannot use as generic type argument
// When you need heap storage or async, use Memory<T> instead
public async Task ProcessAsync(Memory<byte> buffer, CancellationToken ct)
{
// Can use Memory<T> in async methods
int bytesRead = await stream.ReadAsync(buffer, ct);
// Convert to Span<T> for synchronous processing within a method
Span<byte> span = buffer.Span;
ParseHeader(span[..bytesRead]);
}
ArrayPool 和 MemoryPool
ArrayPool
ArrayPool<T> 通过重用数组分配来降低 GC 压力。始终归还租用的数组,且不要假设返回的数组大小恰好等于请求的大小。
// Rent and return pattern
byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 4096);
try
{
// IMPORTANT: Rented array may be larger than requested
int bytesRead = await stream.ReadAsync(
buffer.AsMemory(0, 4096), cancellationToken);
ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
// clearArray: true when buffer contained sensitive data
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
自定义池大小
// Create a custom pool for specific allocation patterns
var pool = ArrayPool<byte>.Create(
maxArrayLength: 1_048_576, // 1 MB max array
maxArraysPerBucket: 50); // Keep up to 50 arrays per size bucket
// Use for workloads with predictable buffer sizes
byte[] buffer = pool.Rent(65_536);
try
{
// Process...
}
finally
{
pool.Return(buffer);
}
MemoryPool
MemoryPool<T> 封装 ArrayPool<T>,并返回 IMemoryOwner<T>,以实现 RAII 风格的生命周期管理:
// MemoryPool returns IMemoryOwner<T> -- dispose to return
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
Memory<byte> buffer = owner.Memory;
// Slice to exact size (owner.Memory may be larger)
int bytesRead = await stream.ReadAsync(buffer[..8192], ct);
await ProcessAsync(buffer[..bytesRead], ct);
// Dispose returns the underlying array to the pool
池使用指南
| 指南 | 原因 |
|---|---|
始终在 finally 或 using 中归还租用的缓冲区 | 缓冲区泄漏会使池化失去意义 |
| 处理前切片到精确大小 | 租用的数组可能大于请求的大小 |
对敏感数据使用 clearArray: true | 池化重用可能会将机密暴露给其他使用者 |
| 不要在长生命周期字段中缓存租用的数组 | 会无限期占用池缓冲区,降低可用性 |
优先使用 MemoryPool<T>,而非原始 ArrayPool<T> | 基于释放的生命周期管理较难误用 |
弱引用与缓存
WeakReference
当没有强引用时,弱引用允许 GC 回收目标对象。可用于在内存压力下允许回收的缓存。
public sealed class ImageCache
{
private readonly ConcurrentDictionary<string, WeakReference<byte[]>> _cache = new();
public byte[]? TryGet(string key)
{
if (_cache.TryGetValue(key, out var weakRef)
&& weakRef.TryGetTarget(out var data))
{
return data;
}
return null;
}
public void Set(string key, byte[] data)
{
_cache[key] = new WeakReference<byte[]>(data);
}
// Periodically clean up dead references
public void Purge()
{
foreach (var key in _cache.Keys)
{
if (_cache.TryGetValue(key, out var weakRef)
&& !weakRef.TryGetTarget(out _))
{
_cache.TryRemove(key, out _);
}
}
}
}
何时使用弱引用
- 内存压力应触发淘汰的大型对象缓存
- 计算成本高但可重新创建的数据缓存(图像缩略图、渲染后的模板)
- 不要用于小型对象——
WeakReference<T>的开销会超过其收益
对于大多数缓存场景,优先使用具有大小限制和过期策略的 MemoryCache。只有在需要由 GC 驱动淘汰时,才将弱引用作为最后手段。
Finalizer 与 IDisposable
IDisposable(首选)
实现 IDisposable,以确定性地释放非托管资源:
public sealed class NativeBufferWrapper : IDisposable
{
private IntPtr _handle;
private bool _disposed;
public NativeBufferWrapper(int size)
{
_handle = Marshal.AllocHGlobal(size);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Marshal.FreeHGlobal(_handle);
_handle = IntPtr.Zero;
// No GC.SuppressFinalize needed -- no finalizer
}
}
终结器(仅作为安全保障)
对象被回收时,终结器会在 GC 终结器线程上运行。对于未显式释放的非托管资源,终结器可提供安全保障。
public class UnmanagedResourceHolder : IDisposable
{
private IntPtr _handle;
private bool _disposed;
public UnmanagedResourceHolder(int size)
{
_handle = Marshal.AllocHGlobal(size);
}
~UnmanagedResourceHolder()
{
Dispose(disposing: false);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
_disposed = true;
if (disposing)
{
// Free managed resources
}
// Free unmanaged resources
if (_handle != IntPtr.Zero)
{
Marshal.FreeHGlobal(_handle);
_handle = IntPtr.Zero;
}
}
}
终结器的代价
| 代价 | 影响 |
|---|---|
| 带有终结器的对象至少会多存活一次 GC | 晋升到 Gen1/Gen2,增加内存压力 |
| 终结器线程是单线程的 | 运行缓慢的终结器会阻塞其他所有终结操作 |
| 执行顺序不确定 | 不能依赖其他可终结对象 |
| 不保证在进程退出时运行 | 关键清理操作可能不会执行 |
**规则:**除非你拥有非托管句柄,否则请使用实现 IDisposable 的 sealed 类(不带终结器)。仅将终结器作为非托管资源的安全保障。
内存压力通知
GC.AddMemoryPressure / RemoveMemoryPressure
告知 GC 非托管内存分配情况,以便 GC 在做出回收决策时将其纳入考量:
public sealed class NativeImageBuffer : IDisposable
{
private readonly IntPtr _buffer;
private readonly long _size;
private bool _disposed;
public NativeImageBuffer(long sizeBytes)
{
_size = sizeBytes;
_buffer = Marshal.AllocHGlobal((IntPtr)sizeBytes);
GC.AddMemoryPressure(sizeBytes);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Marshal.FreeHGlobal(_buffer);
GC.RemoveMemoryPressure(_size);
}
}
使用 GC.GetGCMemoryInfo 实现自适应行为
// React to memory pressure in application logic
var memoryInfo = GC.GetGCMemoryInfo();
double loadPercent = (double)memoryInfo.MemoryLoadBytes
/ memoryInfo.TotalAvailableMemoryBytes * 100;
if (loadPercent > 85)
{
logger.LogWarning("High memory pressure: {Load:F1}%", loadPercent);
// Shed load: reduce cache sizes, reject non-critical requests
}
内存分析
dotMemory(JetBrains)
dotMemory 提供堆快照和分配跟踪,并配有可视化界面。可用它来调查内存泄漏和分配量较高的热点路径。
工作流:
- 将 dotMemory 附加到正在运行的进程(或在启用分析的情况下启动进程)
- 应用预热后捕获基线快照
- 执行待调查的场景
- 捕获第二个快照
- 比较快照,以确定保留的对象和增长情况
主要视图:
- 旭日图 -- 按类型层次结构显示分配树
- 支配树 -- 显示哪些对象阻止了保留内存被 GC 回收
- 存活对象 -- 在两次快照之间分配且在 GC 后仍存活的对象
PerfView
PerfView 是一款免费的 Microsoft 工具,可用于详细分析 GC 和内存分配。它使用 ETW(Windows 事件跟踪)事件进行低开销分析。
# Collect GC and allocation events for 30 seconds
PerfView.exe /GCCollectOnly /MaxCollectSec:30 collect
# Collect allocation stacks (higher overhead)
PerfView.exe /ClrEvents:GC+Stack /MaxCollectSec:30 collect
PerfView 主要视图:
- GCStats -- GC 暂停时间、代数计数、晋升率、碎片情况
- GC Heap Alloc Stacks -- 导致分配的调用堆栈
- Any Stacks -- CPU 采样,用于识别热点方法
分析工作流
- 识别症状 -- 内存使用量高、Gen2 持续增长、频繁进行 Gen2 回收、LOH 碎片
- 使用 dotnet-counters 监控(参见 [skill:dotnet-profiling]),确认 GC 指标与症状相符
- 使用 dotMemory 或 PerfView 进行分析,以确定相关对象和分配位置
- 应用修复 -- 缓冲区池化、使用 Span、减少分配、修复泄漏
- 使用 BenchmarkDotNet
[MemoryDiagnoser]验证(参见 [skill:dotnet-benchmarkdotnet]),确认改进效果 - 在生产环境中通过 OpenTelemetry 运行时指标监控(参见 [skill:dotnet-observability])
Agent 易错点
- ASP.NET Core 应用不要默认使用 workstation GC——server GC 是 Web 工作负载的默认且正确选择。workstation GC 在多核服务器上的吞吐量较低。只有在特定的延迟敏感场景下才覆盖此设置。
- 不要忘记归还 ArrayPool 缓冲区——泄漏的池缓冲区比常规分配更糟糕,因为它们会无限期占用池容量。务必使用
try/finally或通过using使用IMemoryOwner<T>。 - 不要假设租用的数组大小与请求大小一致——
ArrayPool<T>.Rent()返回的数组可能比请求的更大。处理前务必将其切片为所需的精确大小。 - 仅使用托管资源的类不要添加终结器——终结器会使对象晋升到 Gen1/Gen2,并增加 GC 开销。对于仅清理托管资源的情况,使用实现
IDisposable的sealed class(不添加终结器)。 - 不要在生产代码中调用 GC.Collect()——强制执行完整回收会造成长时间暂停,并干扰 GC 的动态调优。对于非托管内存,请使用
GC.AddMemoryPressure()提供提示。 - 不要忽视 LOH 碎片——反复分配和释放大型数组(>= 85,000 bytes)会导致 LOH 碎片化。使用
ArrayPool<T>租用并归还大型缓冲区,而不是分配新数组。 - 不要在长期存活的字段中缓存 IMemoryOwner,却不跟踪释放情况——底层池缓冲区会被无限期占用,导致池无法复用。请显式转移所有权,或限制缓存生命周期。