从分代预算到 DATAS:.NET GC 调优到底在调什么

关于 .NET GC 的资料里,"Server GC 吞吐高、Workstation GC 延迟低""把 GC.Collect 去掉就好了"这类结论流传得比机制本身更广。结果是在容器里把 ServerGarbageCollection 打开、堆内存翻了三倍,或者照着旧文调 GCLatencyMode 却发现毫无变化。这篇想讲清楚一件事:GC 的所有可调参数,最终都收敛到两个量上------gen0 预算和堆的回收目标。理解这两个量怎么被算出来,DATAS 的取舍也就自然清楚了。

结论先行

  • gen0 预算不是固定值,它由存活率反馈动态调整;你写的分配代码在决定下一次 GC 什么时候来。所有"调 GC"的手段,本质上都是在干预这个反馈回路。
  • Server GC 的内存占用瓶颈是"每核一个堆",不是"回收不及时"。核多的机器上,固定配置的 Server GC 会为每个 heap 预留独立的 gen0 预算,空闲服务也会占住一大片。
  • DATAS 改的是目标函数:从"按 CPU 核数和物理内存分配"改成"跟着存活数据量走",代价是吞吐略降、行为不再随时间恒定。
  • 容器里最危险的失败是静默的:GC 读不到 cgroup 限额时不会报错,它会按宿主机的内存规划堆,然后被 OOM Killer 杀掉。
  • GC.Collect 之后 RSS 不降是正常的,region 化堆的释放是延迟且可 decommit 的。把"内存没降"当成泄漏,会引出更糟的调优动作。

机制:两个数字决定一切

gen0 预算与存活率反馈

小对象堆分三代,但 GC 真正频繁决策的只有 gen0。每次 gen0 GC 开始时,运行时手里有一个分配预算(allocation budget):这一轮允许分配多少字节,用完就触发下一次收集。这个预算不是常数,它由上一轮的存活率反推------存活得多,说明这批对象大概率还要活着,预算收紧;存活得少,说明大部分是短命对象,预算可以放大,让 GC 少跑几趟。

这条反馈回路解释了很多现象。一个每秒分配几百 MB 的解析器,gen0 会跑得很密,但每次都很短,因为存活率极低;反过来,一个把请求上下文缓存进静态字典的服务,存活率居高不下,gen0 预算被压小、GC 频率上升,同时对象不断被晋升到 gen1、gen2------你看到的"GC 太频繁",根因常常是一个无界限的缓存,而不是 GC 参数不合适。

大对象与 POH

大于等于 85,000 字节的数组直接进 LOH(大对象堆),和 gen2 一起回收。LOH 默认不压缩,因为搬动大块内存成本高;需要压缩时得显式设置 GCSettings.LargeObjectHeapCompactionMode,而且只对下一次 full blocking GC 生效------它是"一次性开关",不是常驻配置,用完自动复位。

固定大小的缓冲区(socket 收发的 byte\[\]、序列化缓冲)走 POH(Pinned Object Heap),用 GC.AllocateArray<byte>(length, pinned: true) 分配。POH 里的对象永远不会被搬动,所以原生代码可以长期持有指针,不需要每次 fixed。代价是 POH 同样不压缩,长期频繁分配不同大小的固定缓冲区会在 POH 里留下碎片。

Server GC 与 DATAS

Server GC 为每个逻辑核建一个堆,各自有 gen0 预算,GC 时并行回收。它的设计假设是"这台机器被我的进程独占"------在专用服务器上这个假设成立,吞吐确实最高。但在一个 32 核的节点上跑 20 个容器,每个容器都开 Server GC,就等于有 20 份按 32 核膨胀的堆预算,节点内存被迅速吃满。

DATAS(Dynamic Adaptation To Application Sizes)从 .NET 8 引入、在 .NET 9 起成为 Server GC 的默认行为,它动的正是这个假设:堆的目标大小不再锚定核数,而是锚定当前存活数据量;gen0 预算随实际分配速率伸缩;空闲时把 region 归还并 decommit 给操作系统。核数仍然决定并行度上限,但不再决定常驻内存。代价是 GC 的决策随时间波动,吞吐通常略低于固定配置的 Server GC------这是可预测性和内存占用之间的交换。

用 GCMemoryInfo 看真实状态

调优前先能观测,GC.GetGCMemoryInfo() 返回的 GCMemoryInfo 结构把 GC 内部的判断依据都暴露出来了:

csharp 复制代码
using System;

static void DumpGcState()
{
    GCMemoryInfo info = GC.GetGCMemoryInfo();

    Console.WriteLine($"serverGC={GCSettings.IsServerGC} heap={info.HeapSizeBytes >> 20}MB " +
                      $"committed={info.TotalCommittedBytes >> 20}MB " +
                      $"frag={info.FragmentedBytes >> 10}KB");
    // TotalAvailableMemoryBytes 是 GC 认为的可用上限,容器里这个值最值得盯
    Console.WriteLine($"available={info.TotalAvailableMemoryBytes >> 20}MB " +
                      $"fullGCs={GC.CollectionCount(2)} pauses={info.PauseDurations.Length}");

    var gens = info.GenerationInfo;
    for (int i = 0; i < gens.Length; i++)
    {
        Console.WriteLine($"gen{i}: size={gens[i].SizeAfterBytes >> 10}KB " +
                          $"fragAfter={gens[i].FragmentationAfterBytes >> 10}KB");
    }
}

TotalAvailableMemoryBytes 与容器 limit 对不上,就是下一节那个坑的直接信号。FragmentationAfterBytes 持续走高则说明 LOH/POH 的碎片在累积,靠调 gen0 预算是治不好的。

容易踩的坑

坑一:容器限额被忽略,GC 按宿主机内存规划

这是最典型的静默失效。GC 启动时读 cgroup 的内存限额来设定 TotalAvailableMemoryBytes,读不到就退回物理内存。旧运行时在 cgroup v1/v2 混布的环境里、或者限额是在进程启动后才被调整的场景(弹性伸缩改 limit、Kubernetes 原地调整资源)里,GC 会一直按一个偏大的上限工作:它觉得内存充裕,就不急着回收,直到容器被 OOM Killer 杀掉,而托管堆统计看起来一切正常。

对策是两条:一是显式给出上限,别让运行时去猜;二是限额变化后主动刷新。

xml 复制代码
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

<ItemGroup>
  <!-- 1 表示启用 DATAS;容器内让堆目标跟着存活数据走 -->
  <RuntimeHostConfigurationOption Include="System.GC.DynamicAdaptationMode" Value="1" />
  <!-- 硬上限,按容器 limit 的比例给,优先于运行时探测 -->
  <RuntimeHostConfigurationOption Include="System.GC.HeapHardLimitPercent" Value="75" />
</ItemGroup>

进程内改了限额之后,调用 GC.RefreshMemoryLimit() 让 GC 重新读取并调整目标,否则新限额要到下次重启才生效。注意 HeapHardLimitPercent 和 HeapHardLimit(绝对值)同时配置时以绝对值优先,两个都配是常见的配置漂移来源。

坑二:GC.Collect 之后内存没降,于是加更多 GC.Collect

.NET 7 起小对象堆改为 region 化,回收后的 region 何时 decommit 由 GC 自己决定,通常滞后于收集本身。所以"调用 GC.Collect() → 看 RSS 没变 → 认为回收失败 → 在更多地方加 GC.Collect()"是一条完整的错误链条:full blocking GC 会挂起所有线程,把 gen2 里本来还能复用的对象重新扫一遍,吞吐掉下去,内存却还是那个数------因为真正占着内存的是仍被引用的存活数据,不是"没回收的垃圾"。

判断泄漏应该看 HeapSizeBytes 与存活对象趋势,而不是 RSS。RSS 里还有 JIT 代码堆、线程栈、原生分配、以及已 free 但未归还的 region。

坑三:NoGCRegion 静默失败

GC.TryStartNoGCRegion 返回 bool,失败时只是返回 false,不抛异常。在启用 Server GC 的进程里它不会被满足,预算不够时也不会------如果调用方不检查返回值,代码会以为自己在"无 GC 关键区"里跑,实际上 GC 照常发生,而关键路径的延迟抖动一点没改善。

csharp 复制代码
long budget = 64 * 1024 * 1024;   // 关键区内允许分配的字节数,超了会强制退出
if (GC.TryStartNoGCRegion(budget))
{
    try
    {
        // 关键路径:分配量必须显著小于 budget,且不要在这里做 IO 或阻塞等待
        ProcessCriticalPath();
    }
    finally
    {
        GC.EndNoGCRegion();
    }
}
else
{
    // 必须走降级路径,不能假设已经进入无 GC 区
    ProcessCriticalPath();
}

即使成功进入,一旦区内实际分配超过预算,运行时也会强制做一次 GC 并退出该区域------保护的是"不因为 GC 停顿",不是"保证不 GC"。这决定了它只适合毫秒级、分配量可预估的片段。

边界与不适用

DATAS 不是万能档。专用服务器上的高吞吐服务 (网关、批处理、内存数据库)用固定配置的 Server GC 更合适:负载稳定、机器独占,可预测性比省内存更值钱。短生命周期进程(CLI、一次性任务)调 GC 基本没有意义,进程退出时全部归还。

NoGCRegion 不应该被当作性能优化手段铺到业务代码里。它解决的是"某段代码不能被打断",不是"某段代码太慢"。同理,POH 解决的是"原生侧要长期持有指针",不是"pin 太慢"------把普通缓冲区都丢进 POH,只会换来一块不压缩、只增不减的内存。

还有一类常见的误配:GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency 被当成"降低延迟"的开关长期开着。它抑制的是 full blocking GC,代价是 gen2 持续增长,最终仍要还这笔债,而且还得更集中------延迟的尾部会变差而不是变好。

选型建议

部署形态 GC 配置 主要理由
容器 / 多租户节点 Server GC + DATAS + 显式 HeapHardLimit 内存跟随存活数据,避免按核数膨胀
独占物理机、稳定高吞吐 Server GC,关闭 DATAS 行为恒定,吞吐上限更高
桌面 / 低延迟交互 Workstation 并发 GC 暂停时间短,堆规模小
批处理、离线计算 Server GC + 非并发 不需要后台 GC,换取更高吞吐

一个务实的原则:先让 TotalAvailableMemoryBytes 和容器 limit 对齐,再谈其他参数。绝大多数线上 GC 问题查到最后都是内存上限没配对,或者一个没有上限的缓存------这两件事都不是 GC 参数能修的。

小结

GC 的可调表面很大,内核很小:gen0 预算由存活率反馈决定,堆目标由可用内存和存活数据决定。DATAS 做的是把后者从"核数"换成"存活数据",所以它特别适合容器,也特别不适合追求恒定吞吐的独占场景。真正需要盯的指标是 TotalAvailableMemoryBytes、HeapSizeBytes 和分代碎片,而不是 RSS。

相关推荐
淡海水1 小时前
05-03-源码-Boehm GC分配与碎片管理
开发语言·算法·unity·c#·游戏引擎·gc
曹牧1 小时前
C#:字符串占位符与值数量不一致
开发语言·数据库·c#
学以智用18 小时前
.NET 10 完整详解
.net
玩泥巴的20 小时前
MAF + 飞书:让大模型干活,但别让它乱干
ai·c#·.net·飞书·agent
cpp_w1 天前
C#基础3- 面向对象中级应用3
后端·c#
步步为营DotNet1 天前
Async 状态机要搬进运行时了:.NET 10 Runtime Async 实测,快的地方快 1.5 倍,贵的地方贵 5 倍
性能优化·c#·.net·async·runtime
hixiong1231 天前
C# 使用 ZXingCpp.Angle 进行条码、二维码识别
开发语言·c#·扫码
步步为营DotNet1 天前
System.Text.Json 源生成:三个“看起来已经生效“的静默失效
c#·.net·native aot·源生成