增量式 GC 与并发 GC
篇章 :02-原理篇
阅读时间 :约 40 分钟
前置知识:了解 STW 暂停
一、引言
在游戏和实时交互应用中,GC暂停(Stop-The-World, STW)是最令人头疼的性能问题之一。一次Full GC可能暂停几十甚至上百毫秒,导致明显的卡顿。为了减少暂停时间,GC设计者们发展出了三种核心策略:增量式GC (Incremental GC)、并发式GC (Concurrent GC)和并行式GC(Parallel GC)。
这三种策略的目标相同------减少STW暂停时间------但实现方式截然不同。理解它们的原理、权衡和适用场景,是优化GC性能的关键。Unity的Boehm GC、Mono的SGen GC、.NET的GC各自采用了不同的策略组合,这也直接决定了它们在不同场景下的表现差异。
二、Stop-The-World 的问题
2.1 STW的必要性
传统的标记-清扫GC在工作时需要暂停所有应用线程,这是因为:
-
一致性:GC需要看到堆的一致快照。如果应用线程在GC标记过程中修改了对象图,标记结果就会不正确。
-
安全性:GC移动对象(压缩阶段)时,应用线程不能访问正在移动的对象,否则会读到无效地址。
-
简单性:STW使得GC的实现大大简化,不需要处理并发访问的复杂情况。
应用线程: ████░░░░░░░░░░████████████
GC线程: ████
↑
STW暂停
2.2 STW的代价
// STW对游戏的影响
// 假设60FPS游戏,每帧16.67ms
// 一次Full GC暂停50ms → 丢失3帧 → 明显卡顿
void Update() {
// 每帧分配一些临时对象
var temp = new List<int>(100);
for (int i = 0; i < 100; i++) {
temp.Add(i);
}
// temp在下一次GC时被回收
// 如果GC在帧中间触发 → STW暂停 → 帧时间超标
}
2.3 STW暂停的组成
STW暂停不是单一的时间块,而是由多个阶段组成:
┌─────────────────────────────────────────────────────────┐
│ GC暂停时间 │
├──────────┬──────────┬──────────┬──────────┬─────────────┤
│ 初始标记 │ 根扫描 │ 标记 │ 清扫 │ 压缩 │
│ (STW) │ (STW) │ (STW) │ (STW) │ (STW) │
└──────────┴──────────┴──────────┴──────────┴─────────────┘
每个阶段都贡献了暂停时间。增量式和并发GC的核心思想就是将其中某些阶段从STW中移出,让它们与应用线程并发执行。
三、增量式 GC(Incremental GC)
3.1 时间分片
增量式GC的核心思想是将一次长的GC操作拆分成多次短的GC操作,每次只执行一小部分工作,然后让应用线程运行一段时间,如此交替进行。
传统GC:
应用: ████░░░░░░░░░░░░████████
GC: ████████████
└── 一次长暂停 ──┘
增量式GC:
应用: ████░░██░░██░░██░░████████
GC: ██ ██ ██ ██
└── 多次短暂停 ──┘
// 增量式GC的概念模型
class IncrementalGC {
private int _totalWork;
private int _completedWork;
private int _workPerStep;
// 每次只做一小部分工作
public void DoGCStep() {
int work = Math.Min(_workPerStep, _totalWork - _completedWork);
// 执行一小部分标记/清扫工作
for (int i = 0; i < work; i++) {
MarkOneObject();
_completedWork++;
}
if (_completedWork >= _totalWork) {
FinishGC();
}
}
// 应用线程和GC交替执行
public void RunLoop() {
while (true) {
// 应用运行一段时间
RunApplication();
// GC做一小步
DoGCStep();
}
}
}
3.2 写屏障
增量式GC面临一个核心问题:在GC暂停期间,应用线程修改了对象引用关系,导致GC的标记结果可能不正确。
问题场景:
1. GC标记对象A为"已标记"
2. GC暂停,应用线程运行
3. 应用线程将A.field从B改为C
4. B现在不可达,但GC已经标记过A了
5. GC恢复后不会重新检查A → B被遗漏 → 错误回收!
为了解决这个问题,增量式GC引入了写屏障(Write Barrier)。写屏障是一段在每次引用写入时执行的代码,记录被修改的引用,确保GC不会遗漏。
// 写屏障的概念模型
class WriteBarrier {
// 记录被修改的对象
private HashSet<object> _modifiedObjects = new();
// 每次引用写入时调用
public void WriteBarrierWrite(object obj, string field, object value) {
// 记录被修改的对象
_modifiedObjects.Add(obj);
// 执行实际写入
SetField(obj, field, value);
}
}
// 在C#中,写屏障由运行时自动插入
// 开发者不需要手动实现
// 但理解写屏障的开销很重要:
// - 每次引用写入都有额外开销
// - 可能影响缓存性能
// - 增加了内存开销(记录表)
3.3 写屏障的类型
┌─────────────────────────────────────────────────────────┐
│ 写屏障类型 │
├─────────────────────┬───────────────────────────────────┤
│ 精确写屏障 │ 记录被写入的具体字段位置 │
│ (Precise) │ 开销大但精确 │
├─────────────────────┼───────────────────────────────────┤
│ 对象写屏障 │ 记录被修改的对象 │
│ (Object) │ 开销中等,需要重新扫描对象 │
├─────────────────────┼───────────────────────────────────┤
│ 卡表写屏障 │ 将堆分成卡片,记录被修改的卡片 │
│ (Card Table) │ 开销小,最常用 │
└─────────────────────┴───────────────────────────────────┘
3.4 卡表(Card Table)详解
卡表是.NET GC实际使用的写屏障实现方式。它将堆划分为固定大小的"卡片"(通常512字节或1KB),用一个字节数组记录哪些卡片被修改过。
堆内存:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Card0 │ Card1 │ Card2 │ Card3 │ Card4 │ Card5 │ Card6 │ Card7 │
│ 512B │ 512B │ 512B │ 512B │ 512B │ 512B │ 512B │ 512B │
└───┬──┴───┬──┴──────┴───┬──┴──────┴───┬──┴──────┴──────┴──────┘
│ │ │ │
↓ ↓ ↓ ↓
卡表:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │
└────┴────┴────┴────┴────┴────┴────┴────┘
↑ ↑ ↑
被修改 被修改 被修改
// 卡表的工作流程
// 1. 应用线程写入引用时:
// - 计算写入地址对应的卡片索引
// - 将卡表对应位置标记为"脏"(dirty)
// - 执行实际写入
// 2. GC恢复时:
// - 扫描卡表中所有"脏"的卡片
// - 重新标记这些卡片中的对象
// - 清除卡表标记
// 伪代码
void WriteBarrier(ref object field, object value) {
// 计算卡片索引
long cardIndex = (long)&field / CARD_SIZE;
// 标记卡片为脏
cardTable[cardIndex] = 1;
// 执行写入
field = value;
}
void RescanDirtyCards() {
for (int i = 0; i < cardTable.Length; i++) {
if (cardTable[i] == 1) {
// 重新扫描这个卡片中的所有对象
ScanCard(i);
cardTable[i] = 0; // 清除标记
}
}
}
3.5 安全点
增量式GC需要在合适的时机暂停应用线程。不是任意时刻都可以暂停------必须在**安全点(Safepoint)**处暂停。安全点是指应用线程处于可以安全暂停的状态,此时所有引用都在已知位置。
// 安全点的概念
//
// 不是安全点:
// - 正在计算对象引用的中间状态
// - 正在解引用指针
// - 正在执行JNI调用
//
// 是安全点:
// - 方法入口/出口
// - 循环回边
// - 方法调用前
// - 分配新对象前
// JIT编译器在代码中插入安全点检查
// 伪代码:
void Method() {
while (condition) {
// 循环回边 → 安全点
CheckSafepoint();
DoWork();
}
}
void CheckSafepoint() {
if (gcRequested) {
// 暂停自己,等待GC完成
WaitForGC();
}
}
3.6 增量式GC的优缺点
| 优点 | 缺点 |
|---|---|
| 暂停时间短且可预测 | 总GC时间更长(写屏障开销) |
| 适合实时应用 | 实现复杂 |
| 帧率更稳定 | 需要额外的内存记录修改 |
| 可控制每次增量的大小 | 可能需要多次暂停才能完成一次GC |
四、并发式 GC(Concurrent GC)
4.1 并发标记
并发式GC更进一步------让GC的大部分工作与应用线程同时运行,而不是交替运行。只有少数关键阶段需要STW。
并发GC的时间线:
应用线程: ████████████████████████████████
GC线程: ████░░░░░░░░░░░░░░░███
↑ ↑ ↑ ↑
│ │ │ │
初始标记 并发标记 重新标记 并发清扫
(STW) (并发) (STW) (并发)
// 并发GC的阶段
class ConcurrentGC {
// 阶段1: 初始标记 (STW, 很短)
// - 标记所有GC根直接引用的对象
// - 暂停时间很短,因为只扫描根
public void InitialMark() {
StopTheWorld();
foreach (var root in GCRoots) {
MarkObject(root);
}
ResumeApplication();
}
// 阶段2: 并发标记 (与应用并发运行)
// - 从初始标记的对象开始,遍历整个对象图
// - 应用线程可以继续运行
// - 使用写屏障记录修改
public void ConcurrentMark() {
while (!markQueue.IsEmpty) {
var obj = markQueue.Dequeue();
foreach (var child in GetReferences(obj)) {
if (!IsMarked(child)) {
MarkObject(child);
markQueue.Enqueue(child);
}
}
}
}
// 阶段3: 重新标记 (STW, 很短)
// - 处理并发标记期间应用线程修改的引用
// - 扫描写屏障记录的修改
// - 暂停时间短,因为只处理增量
public void Remark() {
StopTheWorld();
foreach (var modified in GetModifiedObjects()) {
ReScan(modified);
}
ResumeApplication();
}
// 阶段4: 并发清扫 (与应用并发运行)
// - 清扫未标记的对象
// - 回收内存
// - 应用线程可以继续运行
public void ConcurrentSweep() {
foreach (var block in heap) {
if (!IsMarked(block)) {
Free(block);
}
}
}
}
4.2 并发标记的挑战
并发标记期间,应用线程在修改对象图,这带来了几个挑战:
// 挑战1: 三色标记的不变性
//
// 三色标记法:
// - 白色: 未访问
// - 灰色: 已访问,但子节点未全部访问
// - 黑色: 已访问,子节点全部访问
//
// 不变性: 黑色对象不能指向白色对象
// (如果黑色→白色,白色会被遗漏)
//
// 并发标记时,应用线程可能:
// 1. 黑色对象 → 新引用 → 白色对象 → 违反不变性!
// 2. 解决: 写屏障在黑色→白色时,将白色变灰
// 挑战2: 浮动垃圾
// 并发标记期间产生的垃圾不会被本次GC回收
// 因为它们可能已经被标记了
// → 需要等下一次GC
// 挑战3: 标记速度 vs 分配速度
// 如果应用分配速度 > GC标记速度
// → GC永远追不上 → 内存持续增长
// → 需要退化为STW
class ConcurrentMarkChallenge {
// 应用线程修改引用
void ApplicationCode() {
var a = new object(); // 白色
blackObject.field = a; // 黑色→白色
// 写屏障: 将a变灰
// 确保a会被标记
WriteBarrier(blackObject, a);
}
}
4.3 并发清扫
清扫阶段也可以并发执行。由于清扫不移动对象,应用线程可以安全地继续运行:
// 并发清扫的优势:
// 1. 清扫不需要暂停应用线程
// 2. 清扫只是遍历堆并释放未标记的对象
// 3. 应用线程可以继续分配新对象
//
// 但需要注意:
// 1. 清扫期间不能压缩堆
// 2. 可能产生碎片
// 3. 新分配的对象需要特殊处理(不能被误清扫)
// .NET的并发清扫
// .NET Server GC使用后台GC (Background GC)
// - Gen0和Gen1仍然STW
// - Gen2的标记和清扫可以并发
// - Gen2的压缩仍然需要STW
4.4 并发GC的暂停时间分析
并发GC暂停时间组成:
┌──────────┬──────────────────────────────────┬──────────┐
│ 初始标记 │ 并发标记 + 重新标记 │ 并发清扫 │
│ (STW) │ (并发, 仅重新标记STW) │ (并发) │
│ ~1-5ms │ 重新标记: ~1-5ms │ ~0ms │
└──────────┴──────────────────────────────────┴──────────┘
总STW暂停: 初始标记 + 重新标记 ≈ 2-10ms
(相比传统Full GC的50-200ms大幅减少)
五、并行式GC(Parallel GC)
5.1 并行 vs 并发
并行GC和并发GC容易混淆,但它们是不同的概念:
| 特性 | 并发GC (Concurrent) | 并行GC (Parallel) |
|---|---|---|
| 与应用的关系 | GC与应用同时运行 | GC暂停应用,但用多线程GC |
| 目标 | 减少暂停次数 | 减少暂停持续时间 |
| 线程模型 | GC线程 + 应用线程 | 多个GC线程,应用暂停 |
| 复杂度 | 高(需要写屏障) | 中(多线程协调) |
| 适用场景 | 低延迟应用 | 高吞吐应用 |
5.2 并行标记
// 并行标记: 多个GC线程同时标记
class ParallelMark {
private ConcurrentQueue<object> _markQueue = new();
private int _activeWorkers;
public void ParallelMarkPhase() {
// 多个GC线程同时工作
int numThreads = Environment.ProcessorCount;
var tasks = new Task[numThreads];
for (int i = 0; i < numThreads; i++) {
tasks[i] = Task.Run(() => MarkWorker());
}
Task.WaitAll(tasks);
}
private void MarkWorker() {
while (_markQueue.TryDequeue(out var obj)) {
if (!IsMarked(obj)) {
MarkObject(obj);
// 将子节点加入队列
foreach (var child in GetReferences(obj)) {
_markQueue.Enqueue(child);
}
}
}
}
}
5.3 并行清扫与压缩
// 并行清扫: 将堆分成多个区域,每个线程负责一个区域
class ParallelSweep {
public void ParallelSweepPhase() {
var regions = DivideHeapIntoRegions(Environment.ProcessorCount);
Parallel.ForEach(regions, region => {
foreach (var block in region) {
if (!IsMarked(block)) {
Free(block);
}
}
});
}
// 并行压缩更复杂
// 需要计算每个对象的移动目标地址
// 然后并行移动
public void ParallelCompactPhase() {
// 1. 计算移动计划(单线程或并行)
// 2. 并行移动对象
// 3. 并行更新引用
}
}
5.4 .NET的并行GC
// .NET有两种GC模式:
//
// 1. Workstation GC (默认)
// - 单线程GC
// - 适合客户端应用
// - 暂停时间可能较长
//
// 2. Server GC
// - 多线程并行GC
// - 每个CPU一个GC线程和独立堆
// - 适合服务器应用
// - 暂停时间短,吞吐量高
// - 内存占用更大
// 配置Server GC (app.config)
// <configuration>
// <runtime>
// <gcServer enabled="true"/>
// </runtime>
// </configuration>
// 代码中检查
Console.WriteLine($"GC Server: {GCSettings.IsServerGC}");
Console.WriteLine($"GC Latency Mode: {GCSettings.LatencyMode}");
六、三者的对比
6.1 核心对比表
| 特性 | 增量式GC | 并发式GC | 并行式GC |
|---|---|---|---|
| 核心思想 | 拆分GC工作 | GC与应用同时运行 | 多线程加速GC |
| STW次数 | 多次短暂停 | 2次短暂停 | 1次(但用多线程加速) |
| STW总时间 | 中等 | 最短 | 较短 |
| 总GC时间 | 最长(写屏障开销) | 中等 | 最短 |
| 吞吐量 | 最低 | 中等 | 最高 |
| 延迟 | 低 | 最低 | 中等 |
| 实现复杂度 | 高 | 最高 | 中等 |
| 内存开销 | 中等 | 较高 | 较低 |
| 适用场景 | 实时系统 | 低延迟服务 | 高吞吐服务 |
6.2 暂停时间对比
场景: 堆大小 1GB, 4核CPU
传统STW GC:
暂停: ████████████████████████ (200ms)
增量式GC:
暂停: ██ ██ ██ ██ ██ ██ ██ ██ (8×5ms = 40ms总暂停)
应用: ░░██░░██░░██░░██░░██░░██░░██░░██░░
并发式GC:
暂停: ████░░░░░░░░░░░░░░░░██ (2×5ms = 10ms总暂停)
并发: ██████████████████
并行式GC:
暂停: ██████████ (50ms, 4线程并行)
(单线程需要200ms, 4线程约50ms)
6.3 吞吐量对比
吞吐量(单位时间内的有效计算时间):
传统STW GC: ████████████░░░░░░░░ 60%
增量式GC: ████████░░░░░░░░░░░░ 40% (写屏障开销大)
并发式GC: ███████████░░░░░░░░ 55% (中等开销)
并行式GC: ████████████████░░░ 80% (高效利用多核)
七、实际 GC 中的混合使用
7.1 .NET GC的混合策略
.NET GC不是单一的策略,而是根据代和GC模式混合使用多种策略:
// .NET GC的混合策略
//
// Gen0 GC (Minor GC):
// - STW + 并行标记/清扫
// - 暂停时间短(堆小)
// - 频率高
//
// Gen1 GC:
// - STW + 并行标记/清扫
// - 暂停时间中等
// - 频率中
//
// Gen2 GC (Major/Full GC):
// - 并发标记(Background GC)
// - STW初始标记 + STW重新标记
// - 并发清扫
// - 压缩时STW(如果需要)
//
// 配置:
// GCSettings.LatencyMode = GCLatencyMode.Interactive; // 默认
// GCSettings.LatencyMode = GCLatencyMode.LowLatency; // 低延迟
// GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; // 持续低延迟
// GCSettings.LatencyMode = GCLatencyMode.Batch; // 批处理(高吞吐)
// 低延迟模式
// - 限制Gen2 GC的触发
// - 适合游戏和实时应用
void ConfigureLowLatency() {
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// Gen0和Gen1仍然正常GC
// Gen2 GC被延迟(除非内存压力极大)
}
7.2 Background GC
// .NET 4.0引入Background GC
// - Gen2的标记和清扫在后台线程并发执行
// - Gen0和Gen1的GC可以在Background GC期间进行
// - 大幅减少Gen2 GC的暂停时间
// Background GC的时间线:
//
// 应用: ████████████████████████████████████
// Gen0: ░░██░░░░░░██░░░░░░░░██░░░░░░░░██░░ (频繁短暂停)
// Gen2: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (并发标记)
// Gen2: ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ (仅初始/重新标记STW)
7.3 Unity/Mono的GC策略
// Unity使用的GC策略取决于运行时:
//
// 1. Mono + Boehm GC (旧版Unity)
// - 非增量、非并发的标记-清扫GC
// - 全程STW
// - 暂停时间长
// - 不支持并发
//
// 2. Mono + SGen GC (较新Unity)
// - 分代GC
// - Gen0/Gen1: STW + 并行
// - Gen2: 可以配置为并发标记
// - 支持写屏障
//
// 3. IL2CPP
// - 使用Boehm GC
// - 非增量、非并发
// - 但可以通过配置启用增量模式
//
// Unity增量GC (Incremental GC):
// - Unity 2019.1+引入
// - 基于Boehm GC的增量模式
// - 将GC工作分摊到多帧
// - 每帧只做一小部分GC工作
// 启用Unity增量GC
// Player Settings → Other Settings → Use incremental GC
// 或脚本:
// UnityEngine.Scripting.GarbageCollector.incrementalGeneration = 1;
// 设置每帧GC预算(毫秒)
// var settings = new UnityEngine.Scripting.GarbageCollector.GCMode();
// settings.incrementalTimeSlice = 3.0f; // 每帧3ms
7.4 Unity增量GC详解
// Unity增量GC的工作原理
//
// 传统Boehm GC:
// 帧N: ████████████████░░░░░░░░░░░░ GC暂停50ms
// 帧N+1: ░░░░░░░░░░░░░░░░░░░░░░░░░░ 正常
//
// 增量Boehm GC:
// 帧N: ██░░░░░░░░░░░░░░ GC 3ms
// 帧N+1: ██░░░░░░░░░░░░░░ GC 3ms
// 帧N+2: ██░░░░░░░░░░░░░░ GC 3ms
// ...
// 帧N+16:██░░░░░░░░░░░░░░ GC完成
// 总GC时间: 50ms (不变)
// 但分摊到17帧 → 每帧只暂停3ms → 不影响帧率
// Unity增量GC的限制:
// 1. 总GC时间不变(甚至略增,因为增量开销)
// 2. 如果内存压力过大,可能退化为全量GC
// 3. 增量GC期间的对象分配可能影响GC效率
// 4. 不支持并发(仍然是STW,只是分成了多次)
// 最佳实践
void OptimizeUnityGC() {
// 1. 启用增量GC
UnityEngine.Scripting.GarbageCollector.isIncremental = true;
// 2. 控制每帧GC预算
// 默认3ms,可根据需要调整
// 越大 → GC完成越快 → 但每帧暂停越长
// 越小 → 每帧暂停越短 → 但GC完成越慢
// 3. 减少GC分配
// - 使用对象池
// - 避免在Update中分配
// - 使用stackalloc代替小数组
// - 使用Span<T>和Memory<T>
// 4. 手动触发GC
// 在Loading场景时触发GC
// 避免在游戏过程中触发
GC.Collect();
}
7.5 各运行时GC策略对比
| 特性 | Boehm GC (Unity旧版) | SGen (Mono) | .NET GC | Unity增量GC |
|---|---|---|---|---|
| 分代 | 否 | 是 | 是 | 否 |
| 增量 | 否 | 否 | 否 | 是 |
| 并发 | 否 | 部分 | 是 | 否 |
| 并行 | 否 | 部分 | 是 | 否 |
| 写屏障 | 否 | 是 | 是 | 是 |
| 压缩 | 否 | 是 | 是 | 否 |
| 暂停时间 | 长 | 中等 | 短 | 短(分摊) |
| 吞吐量 | 中等 | 中等 | 高 | 中等 |
八、写屏障的深入分析
8.1 写屏障的性能影响
// 写屏障对性能的影响
//
// 每次引用写入都需要额外操作:
// 1. 计算卡片索引
// 2. 标记卡表
// 3. 可能的内存屏障(确保可见性)
//
// 这对引用密集的代码影响很大
// 无写屏障:
void NoBarrier() {
var a = new Node();
a.Next = new Node(); // 直接写入
a.Next.Next = new Node(); // 直接写入
}
// 有写屏障(概念):
void WithBarrier() {
var a = new Node();
WriteBarrier(ref a.Next, new Node()); // 额外开销
WriteBarrier(ref a.Next.Next, new Node()); // 额外开销
}
// 性能差异:
// - 简单引用写入: ~1ns
// - 带写屏障的写入: ~3-5ns
// - 在引用密集的代码中可能慢2-3倍
8.2 读屏障
// 某些GC还需要读屏障
// 读屏障: 每次读取引用时执行额外操作
//
// 用途:
// 1. 移动式GC: 读取时可能需要转发引用
// 2. Brooks指针: 每个对象有一个指向自身的指针
// 3. 并发移动GC: 读取时检查对象是否在移动
//
// .NET GC不使用读屏障(使用句柄表替代)
// 但某些GC(如Azul Zing)使用读屏障实现超低延迟
// 读屏障的性能影响比写屏障更大
// 因为读操作远多于写操作
// 所以大多数GC尽量避免读屏障
九、实际应用中的选择
9.1 游戏开发中的GC策略选择
// 游戏开发GC策略选择指南
//
// 1. Unity + Boehm GC (IL2CPP)
// - 启用增量GC
// - 严格控制分配
// - 使用对象池
// - 适合移动平台
//
// 2. Unity + Mono (Editor)
// - Boehm GC或SGen
// - 开发时不太关心GC
// - 但要避免在热路径分配
//
// 3. .NET游戏服务器
// - 使用Server GC
// - 配置低延迟模式
// - 利用并发GC减少暂停
//
// 4. .NET客户端游戏
// - Workstation GC
// - 低延迟模式
// - 注意Gen2 GC暂停
// .NET游戏服务器配置
void ConfigureServerGC() {
// app.config
// <gcServer enabled="true"/>
// <gcConcurrent enabled="true"/>
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// 定期在安全时机触发GC
// 例如在场景切换时
void OnSceneChange() {
GCSettings.LatencyMode = GCLatencyMode.Batch;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
}
}
9.2 监控GC性能
// 使用GC性能计数器监控
void MonitorGC() {
// 获取GC统计信息
Console.WriteLine($"Gen0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2 collections: {GC.CollectionCount(2)}");
Console.WriteLine($"Total allocated: {GC.GetTotalAllocatedBytes()}");
// 使用PerformanceCounter
using var gen0Counter = new PerformanceCounter(".NET CLR Memory", "# Gen 0 Collections", Process.GetCurrentProcess().ProcessName);
using var gen2Counter = new PerformanceCounter(".NET CLR Memory", "# Gen 2 Collections", Process.GetCurrentProcess().ProcessName);
using var pauseTime = new PerformanceCounter(".NET CLR Memory", "% Time in GC", Process.GetCurrentProcess().ProcessName);
Console.WriteLine($"Time in GC: {pauseTime.NextValue()}%");
}
// 使用ETW事件追踪GC
// .NET提供详细的GC ETW事件
// 可以使用PerfView或dotnet-trace分析
十、总结
增量式GC、并发式GC和并行式GC是减少GC暂停时间的三种核心策略:
-
增量式GC:将一次长GC拆分成多次短GC,通过写屏障保证正确性。适合实时系统,但总GC时间更长。
-
并发式GC:让GC的大部分工作与应用线程同时运行,只有关键阶段需要STW。暂停时间最短,但实现最复杂。
-
并行式GC:使用多线程加速GC工作,减少STW持续时间。吞吐量最高,但暂停时间不如并发式短。
-
混合策略:实际GC系统通常混合使用多种策略。.NET的Background GC结合了并发和并行,Unity的增量GC是增量式的简化版本。
-
写屏障是增量式和并发式GC的基础,它记录引用修改,确保GC正确性,但带来性能开销。
-
选择策略需要根据应用场景权衡:游戏需要低延迟(增量/并发),服务器需要高吞吐(并行),实时系统需要可预测的暂停(增量)。
下一篇 :02-07 GC暂停时间的本质