让 .NET GC 行为可观察:我在构建 GCExperiment 时的收获

发布: (2026年3月7日 GMT+8 17:57)
5 分钟阅读
原文: Dev.to

Source: Dev.to

为什么进行实验?

文档告诉你 GC 做了什么。运行代码则告诉你 何时 以及 为什么。在尝试推理真实系统中的分配压力时,这两者的区别非常重要。

该项目名为 GCExperiment —— 四个相互独立的实验,每个实验针对一种 GC 机制,全部配备真实的快照进行仪表化。

基于 .NET 10 / C# 14,仅支持 x64。x86 上的头部大小和对齐方式不同,因此需要 64 位才能获得真实的 LOH 行为。

实验 1 — LOH 放置

我学到的最令人惊讶的事情是:这并不是一个简单的 85,000 字节阈值。
GC 测量的是 完整对象成本——包括头部、有效负载以及对齐。下面是 byte[84_999] 的实际计算过程:

// Full size calculation
24   // array header
+ 84_999 // payload
= 85_023 → aligned to 85_024 → LOH threshold hit

因此 byte[84_999] 会进入大对象堆(Large Object Heap)——这不是因为负载超过了 85 KB,而是因为对齐后的完整对象超过了阈值。

实验结果显示

  • 不同大小的数组实际落在哪个堆(SOH 与 LOH)
  • 如何使用 GCInfo.EstimateSizeByFactory 测量完整对象大小
  • 如何在需要时强制进行 LOH 紧缩:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, blocking: true);

实验 2 — 代提升

对象在 Gen0 中并不会永远存活。存活下来的对象会被提升——本实验实时展示了这一过程。

代数含义
0最近分配的,短暂的
1已经经历过一次 Gen0 垃圾回收仍然存活
2长期存活的,或位于 LOH(LOH 对象会立即从 Gen2 开始)

有一点我没预料到:GC.KeepAlive 在观察提升时真的会改变结果。如果不使用它,JIT 可能会优化掉你的引用,导致对象在你检查其代数之前就被回收。

实验 3 — 分配压力

并排比较两种模式:

  • 短命小对象 — 分配、丢弃、重复 → 强化 Gen0
  • 长命大对象 — 产生 Gen2/LOH 压力

输出在实验运行时打印实时的垃圾回收计数。更改样本大小并观察 Gen0 与 Gen2 计数的差异,有助于建立直观认识。

实验 4 — LOH 碎片化

如果交替进行大对象分配——分配 A,分配 B,释放 A,尝试分配 C——A 留下的空洞可能容不下 C。默认情况下 LOH 不会进行压缩,因此这些空洞会累积。

[  A  ][ B ][ hole ][ B ][ hole ]

         C doesn't fit here

这就是 LOH 压缩和缓冲区池(ArrayPool)存在的原因。该实验通过分配失败和 GC 快照比较,使碎片化可见。

诊断与辅助工具

该仓库包含两个仪器辅助工具:

GCMonitor(位于 GCMastery.Diagnostics

GCMonitor.PrintSnapshot("after allocation");
GCMonitor.PrintObjectGeneration(myObj, nameof(myObj));
GCMonitor.Reset("clean baseline");

GCInfo(位于 GCExperiment

var state = GCInfo.GetCurrentState(forceFullCollectionFirst: true);
var gen   = GCInfo.GetGeneration(myObj);
var size  = GCInfo.EstimateSizeByFactory(() => new byte[84_999], sampleCount: 1000);

运行所有

GenerationExperiment.Run();
LOHExperiment.Run();
FragmentationExperiment.Run();
PressureExperiment.Run();

在测量任何东西之前的一个提示 — 始终从干净的状态开始

GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);

否则,之前运行留下的状态会污染你的结果。

我仍在探索的内容

  • Server GC 与 Workstation GC 行为差异
  • GCSettings.LatencyMode 如何影响晋升时机
  • 更好的可视化碎片随时间变化的方法(目前仅限控制台)

如果你在这些方面有经验并发现了问题——或者有值得加入的实验想法——欢迎在评论中提供反馈。

仓库

👉

需要 .NET 10 · C# 14 · x64

0 浏览
Back to Blog

相关文章

阅读更多 »

Unreal Engine 中的移动语义

拷贝问题 在传统的 C++98 编程中,对象的创建有两种方式:从零开始和通过拷贝。 cpp Foo; // 默认构造函数 Fooin...

C# 与 F# 的关键区别是什么?

介绍 通常,我们的 .NET 客户会问这个问题:我们应该使用 C 还是 F?这两种语言都运行在相同的 .NET runtime 上,并共享对相同 libraries 的访问……