.NETGC内存管理 dotnet-gc-memory

.NET GC 内存管理技能专注于优化 .NET 应用程序的垃圾收集和内存使用,包括 GC 模式配置、分代调优、大对象堆管理、Span<T>/Memory<T> 高级模式、数组池缓冲等,提升应用性能和减少内存开销。关键词:.NET、垃圾收集、内存管理、GC 优化、LOH、POH、Span<T>、Memory<T>、ArrayPool。

后端开发 0 次安装 0 次浏览 更新于 3/6/2026

名称: dotnet-gc-memory 描述: “调优 GC 和内存。GC 模式、LOH/POH、Gen0/1/2、Span/Memory 深度模式、ArrayPool。” 用户可调用: false

dotnet-gc-memory

.NET 应用程序的垃圾收集和内存管理。涵盖 GC 模式(工作站 vs 服务器、并发 vs 非并发)、大对象堆 (LOH) 和固定对象堆 (POH)、分代调优 (Gen0/1/2)、内存压力通知、超越基础的 Span<T>/Memory<T> 所有权模式、使用 ArrayPool<T> 和 MemoryPool<T> 的缓冲区池、弱引用、终结器 vs IDisposable,以及使用 dotMemory 和 PerfView 的内存分析。

范围

  • GC 模式(工作站 vs 服务器、并发 vs 非并发)
  • 大对象堆 (LOH) 和固定对象堆 (POH)
  • 分代调优 (Gen0/1/2) 和内存压力
  • Span<T>/Memory<T> 深度所有权模式
  • 使用 ArrayPool<T> 和 MemoryPool<T> 的缓冲区池
  • 使用 dotMemory 和 PerfView 的内存分析

超出范围

  • Span<T>/Memory<T> 语法介绍和基本使用 – 参见 [skill:dotnet-performance-patterns]
  • 微基准测试设置 – 参见 [skill:dotnet-benchmarkdotnet]
  • CLI 诊断工具 (dotnet-counters, dotnet-trace, dotnet-dump) – 参见 [skill:dotnet-profiling]
  • Channel<T> 生产者/消费者模式 – 参见 [skill:dotnet-channels]

交叉引用: [skill:dotnet-performance-patterns] 用于 Span<T>/Memory<T> 基础和密封反虚化,[skill:dotnet-profiling] 用于运行时诊断工具 (dotnet-counters, dotnet-trace, dotnet-dump),[skill:dotnet-channels] 用于与内存管理交互的反压模式,[skill:dotnet-file-io] 用于 MemoryMappedFile 使用和文件 I/O 中的 POH 缓冲区模式。


GC 模式和配置

工作站 vs 服务器 GC

方面 工作站 服务器
GC 线程 单线程 每个逻辑核心一个线程
堆段 单个堆 每个核心一个堆
暂停延迟 较低 较高(扫描更多内存)
吞吐量 较低 较高
默认用于 控制台应用、桌面应用 ASP.NET Core 网络应用
<!-- 在 .csproj 文件中 -->
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
// 或在 runtimeconfig.json 中
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true
    }
  }
}

并发 vs 非并发 GC

模式 行为 使用时机
并发(默认) Gen2 收集与应用线程并行运行 延迟敏感(网络 API、UI)
非并发 应用线程在 Gen2 收集期间暂停 最大吞吐量、批处理
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Concurrent": true
    }
  }
}

DATAS(动态适应应用大小) – .NET 8+

DATAS 根据应用内存使用模式动态调整 GC 堆大小。在 .NET 8+ 服务器 GC 模式中默认启用。DATAS 通过低活动期缩减堆来减少具有可变负载的应用的内存占用。

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.DynamicAdaptationMode": 1
    }
  }
}

如果观察到稳态工作负载中 GC 频率过高,设置为 0 以禁用 DATAS。

GC 区域 – .NET 7+

区域取代了旧的基于段的堆管理。每个区域是一个小的、固定大小的内存块,GC 可以独立分配和释放。在 .NET 7+ 中默认启用,并改进:

  • 使用峰值后内存返回到操作系统
  • 堆压缩效率
  • 高核心数机器上的服务器 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<T> 和值类型来保持长寿命堆分配低。

监控分代

# 实时 GC 指标
dotnet-counters monitor --process-id <PID> \
  --counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gc-heap-size]
// 编程方式观察 GC
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} 堆={HeapMB:F1}MB",
    gen0, gen1, gen2, totalMemory / (1024.0 * 1024));

大对象堆 (LOH) 和固定对象堆 (POH)

LOH

对象 >= 85,000 字节分配在 LOH 上。LOH 收集仅在 Gen2 收集中发生,默认情况下 LOH 不压缩(导致碎片化)。

// 强制 LOH 压缩(谨慎使用 -- 昂贵)
GCSettings.LargeObjectHeapCompactionMode =
    GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

LOH 碎片预防

策略 实现
ArrayPool<T> 用于大数组 ArrayPool<byte>.Shared.Rent(100_000)
MemoryPool<T> 用于 IMemoryOwner 模式 MemoryPool<byte>.Shared.Rent(100_000)
预分配和重用 在启动时一次性创建大缓冲区
避免频繁的大字符串连接 使用 StringBuilderstring.Create

POH(固定对象堆) – .NET 5+

POH 是专用于必须保持固定内存地址(固定)对象的堆。在 .NET 5 之前,在常规堆上固定对象阻止压缩。POH 隔离固定对象,使它们不阻塞 Gen0/1/2 堆的压缩。

// 在 POH 上分配 -- 用于传递给原生代码的 I/O 缓冲区
byte[] buffer = GC.AllocateArray<byte>(4096, pinned: true);

// 缓冲区的地址不会改变,安全用于原生互操作
// 和重叠 I/O,无需显式 GCHandle 固定

使用 POH 用于:

  • 传递给原生/非托管代码的 I/O 缓冲区
  • 内存映射文件后备数组
  • Socket.ReceiveAsync 一起使用的缓冲区(重叠 I/O)

Span<T>/Memory<T> 深度所有权模式

参见 [skill:dotnet-performance-patterns] 获取 Span<T>/Memory<T> 介绍和基本切片。本节涵盖共享缓冲区的所有权语义和生命周期管理。

IMemoryOwner<T> 用于池化缓冲区

// 从 MemoryPool 租用并使用 IDisposable 管理生命周期
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory[..4096]; // 切片到所需精确大小

// 将 Memory<T> 传递给异步 I/O
int bytesRead = await stream.ReadAsync(buffer, cancellationToken);
Memory<byte> data = buffer[..bytesRead];

// 处理数据
await ProcessDataAsync(data, cancellationToken);
// owner.Dispose() 将缓冲区返回到池

所有权转移模式

当在组件之间传输缓冲区所有权时,使用 IMemoryOwner<T> 使生命周期责任明确:

public sealed class MessageParser
{
    // 调用者转移所有权 -- 此方法负责处置
    public async Task ProcessAsync(
        IMemoryOwner<byte> messageOwner,
        CancellationToken ct)
    {
        using (messageOwner)
        {
            Memory<byte> data = messageOwner.Memory;
            // 解析和处理...
            await HandleMessageAsync(data, ct);
        }
        // 缓冲区在处置时返回到池
    }
}

Span<T> 堆栈纪律

// Span<T> 强制仅堆栈使用(ref struct)
// 这些是编译时错误:
// Span<byte> field;              // 不能存储在类/结构字段中
// async Task Foo(Span<byte> s);  // 不能在异步方法中使用
// var list = new List<Span<byte>>(); // 不能用作泛型类型参数

// 当需要堆存储或异步时,改用 Memory<T>
public async Task ProcessAsync(Memory<byte> buffer, CancellationToken ct)
{
    // 可以在异步方法中使用 Memory<T>
    int bytesRead = await stream.ReadAsync(buffer, ct);

    // 转换为 Span<T> 用于方法内的同步处理
    Span<byte> span = buffer.Span;
    ParseHeader(span[..bytesRead]);
}

ArrayPool<T> 和 MemoryPool<T>

ArrayPool<T>

ArrayPool<T> 通过重用数组分配减少 GC 压力。始终返回租用的数组,并且永远不要假设返回的数组恰好是请求的大小。

// 租用和返回模式
byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 4096);
try
{
    // 重要:租用的数组可能大于请求的大小
    int bytesRead = await stream.ReadAsync(
        buffer.AsMemory(0, 4096), cancellationToken);
    ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
    // clearArray: true 当缓冲区包含敏感数据时
    ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}

自定义池大小调整

// 为特定分配模式创建自定义池
var pool = ArrayPool<byte>.Create(
    maxArrayLength: 1_048_576,  // 最大 1 MB 数组
    maxArraysPerBucket: 50);    // 每个大小桶保留最多 50 个数组

// 用于具有可预测缓冲区大小的工作负载
byte[] buffer = pool.Rent(65_536);
try
{
    // 处理...
}
finally
{
    pool.Return(buffer);
}

MemoryPool<T>

MemoryPool<T> 包装 ArrayPool<T> 并返回 IMemoryOwner<T> 用于 RAII 风格的生命周期管理:

// MemoryPool 返回 IMemoryOwner<T> -- 处置以返回
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
Memory<byte> buffer = owner.Memory;

// 切片到精确大小(owner.Memory 可能更大)
int bytesRead = await stream.ReadAsync(buffer[..8192], ct);
await ProcessAsync(buffer[..bytesRead], ct);
// 处置将底层数组返回到池

池使用指南

指南 理由
始终在 finallyusing 中返回租用的缓冲区 泄漏缓冲区违背了池的目的
在处理前切片到精确大小 租用的数组可能大于请求的大小
对敏感数据使用 clearArray: true 池重用可能向其他消费者暴露秘密
不要将租用的数组缓存在长寿命字段中 无限期持有池缓冲区,减少可用性
优先使用 MemoryPool<T> 而非原始 ArrayPool<T> 基于处置的生命周期更难误用

弱引用和缓存

WeakReference<T>

弱引用允许 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);
    }

    // 定期清理死引用
    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 驱动驱逐时的最后手段。


终结器 vs 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;
        // 无需 GC.SuppressFinalize -- 无终结器
    }
}

终结器(仅安全网)

终结器在对象被收集时在 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)
        {
            // 释放托管资源
        }

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

终结器成本

成本 影响
具有终结器的对象至少存活一次额外 GC 提升到 Gen1/Gen2,增加内存压力
终结器线程是单线程的 慢终结器阻塞所有其他终结
执行顺序非确定性 不能依赖于其他可终结对象
在进程退出时不保证运行 关键清理可能不执行

规则: 使用 sealed 类与 IDisposable(无终结器),除非您拥有非托管句柄。仅作为非托管资源的安全网添加终结器。


内存压力通知

GC.AddMemoryPressure / RemoveMemoryPressure

通知 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 用于自适应行为

// 在应用逻辑中响应内存压力
var memoryInfo = GC.GetGCMemoryInfo();
double loadPercent = (double)memoryInfo.MemoryLoadBytes
    / memoryInfo.TotalAvailableMemoryBytes * 100;

if (loadPercent > 85)
{
    logger.LogWarning("高内存压力:{Load:F1}%", loadPercent);
    // 减轻负载:减少缓存大小,拒绝非关键请求
}

内存分析

dotMemory(JetBrains)

dotMemory 提供具有可视化 UI 的堆快照和分配跟踪。用于调查内存泄漏和高分配热路径。

工作流程:

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

关键视图:

  • Sunburst – 按类型层次显示分配树
  • 主导者树 – 显示哪些对象阻止保留内存的 GC
  • 存活对象 – 快照之间分配并存活于 GC 的对象

PerfView

PerfView 是用于详细 GC 和分配分析的免费微软工具。它使用 ETW(事件跟踪 for Windows)事件进行低开销分析。

# 收集 30 秒的 GC 和分配事件
PerfView.exe /GCCollectOnly /MaxCollectSec:30 collect

# 收集分配堆栈(较高开销)
PerfView.exe /ClrEvents:GC+Stack /MaxCollectSec:30 collect

关键 PerfView 视图:

  • GCStats – GC 暂停时间、分代计数、提升率、碎片化
  • GC 堆分配堆栈 – 负责分配的调用堆栈
  • 任何堆栈 – 用于识别热方法的 CPU 采样

分析工作流程

  1. 识别症状 – 高内存使用、增长 Gen2、频繁 Gen2 收集、LOH 碎片化
  2. 使用 dotnet-counters 监控(参见 [skill:dotnet-profiling])以确认 GC 指标匹配症状
  3. 使用 dotMemory 或 PerfView 分析以识别对象和分配站点
  4. 应用修复 – 池缓冲区、使用 Span<T>、减少分配、修复泄漏
  5. 使用 BenchmarkDotNet 验证(参见 [skill:dotnet-benchmarkdotnet])[MemoryDiagnoser] 以确认改进
  6. 在生产中监控通过 OpenTelemetry 运行时指标(参见 [skill:dotnet-observability])

代理陷阱

  1. 不要默认对 ASP.NET Core 应用使用工作站 GC – 服务器 GC 是默认且正确的网络工作负载选择。工作站 GC 在多核服务器上吞吐量较低。仅针对特定延迟敏感场景覆盖。
  2. 不要忘记返回 ArrayPool 缓冲区 – 泄漏池缓冲区比常规分配更糟,因为它们无限期持有池容量。始终使用 try/finally 或带 usingIMemoryOwner<T>
  3. 不要假设租用的数组是请求的大小ArrayPool<T>.Rent() 可能返回大于请求的数组。在处理前始终切片到精确所需大小。
  4. 不要为仅使用托管资源的类添加终结器 – 终结器将对象提升到 Gen1/Gen2 并增加 GC 开销。使用 sealed classIDisposable(无终结器)进行仅托管清理。
  5. 不要在生成代码中调用 GC.Collect() – 强制完全收集导致长暂停并破坏 GC 的动态调优。使用 GC.AddMemoryPressure() 提示非托管内存。
  6. 不要忽略 LOH 碎片化 – 分配和释放重复的大数组(>= 85,000 字节)碎片化 LOH。使用 ArrayPool<T> 租用和返回大缓冲区,而非分配新数组。
  7. 不要在没有处置跟踪的情况下将 IMemoryOwner<T> 缓存在长寿命字段中 – 底层池缓冲区被无限期持有,阻止池重用。明确转移所有权或限制缓存寿命。

参考