名称: 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 收集。
调优原则
- 最小化 Gen0 分配率 – 减少热路径上的临时对象创建。每个分配都会增加 Gen0 压力。
- 避免中年危机 – 对象存活足够长以提升到 Gen1/Gen2 但随后变成垃圾是最昂贵的。它们存活于便宜的 Gen0 收集,并需要昂贵的 Gen2 收集来回收。
- 减少 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) |
| 预分配和重用 | 在启动时一次性创建大缓冲区 |
| 避免频繁的大字符串连接 | 使用 StringBuilder 或 string.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);
// 处置将底层数组返回到池
池使用指南
| 指南 | 理由 |
|---|---|
始终在 finally 或 using 中返回租用的缓冲区 |
泄漏缓冲区违背了池的目的 |
| 在处理前切片到精确大小 | 租用的数组可能大于请求的大小 |
对敏感数据使用 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 的堆快照和分配跟踪。用于调查内存泄漏和高分配热路径。
工作流程:
- 将 dotMemory 附加到运行进程(或启用以分析启动)
- 应用预热后捕获基线快照
- 执行调查场景
- 捕获第二个快照
- 比较快照以识别保留对象和增长
关键视图:
- 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 采样
分析工作流程
- 识别症状 – 高内存使用、增长 Gen2、频繁 Gen2 收集、LOH 碎片化
- 使用 dotnet-counters 监控(参见 [skill:dotnet-profiling])以确认 GC 指标匹配症状
- 使用 dotMemory 或 PerfView 分析以识别对象和分配站点
- 应用修复 – 池缓冲区、使用 Span<T>、减少分配、修复泄漏
- 使用 BenchmarkDotNet 验证(参见 [skill:dotnet-benchmarkdotnet])
[MemoryDiagnoser]以确认改进 - 在生产中监控通过 OpenTelemetry 运行时指标(参见 [skill:dotnet-observability])
代理陷阱
- 不要默认对 ASP.NET Core 应用使用工作站 GC – 服务器 GC 是默认且正确的网络工作负载选择。工作站 GC 在多核服务器上吞吐量较低。仅针对特定延迟敏感场景覆盖。
- 不要忘记返回 ArrayPool 缓冲区 – 泄漏池缓冲区比常规分配更糟,因为它们无限期持有池容量。始终使用
try/finally或带using的IMemoryOwner<T>。 - 不要假设租用的数组是请求的大小 –
ArrayPool<T>.Rent()可能返回大于请求的数组。在处理前始终切片到精确所需大小。 - 不要为仅使用托管资源的类添加终结器 – 终结器将对象提升到 Gen1/Gen2 并增加 GC 开销。使用
sealed class与IDisposable(无终结器)进行仅托管清理。 - 不要在生成代码中调用 GC.Collect() – 强制完全收集导致长暂停并破坏 GC 的动态调优。使用
GC.AddMemoryPressure()提示非托管内存。 - 不要忽略 LOH 碎片化 – 分配和释放重复的大数组(>= 85,000 字节)碎片化 LOH。使用
ArrayPool<T>租用和返回大缓冲区,而非分配新数组。 - 不要在没有处置跟踪的情况下将 IMemoryOwner<T> 缓存在长寿命字段中 – 底层池缓冲区被无限期持有,阻止池重用。明确转移所有权或限制缓存寿命。