mcpField
D
dotnet-gc-memory

dotnet-gc-memory

github.com/wshaddix开发8684 次安装35 周前更新
代码仓库

关于 dotnet-gc-memory

面向 .NET 开发者的垃圾回收与内存管理技能,涉及 GC 模式、堆分代、缓冲区池和资源生命周期。也涵盖 Span/Memory 所有权、弱引用及内存分析工具。

主要功能

  1. 01GC 模式与参数调优
  2. 02Gen0、Gen1、Gen2 管理
  3. 03LOH 与 POH 使用
  4. 04ArrayPool 与 MemoryPool
  5. 05Span/Memory 所有权模式
  6. 06弱引用及资源释放

适用场景

  1. 01调整服务的 GC 模式
  2. 02减少大型缓冲区分配
  3. 03管理异步 I/O 缓冲区所有权
  4. 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 回收。

调优原则
  1. 将 Gen0 分配速率降至最低 —— 减少热路径上的临时对象创建。每次分配都会增加 Gen0 压力。
  2. 避免对象在生命周期中段陷入困境 —— 存活时间刚好足以晋升到 Gen1/Gen2、随后却变成垃圾的对象代价最高。它们会躲过开销较低的 Gen0 回收,却需要开销较高的 Gen2 回收才能清理。
  3. 降低 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 碎片
策略实现方式
大型数组使用 ArrayPoolArrayPool<byte>.Shared.Rent(100_000)
对于 IMemoryOwner 模式使用 MemoryPoolMemoryPool<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 提供堆快照和分配跟踪,并配有可视化界面。可用它来调查内存泄漏和分配量较高的热点路径。

工作流:

  1. 将 dotMemory 附加到正在运行的进程(或在启用分析的情况下启动进程)
  2. 应用预热后捕获基线快照
  3. 执行待调查的场景
  4. 捕获第二个快照
  5. 比较快照,以确定保留的对象和增长情况

主要视图:

  • 旭日图 -- 按类型层次结构显示分配树
  • 支配树 -- 显示哪些对象阻止了保留内存被 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 采样,用于识别热点方法
分析工作流
  1. 识别症状 -- 内存使用量高、Gen2 持续增长、频繁进行 Gen2 回收、LOH 碎片
  2. 使用 dotnet-counters 监控(参见 [skill:dotnet-profiling]),确认 GC 指标与症状相符
  3. 使用 dotMemory 或 PerfView 进行分析,以确定相关对象和分配位置
  4. 应用修复 -- 缓冲区池化、使用 Span、减少分配、修复泄漏
  5. 使用 BenchmarkDotNet [MemoryDiagnoser] 验证(参见 [skill:dotnet-benchmarkdotnet]),确认改进效果
  6. 在生产环境中通过 OpenTelemetry 运行时指标监控(参见 [skill:dotnet-observability])

Agent 易错点

  1. ASP.NET Core 应用不要默认使用 workstation GC——server GC 是 Web 工作负载的默认且正确选择。workstation GC 在多核服务器上的吞吐量较低。只有在特定的延迟敏感场景下才覆盖此设置。
  2. 不要忘记归还 ArrayPool 缓冲区——泄漏的池缓冲区比常规分配更糟糕,因为它们会无限期占用池容量。务必使用 try/finally 或通过 using 使用 IMemoryOwner<T>。
  3. 不要假设租用的数组大小与请求大小一致——ArrayPool<T>.Rent() 返回的数组可能比请求的更大。处理前务必将其切片为所需的精确大小。
  4. 仅使用托管资源的类不要添加终结器——终结器会使对象晋升到 Gen1/Gen2,并增加 GC 开销。对于仅清理托管资源的情况,使用实现 IDisposable 的 sealed class(不添加终结器)。
  5. 不要在生产代码中调用 GC.Collect()——强制执行完整回收会造成长时间暂停,并干扰 GC 的动态调优。对于非托管内存,请使用 GC.AddMemoryPressure() 提供提示。
  6. 不要忽视 LOH 碎片——反复分配和释放大型数组(>= 85,000 bytes)会导致 LOH 碎片化。使用 ArrayPool<T> 租用并归还大型缓冲区,而不是分配新数组。
  7. 不要在长期存活的字段中缓存 IMemoryOwner,却不跟踪释放情况——底层池缓冲区会被无限期占用,导致池无法复用。请显式转移所有权,或限制缓存生命周期。

参考资料

评论

支持 Markdown