资讯动态

03-05-架构篇-.NET Core GC架构解析

发布时间:2026/10/15 12:51:28 来源:尧图企业网站定制
.NET Core GC 架构解析篇章03-架构篇 |阅读时间约 50 分钟 |前置知识了解 Server/Workstation GC一、引言.NET 的垃圾回收器是工业界迭代时间最长的 GC 之一。从 .NET Framework 1.0 时代单一的 Workstation 模式到今天支持 Server 多堆并行、后台并发标记、LOH 手动压缩、POH 固定堆的完整体系它前后演进了近 20 年。对后端开发者来说这套体系意味着大部分性能问题只需要调几个开关对 Unity 开发者来说摸清 .NET GC 的天花板在哪才能准确判断 Mono SGen 和 Unity 增量式 GC 还差在哪一层。它和本系列前面讨论过的两个对象定位很不一样。Boehm 是保守式、不分代、不移动的回收器用最低的集成成本换来了碎片化和吞吐上限SGen 是精确分代、移动式的回收器但只有 Nursery 和 Major 两代停在 Stop-The-World 一种执行模式上也没有多核协同。.NET Core GC 把分代、移动压缩、并发标记、多堆并行全放进了一个实现里代价是架构复杂度陡增段、代、卡表、分配上下文、后台线程、分配预算每一层都要看懂才能解释清楚为什么这个服务的 GC 停顿是 20ms 而不是 2ms。本文按堆结构、运行模式、并发、配置、诊断的顺序展开。先把堆在内存里的真实形状画出来再看 Workstation 和 Server 两种模式怎么使用这个堆然后拆 SOH / LOH / POH 三个子堆与分配路径接着讲 Background GC 和触发机制最后落到配置最佳实践与线上诊断手段。读完你应该能回答三个问题对象到底落在哪里GC 什么时候被叫醒出了问题从哪里拿证据。二、堆的物理结构段与代2.1 段虚拟内存的分配单元.NET GC 不会逐个对象向操作系统要内存。它先预留一大块虚拟地址空间切成若干段Segment之后所有分配都在段内部完成。段采用两阶段管理先 Reserve 占住地址范围真正写入时才 Commit 物理页回收后先 Decommit 归还物理页、保留地址空间内存紧张时才整段 Release。运行时把回收掉的段留在缓存里复用这就是段缓存它省下了大量系统调用。Workstation 模式的段通常只有数 MBServer 模式下每个堆的段可以达到几十 MB 甚至 GB 量级具体随核数变化。同一代的对象可能分布在多个段里托管堆的一个堆Server 模式下每个逻辑处理器各有一份 Gen0/Gen1 短暂段ephemeral segment ┌────────────────────────────────────────────────┐ │ 对象A │ 对象B │ 已分配 → │ 空闲 │ └────────────────────────────────────────────────┘ Gen2 段 ┌────────────────────────────────────────────────┐ │ 长寿对象 │ 长寿对象 │ 碎片 │ 空闲 │ └────────────────────────────────────────────────┘ LOH 段 ┌────────────────────────────────────────────────┐ │ 大数组 128KB │ 空闲 │ 大字符串 96KB │ 空闲 │ └────────────────────────────────────────────────┘ POH 段.NET 5 ┌────────────────────────────────────────────────┐ │ pinned 缓冲区 1MB │ 空闲 │ └────────────────────────────────────────────────┘2.2 对象按代分布Gen0 和 Gen1 共用短暂段。新对象一律往短暂段里 bump 分配一次 Gen0 / Gen1 回收会把幸存对象压缩到段的一端腾出来的空间继续给新分配使用。Gen2 对象住在自己的段里只有 Gen2 回收才允许移动它们。LOH 与 POH 各自持有独立的段完全不参与 SOH 的代际晋升。对象的典型轨迹 new Foo() → Gen0短暂段 │ 存活过一次 Gen0 GC ▼ Gen1 ── 存活过一次 Gen1 GC ──→ Gen2长寿只在 Gen2 回收时移动 ≥ 85,000 字节的对象 ─────────→ LOH不晋升只随 Gen2 回收 pinned: true 分配 ─────────→ POH不分代地址永久固定这个设计的收益很直接绝大多数对象活不过一次 Gen0 回收GC 只需要盯着一小块短暂段不用为长寿对象可能引用了新生对象去遍历整个老年代。2.3 卡表与分配上下文分代回收要解决的第一个问题是老年代里有没有引用指向新生代。卡表Card Table把堆划成固定大小的卡每张卡对应一个记录字节当应用把引用从老对象写向新对象时写屏障write barrier顺手把对应的卡标脏。Gen0 回收只扫根集合和脏卡不必遍历整个 Gen2这就是分代 GC 的效率来源也是写屏障给每次引用赋值增加的那点开销换来的。每个托管线程还持有自己的分配上下文allocation context本质是alloc_ptr和alloc_limit两个指针圈出该线程当前独占的一段分配窗口。线程在窗口内分配对象时只碰自己的数据完全不经过全局锁。第五节会把这条路径逐行拆开。关于实现细节要留一个口子不同版本的运行时一直在调整堆的物理布局例如把按代划分的段进一步做成 region 粒度的管理单元。这类调整属于持续演进中的实现细节不构成对外契约。本文统一按段 代 短暂段的主线讲解你读源码时看到不一样的命名不必意外。三、Server GC 与 Workstation GC3.1 Workstation GC单堆单线程一个托管堆配一个 GC 线程两种执行方式可选阻塞式回收期间应用全停和并发式标记与应用同时跑内存占用小一套堆元数据、一张卡表暂停时长与堆大小正相关堆越大单次回收越慢它是默认模式适合桌面应用、低核容器以及任何内存比吞吐更贵的场景。3.2 Server GC多堆多线程默认每个逻辑处理器一个独立堆配一个专属 GC 线程GC 线程与处理器绑定affinitize避免跨核迁移造成的缓存失效吞吐更高、单次暂停更短每个堆更小各堆还能并行回收内存占用成比例上升碎片风险同步上升触发一次 GC 时各核的 GC 线程并行清理本核的堆堆与堆之间几乎没有同步点。16 核机器上标记与清扫的工作量被摊到 16 条线程理论并行度就是 16。这里给的是量级说明而不是基准测试真实加速比受串行的根扫描阶段、内存带宽、各堆大小不均的影响一般到不了 16 倍但吞吐随核数近似线性扩展确实是 Server GC 的设计目标。3.3 配置示例{ runtimeOptions: { configProperties: { System.GC.Server: true, System.GC.Concurrent: true, System.GC.HeapCount: 8 } } }同一组开关还有两种等价写法项目文件里的Servertrue/Server以及环境变量DOTNET_GCServer1。容器部署几乎都用后者因为它不改镜像。3.4 性能特征Server 模式下每个核各有一套堆元数据卡表、段结构、分代统计也按堆复制一份通常占堆总大小的 3% 到 5%。工作集 4GB 时这部分就是 120 到 200MB。Server GC 吞吐高但更吃内存这句话的账全在这里第八节的选择指南也是从这条推出来的。四、堆结构SOH / LOH / POH4.1 三种堆总览堆大小阈值分代压缩策略主要用途SOH 85,000 字节三代Gen0/1/2每次回收都压缩绝大多数日常对象LOH≥ 85,000 字节视作 Gen2不自动压缩可手动触发大数组、大字符串POH任意大小不分代不压缩地址永久固定.NET 5需要固定地址的缓冲区4.2 SOH三代与压缩SOH 承接了 99% 以上的日常分配内部再分三代各自分工明确。Gen0 只装最新分配的对象回收最频繁也最便宜Gen1 是缓冲区接住 Gen0 里活下来但还没证明自己长寿的对象避免它们过早挤进 Gen2Gen2 收留真正的长寿对象回收昂贵但频率低。每一代被回收时都会压缩把幸存对象挪到一起因此 SOH 内部基本不产生外部碎片对象布局始终紧凑。代价是对象地址会变任何缓存了托管指针的代码都必须依赖句柄或固定机制而不能长期持有裸地址。这条规则贯穿 .NET 的整个互操作设计。4.3 LOH大对象与碎片85,000 字节这个阈值来自对象对齐与段管理的成本权衡不是随手挑的整数。达到它的对象直接进 LOH跳过 Gen0 和 Gen1 的复制晋升因为每次 Gen2 回收都可能去动它们没有必要再让它们在年轻代里反复搬运。LOH 的关键规则是不自动压缩搬动一个几 MB 的大数组CPU 和内存带宽的代价都很高收益却不一定有。副作用是外部碎片大对象频繁分配释放后LOH 会留下大量尺寸不匹配的空洞后续大分配可能因为找不到连续空间而提前触发 Gen2 回收哪怕总空闲量足够。缓解手段有三种。第一是复用缓冲区而不是反复 new。第二是显式请求一次 LOH 压缩// LOH 压缩是 opt-in 的默认关闭 GCSettings.LargeObjectHeapCompactionMode GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(2, GCCollectionMode.Forced, blocking: true);第三是选对时机LOH 压缩会移动对象如果有原生代码正持有其中某个数组的指针P/Invoke 缓冲区、第三方库句柄压缩会立刻制造悬垂指针。安全时机是进程内没有外借的 LOH 指针例如请求间隙、批处理任务的阶段切换处。若这类缓冲区长期存在正确做法是改用 POH而不是反复压 LOH。还有一条容易忘的规则LOH 上的对象只有 Gen2 回收时才会被处理这点和 SOH 的 Gen2 一致。所以 LOH 膨胀会直接拖长 Gen2 回收的间隔与单次成本。4.4 POH固定对象堆POH 是 .NET 5 引入的第三类堆专门存放地址必须稳定的大块内存。它的规则简单不分代、不压缩、不移动分配后地址永久有效回收只在 Gen2 回收时发生。POH 解决的是 pin 的老问题。传统做法里把一个托管数组交给原生代码必须用 GCHandle 把它钉住否则下一次压缩回收就会把它搬走。钉住的对象会拖慢它所在堆的压缩因为压缩算法要绕开它而且句柄本身需要手动释放。POH 把这类对象单独隔离出来它们本来就不参与压缩自然不需要钉也不会占用压缩算法的注意力。代价同样要说清楚。POH 的对象只能整块等待 Gen2 回收没有部分回收这回事所以它适合生命周期本身就长的缓冲区把大量短命对象往 POH 上塞等于把它们全变成要等 Gen2 才能释放的垃圾。POH 不是万能的分配池它是固定地址这个需求的专用解。五、分配路径5.1 快速路径bump pointer 分配new 一个对象时JIT 生成的代码进入分配入口第一步只做一次边界检查线程的分配上下文 [ 已分配区 | alloc_ptr → 空闲窗口 … alloc_limit ] 快速路径 ptr ctx.alloc_ptr if ptr size ctx.alloc_limit: // 边界检查 ctx.alloc_ptr ptr size // 指针前移 初始化对象头方法表、同步块索引 return ptr else: 进入慢速路径核心就是加法、比较、一次写回指令数在个位数。没有空闲链表遍历没有按尺寸分桶查找没有锁。因为窗口由线程独占连原子操作都不需要。这是 .NET 分配吞吐高的直接原因。5.2 慢速路径新窗口、新段、触发回收上下文用尽时进入慢速路径按顺序尝试四件事向当前段申请一个新的分配窗口段归堆所有这一步可能需要和 GC 协调当前段不够向运行时申请一个新段新段也拿不到说明分配预算耗尽触发 GC 后重试GC 回收后仍然放不下说明堆确实现货不足只能抛OutOfMemoryException慢路径是分配开销的主要来源也是 GC 被实际叫醒的地方。健康运行的服务里慢路径命中率应该极低如果它频繁出现要么分配速率失控要么堆的预算配置不合理。5.3 大对象路径请求达到 85,000 字节时直接走 LOH 分配器绕开分配上下文先在 LOH 的空闲区里找一块足够大的连续空间找不到就触发 Gen2 回收。POH 的路径类似只是目标堆换成了 POH。这条路径没有 bump pointer 的运气成分每次都要真正搜索空间所以大对象分配本身就比小对象贵一个量级。频繁分配大对象的服务问题往往出在这条路径上而不是 GC 本身。5.4 为什么 .NET 的分配吞吐这么高对照本系列 03-02 讲过的 Boehm保守式回收器大多用空闲链表管理内存分配时要按尺寸找合适的块容易受碎片影响还得周期性整理。SGen 用 TLAB线程本地分配缓冲解决并发分配思路上和 .NET 的分配上下文属于同一类设计都是把堆切一块给线程独占让绝大多数分配退化成指针加法。.NET 的差别在于整条路径的完成度分代让慢路径很少被触发段缓存让向操作系统要内存的成本极低Server 模式下每个核还有独立的堆连跨线程的窗口竞争都省了。几个因素叠加才有了分配几乎免费的观感。任何一条腿短了这个观感都会消失。六、Background GC后台并发回收6.1 阻塞式回收的问题最朴素的 GC 流程是全程 Stop-The-World挂起所有托管线程标记、清扫、压缩一次做完再恢复。堆越大、存活对象越多暂停越长。对 Web 服务来说一次 5 到 50ms 的阻塞式 Gen2 回收典型范围足以让一批请求的尾延迟明显抖动。Background GC 就是为压掉这部分停顿而生的。6.2 四个阶段后台回收把最耗时的标记阶段挪到后台线程和应用同时运行时间轴 ──────────────────────────────────────────────────► 应用线程 正常运行 ▥停█ 正常运行并发标记期间 ▥停█ 正常运行… │ GC 线程 ├─ 1. 初始标记STW极短 ├─ 2. 并发标记与应用并行遍历对象图 ├─ 3. 重标记STW处理并发期的引用变更 └─ 4. 并发清扫与应用并行归还空闲空间初始标记STW暂停所有线程只扫描根集合和卡表脏项确定一批必然存活的起点。这一步必须独占视图否则根会在扫描过程中被改掉。并发标记GC 线程从起点出发遍历对象图应用照常运行。期间应用修改引用由写屏障记录下来留给下一步处理。重标记STW再次暂停消化并发期间积累的引用变更把标记补完整。这一步同样很短。并发清扫把未标记的内存归还或整理应用继续运行。两次 STW 的耗时都与根集合规模相关而不是与堆大小相关所以堆涨上去之后单次停顿基本不动。这是后台回收最值钱的性质。6.3 与 Gen0/Gen1 短回收的配合后台回收只负责 Gen2。Gen0 和 Gen1 回收依然是短阻塞式因为它们要处理的存活对象少、区间小暂停本来就在亚毫秒到几毫秒量级。并发标记跑到一半时 Gen0 预算用完运行时会插入一次前台的 Gen0 或 Gen1 回收处理紧急的分配需求后立刻把线程还给并发标记。两条流水线并行不悖这正是这套设计的精巧之处。一个必须记住的约束分配速率过快时并发标记可能来不及跑完Gen2 空间先耗尽了此时只能退化成一次完整的阻塞式 Gen2 回收暂停反而比平常更长。监控里看到本该后台跑的回收变成了 blocking通常意味着分配速率或堆规模需要介入。6.4 延迟量级与开关典型范围阻塞式 Gen2 回收 5 到 50msBackground GC 下真正的 STW 切片通常在 1ms 以下。这两个数字是量级参考而不是承诺实际取决于堆大小、根集合规模和硬件。后台回收默认开启也可以显式关闭{ runtimeOptions: { configProperties: { System.GC.Concurrent: false } } }关掉之后回到全程阻塞模式总 CPU 可能略降省掉写屏障和并发协调的开销代价是暂停回归 5 到 50ms 量级。批处理任务可以这么换交互式服务别动。七、GC 触发机制7.1 预算触发GC 不是被定时器叫醒的是被分配预算花光叫醒的。运行时给每个代记账Gen0 有一份分配预算初始值随模式与核数变化每次分配往上累加用尽就触发一次 Gen0 回收Gen1 的预算在 Gen0 回收后重新结算Gen2 的预算跨度最大通常由存活集大小和压缩收益共同决定。这种按分配量计费的设计让 GC 频率自动正比于分配速率分配少就回收少分配猛就回收猛不需要人工调周期。预算还会根据历史存活率动态修正回收后存活比例高就调低预算让下次回收来得更早。7.2 分配失败与代际递进慢路径拿不到内存是另一类触发器而且是逐级升格的先试 Gen0 回收不够再 Gen1再不够 Gen2最后 Full GC。每升一级单次暂停时间和能回收的量都上一个台阶。线上如果频繁看到高代回收通常意味着长寿对象在增长或者分配速率已经超出了预算触发的处理能力。7.3 显式触发GC.Collect 的正确用法GC.Collect(); // 等价于 Collect(2, Forced, blocking: true) GC.Collect(0, GCCollectionMode.Forced); // 只回收 Gen0 GC.Collect(2, GCCollectionMode.Optimized); // 交给运行时判断值不值得 GC.Collect(2, GCCollectionMode.Forced, blocking: false); // 非阻塞允许后台完成第一个参数是代掩码决定回收到哪一代blocking决定是否停到底设为 false 时运行时可以把它拆成后台执行。生产代码里手写GC.Collect往往是个信号说明分配模式有问题。少数合理场景性能基准测试的起点对齐、测试用例需要确定性、批处理任务在阶段切换时主动归还内存。7.4 内存压力通知宿主环境和操作系统会推送内存压力事件比如容器接近限额、系统整体吃紧。运行时收到后会收紧回收预算、提前触发高代回收尽量避免进程被 OOM 杀掉。托管代码也能主动感知监听内存使用率回调在压力升高时自己先降级丢弃大缓冲区、清掉缓存把命运掌握在自己手里而不是等内核出手。7.5 LatencyMode四种语义GCSettings.LatencyMode GCLatencyMode.Interactive; // 默认值模式行为适用场景Interactive默认后台回收开启Gen2 回收被拆成短暂停Web API、桌面交互应用Batch关闭后台回收追求总吞吐不关心单次暂停批处理、离线计算、基准测试LowLatency压制 Gen2 回收尽量只跑 Gen0/Gen1有明确时间边界的延迟敏感窗口SustainedLowLatency长期压制 Gen2 回收可常驻持续低延迟要求的服务LowLatency 和 SustainedLowLatency 本质是借未来的债Gen2 回收被推迟堆只涨不缩用久了最终还是要还一次更大的回收。它们适合接下来 30 秒不能卡这种有边界的场景不适合常开。判断标准很简单如果压了两分钟堆还在涨而你开始担心内存就该切回 Interactive 让 GC 正常工作。八、配置最佳实践8.1 配置键总览{ runtimeOptions: { configProperties: { System.GC.Server: true, System.GC.Concurrent: true, System.GC.HeapCount: 8, System.GC.NoAffinitize: false, System.GC.RetainVM: false } } }配置键作用System.GC.Server开关 Server GC与项目文件的Server和环境变量DOTNET_GCServer等价System.GC.Concurrent开关 Background GC默认为 trueSystem.GC.HeapCount显式指定堆数量默认按逻辑处理器数推导System.GC.NoAffinitize关闭 GC 线程与处理器的绑定改由操作系统调度System.GC.RetainVM控制回收后的段是否把虚拟地址空间也归还操作系统此处列出作为可选项配置改完必须验证第九节的两行代码就是干这个的。见过太多案例调了半天 Workstation 参数最后发现进程是 Server GC 起的所有调整都没落在生效的配置上。8.2 容器化部署容器里最常见的坑是 GC 看到的 CPU 数与容器配额对不上。Kubernetes 的 pod 限了 2 核运行时却按宿主机 64 核初始化了 64 个堆既浪费内存又互相抢内存带宽。做法有两条显式设置System.GC.HeapCount数值与容器的 CPU limit 对齐limit 是 2 就设 2不要沿用默认值。限核较小时同时考虑System.GC.NoAffinitize。容器内的处理器本来就可能被宿主机调走硬绑定反而制造停顿。System.GC.RetainVM在容器里通常保持默认除非进程内存曲线频繁大幅波动才需要放开它让虚拟地址空间的归还更积极。8.3 什么时候不要用 Server GC场景推荐原因ASP.NET Web API多核Server GC多堆并行吞吐与暂停双赢WPF / WinForms 桌面应用Workstation GC单机内存有限多堆元数据是纯浪费微服务容器1 到 2 核Workstation GC堆数太少并行收益覆盖不了内存开销批处理、离线计算Server GC Batch 模式极致吞吐不关心单次暂停交易、音视频管线Workstation GC SustainedLowLatency内存可控暂停可压制判断标准压缩成一句话核数少、内存紧、对单次暂停敏感就用 Workstation核多、吞吐优先、内存有富余才上 Server。1 到 2 核的容器强行开 Server GC是实践中最典型的配置错误之一。九、诊断与可观测性9.1 GC 事件ETW 与 LTTngGC 的每个阶段都会发事件。Windows 上走 ETWEvent Tracing for WindowsLinux 上走 LTTng事件名两边保持一致因此工具链可以跨平台复用GCStart / GCEnd一次回收的起止带代数和是否 blocking两条配对就是单次暂停时长GCHeapStats回收前后的各代存活量与分配量用来判断晋升速率GCPerHeapHistoryServer 模式下每个堆的回收明细用来定位哪个核的堆特别大GCGenerationRange各代地址范围的变化观察段的扩张与收缩拿到这些事件之后暂停长就不再是一个感觉而是一条可以精确到毫秒的时间线。9.2 工具链工具用途PerfViewWindows 上最全的 ETW 分析器GC 日志、分配速率、时间线一把抓dotnet-trace跨平台采样与 GC 事件采集线上排障首选dotnet-counters实时计数器盯 GC 堆大小与回收次数的斜率dotnet-gcdump抓托管堆快照对比两次抓取定位泄漏对象dotnet-counters的典型用法是一条命令挂上去看趋势dotnet-counters monitor --process-id pid System.Runtime关注GC Heap Size和GC Count两条曲线前者单调上涨说明有泄漏或者预算失衡后者斜率突变说明分配速率变了。排查节奏建议按趋势、事件、对象推进先用 dotnet-counters 看趋势再用 dotnet-trace 抓事件确认是哪一代、是不是 blocking最后才上 gcdump 找具体对象。反过来做成本会高一个量级。9.3 自省 API不借助外部工具也能拿到关键信息var info GC.GetGCMemoryInfo(); long heapSize info.HeapSizeBytes; // 当前堆大小 long committed info.TotalCommittedBytes; // 已提交内存 long fragmented info.FragmentedBytes; // 碎片量LOH 问题的直接证据 int gen0 GC.CollectionCount(0); // 各代回收次数 int gen2 GC.CollectionCount(2);GC.GetGCMemoryInfo()在高代回收之后数据最准适合打进周期性日志。CollectionCount要看差值而不是绝对值两个时间点相减就能算出区间内的回收频率。9.4 运行时确认当前模式配置到底有没有生效运行时一查便知Console.WriteLine($Server GC : {GCSettings.IsServerGC}); Console.WriteLine($LatencyMode: {GCSettings.LatencyMode});这两行应该出现在服务的启动日志里。它排掉的是一整类误判模式判断错、配置键拼错、环境变量没注入容器全都会在这里现形。十、POH 实战10.1 固定缓冲区的两种做法// 方式一.NET 5直接分配在 POH byte[] pinnedBuffer GC.AllocateArraybyte(1024 * 1024, pinned: true); // 地址永久固定压缩回收永远绕开它不需要任何句柄 // 原生代码可以长期安全地持有它的指针 // 方式二传统普通堆 手动钉住 byte[] buffer new byte[1024 * 1024]; GCHandle handle GCHandle.Alloc(buffer, GCHandleType.Pinned); try { IntPtr ptr Marshal.AddrOfPinnedObject(buffer); // 交给原生代码使用… } finally { handle.Free(); // 漏掉这一行就是资源泄漏 }差别不只是代码行数。方式二里被钉住的对象会让它所在堆的压缩变慢因为算法必须绕开它GCHandle 本身还是需要释放的资源。方式一把这类对象彻底隔离到 POHSOH 的压缩不再受干扰忘了 Free这个错误类别也从根上消失。字符串同理GC.AllocateString可以直接分配到 POH。10.2 GCHandle 泄漏这一类 bug传统 pin 模式下的典型事故有三种正常路径写了Free()而异常路径忘了两个方法各自Alloc同一个缓冲区却只Free一次以及循环里反复Alloc但只在循环外释放。后果不只是内存泄漏被钉住的对象会持续拖累后续所有压缩回收线上表现往往是堆不算大GC 却特别慢查起来非常隐蔽。POH 把这类问题消解掉了因为根本没有句柄需要管理。除了互操作缓冲区POH 还适合另一类场景长期复用的网络收发缓冲、序列化中间结果。它们的生命周期本来就是整个请求乃至整个进程交给 Gen2 回收完全合理还能省掉反复 pin 和 unpin 的开销。十一、与 Mono SGen / Boehm 的架构对比维度BoehmMono SGen.NET Core GC分代无2 代Nursery Major3 代Gen0/1/2是否移动否保守标记不搬对象是Nursery 复制Major 可压缩是SOH 全压缩LOH 默认不压并发能力无有限并发Background GC并发标记 并发清扫Server 模式无无有每核一堆 专属 GC 线程大对象处理无独立堆同样走空闲链表≥8KB 直接进 Major独立 LOH≥85,000 字节固定对象本来就不移动GCHandle 钉住POH 专用堆.NET 5线程模型STW safepointSTW safepointSTW 切片 后台线程并发GC 接口嵌入点Mono 的 GC 接口层实现可热插拔同左与 Boehm 实现同一组接口CLR 的 EE 层 GCHeap 接口配置方式无MONO_GC_PARAMS 环境变量runtimeconfig / 项目文件 / 环境变量三者的差异不是功能清单的长短而是设计目标不同Boehm 追求零依赖的嵌入成本SGen 追求在 Unity 这种受限环境里给出可预期的分代回收.NET Core GC 追求在多核服务器上同时拿到吞吐和低延迟。理解这条光谱也就理解了 Unity 为什么做增量式 GC以及为什么把 .NET 的后台标记思路搬进游戏运行时会成为方向。总结.NET Core GC 的架构可以用三句话概括。堆被切成段和代短命对象在短暂段里快速 bump 分配长寿对象和大对象各有专属区域模式上 Workstation 求省内存、Server 求并行吞吐差别只在几个配置键并发上 Background GC 把最贵的 Gen2 标记拆到后台只留下与根集合成正比的两次短暂停。落到工程实践最重要的三个结论是第一先确认自己跑在什么模式上IsServerGC和LatencyMode两行代码就能排掉一半的误判第二配置要和部署环境对齐容器里HeapCount不匹配 CPU limit 是最常见的隐性浪费第三诊断要按趋势、事件、对象的顺序推进dotnet-counters 起步dotnet-trace 定位gcdump 收尾。把这条线对照回本系列前面的 Mono 章节会看到一条清晰的演进路径从 Boehm 的保守不分代到 SGen 的两代 STW再到 .NET 的三堆、并发与多核并行。GC 架构的历史本质就是在吞吐、暂停、内存三者之间不断重新切分代价的历史而 .NET Core GC 目前把这道题答得最完整。代码示例运行时 GC 状态 dumpusing System; using System.Runtime; class GCStatusDump { static void Main() { Console.WriteLine($IsServerGC : {GCSettings.IsServerGC}); Console.WriteLine($LatencyMode : {GCSettings.LatencyMode}); Console.WriteLine($LOH compaction: {GCSettings.LargeObjectHeapCompactionMode}); for (int gen 0; gen 2; gen) Console.WriteLine($Gen{gen} collections: {GC.CollectionCount(gen)}); var info GC.GetGCMemoryInfo(); Console.WriteLine($Heap size : {info.HeapSizeBytes / (1024.0 * 1024):F1} MB); Console.WriteLine($Committed : {info.TotalCommittedBytes / (1024.0 * 1024):F1} MB); Console.WriteLine($Fragmented : {info.FragmentedBytes / (1024.0 * 1024):F1} MB); Console.WriteLine($Available : {info.TotalAvailableMemoryBytes / (1024.0 * 1024):F1} MB); } }跑一次就能确认当前是 Server 还是 Workstation、LatencyMode 落在哪一档、各代回收了几次、堆上堆了多少碎片。上线前后各打一次配置是否生效、内存是否恶化一眼就能对上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑