GC 暂停时间的本质
篇章 :02-原理篇
阅读时间 :约 35 分钟
前置知识:了解 GC 触发条件
一、引言
GC暂停(Stop-The-World, STW)是所有使用GC的运行时都绕不开的话题。在游戏开发中,一次50ms的GC暂停意味着丢失3帧画面;在交易系统中,一次100ms的暂停可能导致订单超时。理解GC暂停的本质------它从何而来、由什么组成、如何测量、如何减少------是性能优化的核心知识。
本文将深入剖析GC暂停的每一个来源,从安全点机制到各阶段的耗时分析,再到实际的测量和优化策略。无论你使用Unity还是.NET,理解这些原理都能帮助你更好地控制GC暂停时间。
二、Stop-The-World 详解
2.1 什么是 STW
Stop-The-World是指GC运行时暂停所有应用线程的状态。在STW期间,应用线程完全停止,不执行任何代码。这是GC保证堆一致性的最简单方式。
正常执行:
线程1: ████████████████████████████
线程2: ████████████████████████████
线程3: ████████████████████████████
GC: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░
STW暂停:
线程1: ████████░░░░░░░░░░░░████████
线程2: ████████░░░░░░░░░░░░████████
线程3: ████████░░░░░░░░░░░░████████
GC: ░░░░░░░░████████████░░░░░░░░
↑ GC工作 ↑
2.2 STW 的触发时机
GC在以下情况会触发STW:
// 1. 显式触发
GC.Collect(); // 触发GC,需要STW
GC.Collect(generation); // 指定代的GC
GC.Collect(generation, mode); // 指定模式
// 2. 隐式触发(分配时)
// 当分配新对象时,如果对应代的预算超限
var obj = new BigObject(); // 可能触发GC
// 3. 内存压力触发
// 操作系统内存不足时
// .NET会收到低内存通知并触发GC
// 4. 定时触发(某些GC实现)
// 后台GC定期检查是否需要GC
// 5. 大对象分配
// LOH分配可能触发Gen2 GC
var bigArray = new int[100_000]; // 可能触发GC
2.3 STW的启动过程
// STW不是瞬间完成的,它有一个启动过程:
//
// 1. GC线程发起STW请求
// 2. 所有应用线程到达安全点
// 3. 所有应用线程暂停
// 4. GC开始工作
//
// 从发起请求到所有线程暂停的时间 = STW启动延迟
// 这个延迟取决于线程到达安全点的速度
// 时间线:
// GC请求: ░░░░░░░░█░░░░░░░░░░░░░░░░░░░
// 线程1: ████████░░░░░░░░░░░░████████ (快速到达安全点)
// 线程2: ████████████░░░░░░░░████████ (慢速到达安全点)
// 线程3: ████████████████░░░░████████ (最慢到达安全点)
// ↑
// 所有线程到达安全点
// GC开始工作
// 问题: 如果某个线程长时间不到达安全点
// → STW启动延迟很长 → 实际暂停时间远超预期
三、安全点(Safepoint)
3.1 安全点机制
安全点是指应用线程可以安全暂停的代码位置。在这些位置,线程的所有引用都在已知状态,GC可以安全地扫描和修改对象。
// 安全点的特征:
// 1. 所有引用变量都在已知位置(栈/寄存器中可被枚举)
// 2. 没有正在进行的引用计算
// 3. 线程可以安全地暂停并等待GC完成
// JIT编译器在以下位置插入安全点:
// 1. 方法入口
// 2. 方法出口
// 3. 循环回边(每次循环迭代)
// 4. 方法调用点
// 5. 分配新对象前
// 安全点检查的伪代码:
void CompiledMethod() {
// 方法入口 → 安全点
if (GCRequested) PauseForGC();
while (condition) {
// 循环回边 → 安全点
if (GCRequested) PauseForGC();
DoWork();
}
// 方法出口 → 安全点
if (GCRequested) PauseForGC();
}
3.2 安全点的实现
// .NET中的安全点实现
//
// JIT编译器在生成的机器码中插入安全点检查
// 检查方式:
// 1. 读取一个全局标志(GC触发标志)
// 2. 如果标志被设置 → 跳转到暂停代码
// 3. 如果标志未设置 → 继续执行
// 伪汇编代码:
// mov eax, [gc_poll_flag] ; 读取GC标志
// test eax, eax ; 检查是否需要GC
// jnz suspend_code ; 如果需要,跳转到暂停
// ; ... 继续正常执行 ...
//
// suspend_code:
// ; 保存所有引用到栈上的GC信息表
// ; 调用GC暂停函数
// ; 等待GC完成
// ; 恢复执行
// 安全点检查的开销:
// - 每次检查约1-2个CPU周期
// - 对性能影响极小
// - 但在高频循环中可能累积
3.3 安全区域
安全点解决了线程在运行时的暂停问题,但对于阻塞状态的线程(如等待I/O、睡眠、等待锁),安全点无法生效。这时需要安全区域。
// 安全区域: 一段代码区域,在其中引用不会改变
// 进入安全区域时,线程通知GC"我在安全区域"
// 离开安全区域时,线程检查是否需要暂停
// .NET中的安全区域:
// 1. 阻塞操作(Thread.Sleep, WaitOne等)
// 2. I/O操作
// 3. P/Invoke调用(调用非托管代码时)
// 4. 线程池等待
// 伪代码:
void ThreadSleep(int ms) {
// 进入安全区域
EnterSafeRegion();
// 执行睡眠
Sleep(ms);
// 离开安全区域
LeaveSafeRegion();
// 如果GC正在进行 → 等待GC完成
}
void EnterSafeRegion() {
// 通知GC: 这个线程在安全区域
// GC可以忽略这个线程(它的引用不会改变)
Thread.CurrentThread.InSafeRegion = true;
}
void LeaveSafeRegion() {
Thread.CurrentThread.InSafeRegion = false;
// 检查GC是否正在进行
if (GCInProgress) {
WaitForGC(); // 阻塞直到GC完成
}
}
3.4 安全点的问题
// 安全点的问题: 长时间运行的循环
//
// 如果一个循环没有安全点(如某些内联的紧密循环)
// → GC等待线程到达安全点 → STW启动延迟
// 问题示例: 大循环没有安全点
void LongLoop() {
long sum = 0;
for (long i = 0; i < long.MaxValue; i++) {
sum += i; // 紧密循环,可能没有安全点
}
// 如果GC在此期间触发 → 等待循环结束 → 长暂停
}
// .NET的改进:
// .NET Core 3.0+在所有循环回边插入安全点
// 之前某些简单循环可能被优化掉安全点
// 手动插入安全点(如果需要)
void LongLoopWithSafepoint() {
long sum = 0;
for (long i = 0; i < long.MaxValue; i++) {
sum += i;
// 每10000次迭代检查一次
if (i % 10000 == 0) {
Thread.Sleep(0); // 触发安全点检查
}
}
}
四、GC 暂停的来源分析
4.1 标记阶段耗时
标记阶段是GC的核心,它从GC根出发,遍历整个对象图,标记所有可达对象。
// 标记阶段的工作:
// 1. 枚举所有GC根(栈变量、静态字段、句柄等)
// 2. 从根出发,深度优先/广度优先遍历对象图
// 3. 标记每个访问到的对象
// 标记阶段的耗时来源:
// 1. GC根数量 → 根越多,初始扫描越慢
// 2. 对象图大小 → 对象越多,遍历越慢
// 3. 对象引用密度 → 引用越多,遍历越深
// 4. 缓存局部性 → 对象分散在内存中 → 缓存未命中
// 标记阶段的优化:
// 1. 分代GC: 只标记年轻代 → 减少标记范围
// 2. 并发标记: 与应用线程并发 → 减少STW
// 3. 卡表: 只重新扫描修改过的区域
// 4. 位图标记: 使用位图而非对象头 → 减少内存访问
// 标记阶段的时间估算:
// 假设:
// - 1GB堆
// - 平均对象大小50字节
// - 约2000万个对象
// - 每个对象标记约100ns(含缓存未命中)
// - 总标记时间: 2000万 × 100ns = 2秒
//
// 但分代GC只标记Gen0(~20MB):
// - 约40万个对象
// - 总标记时间: 40万 × 100ns = 40ms
// → 分代GC大幅减少标记时间
4.2 清扫阶段耗时
清扫阶段释放未被标记的对象,回收内存。
// 清扫阶段的工作:
// 1. 遍历堆中的所有对象
// 2. 检查标记位
// 3. 未标记的对象 → 加入空闲列表/标记为可用
// 4. 已标记的对象 → 清除标记位(为下次GC准备)
// 清扫阶段的耗时来源:
// 1. 堆大小 → 需要遍历整个堆
// 2. 碎片化 → 需要合并空闲块
// 3. 空闲列表维护 → 更新空闲列表
// 清扫阶段的优化:
// 1. 并发清扫: 与应用线程并发执行
// 2. 分代清扫: 只清扫年轻代
// 3. 空闲列表优化: 使用分层空闲列表
// 4. 标记位图: 使用位图快速查找未标记对象
// .NET的清扫策略:
// - Gen0/Gen1: 清扫 + 压缩(移动对象)
// - Gen2: 清扫(不压缩,除非碎片化严重)
// - LOH: 通常不压缩
// 清扫时间估算:
// - 遍历1GB堆: ~50-100ms
// - 分代GC只清扫Gen0(~20MB): ~1-2ms
4.3 压缩阶段耗时
压缩阶段(Compaction)移动对象以消除碎片,这是GC中最耗时的操作。
// 压缩阶段的工作:
// 1. 计算每个存活对象的新位置
// 2. 移动对象到新位置
// 3. 更新所有引用(指向新位置)
// 4. 更新GC根
// 压缩阶段的耗时来源:
// 1. 存活对象数量 → 需要移动的对象数
// 2. 引用更新 → 需要更新的引用数
// 3. 内存拷贝 → 移动对象的数据量
// 4. 缓存影响 → 移动导致缓存失效
// 压缩 vs 不压缩
//
// 压缩:
// 优点: 消除碎片,分配速度快
// 缺点: 耗时长,暂停时间大
//
// 不压缩:
// 优点: 暂停时间短
// 缺点: 碎片化,分配速度慢(需要搜索空闲列表)
// .NET的压缩策略:
// - Gen0: 总是压缩(对象少,速度快)
// - Gen1: 总是压缩
// - Gen2: 根据碎片化程度决定
// - LOH: 默认不压缩(.NET 4.5.1+可配置)
// 手动控制LOH压缩
void CompactLargeObjectHeap() {
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
// 压缩后恢复默认
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.Default;
}
4.4 各阶段暂停时间占比
典型Gen0 GC暂停时间分解 (约5ms):
┌──────────┬──────────┬──────────┐
│ 根扫描 │ 标记 │ 清扫+压缩 │
│ ~1ms │ ~2ms │ ~2ms │
│ 20% │ 40% │ 40% │
└──────────┴──────────┴──────────┘
典型Gen2 GC暂停时间分解 (约50-200ms):
┌──────────┬──────────┬──────────┬──────────┐
│ 初始标记 │ 标记 │ 清扫 │ 压缩 │
│ ~5ms │ ~20ms │ ~10ms │ ~50ms │
│ 7% │ 28% │ 14% │ 51% │
└──────────┴──────────┴──────────┴──────────┘
并发Gen2 GC暂停时间分解 (约10-20ms):
┌──────────┬──────────────────────┬──────────┐
│ 初始标记 │ (并发标记, 不计入) │ 重新标记 │
│ ~5ms │ │ ~5ms │
│ 50% │ │ 50% │
└──────────┴──────────────────────┴──────────┘
五、如何测量暂停时间
5.1 使用GC通知API
// .NET提供GC通知API,可以测量GC暂停时间
class GCProfiler {
private static Stopwatch _sw = new();
private static long _totalPauseTime = 0;
private static int _gcCount = 0;
public static void Start() {
GC.RegisterForFullGCNotification(10, 10);
// 在另一个线程中等待GC通知
Task.Run(() => {
while (true) {
if (GC.WaitForFullGCApproach() == GCNotificationStatus.Succeeded) {
_sw.Restart();
}
if (GC.WaitForFullGCComplete() == GCNotificationStatus.Succeeded) {
_sw.Stop();
_totalPauseTime += _sw.ElapsedMilliseconds;
_gcCount++;
Console.WriteLine($"GC #{_gcCount}: {_sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Total pause: {_totalPauseTime}ms");
}
}
});
}
}
5.2 使用ETW事件
// .NET的ETW(Event Tracing for Windows)提供详细的GC事件
// 可以使用PerfView或dotnet-trace工具分析
// 使用dotnet-trace命令行:
// dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime --process-id <pid>
//
// 关键GC事件:
// - GCStart: GC开始(包含代、原因、类型)
// - GCEnd: GC结束
// - GCSuspendEEStart: STW开始
// - GCSuspendEEEnd: STW完成
// - GCRestartEEStart: 恢复执行开始
// - GCRestartEEEnd: 恢复执行完成
//
// 暂停时间 = GCRestartEEEnd - GCSuspendEEStart
5.3 使用Unity Profiler
// Unity中测量GC暂停
//
// 1. 使用Profiler
// Window → Analysis → Profiler → Memory
// 可以看到GC Alloc和GC Collect事件
//
// 2. 使用脚本测量
class UnityGCProfiler : MonoBehaviour {
private float _lastFrameTime;
private float _maxFrameTime;
void Update() {
float frameTime = Time.realtimeSinceStartup - _lastFrameTime;
_lastFrameTime = Time.realtimeSinceStartup;
if (frameTime > _maxFrameTime) {
_maxFrameTime = frameTime;
Debug.Log($"Max frame time: {_maxFrameTime * 1000}ms");
}
// 检测GC导致的帧时间飙升
if (frameTime > 0.020f) { // >20ms
Debug.LogWarning($"Frame spike: {frameTime * 1000}ms (possible GC)");
}
}
void OnGUI() {
GUILayout.Label($"Max frame: {_maxFrameTime * 1000:F1}ms");
GUILayout.Label($"GC alloc/frame: {UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / 1024}KB");
}
}
5.4 使用性能计数器
// 使用.NET性能计数器监控GC
class GCPerformanceMonitor {
public static void Monitor() {
using var timeInGC = new PerformanceCounter(
".NET CLR Memory",
"% Time in GC",
Process.GetCurrentProcess().ProcessName);
using var gen0 = new PerformanceCounter(
".NET CLR Memory",
"# Gen 0 Collections",
Process.GetCurrentProcess().ProcessName);
using var gen2 = new PerformanceCounter(
".NET CLR Memory",
"# Gen 2 Collections",
Process.GetCurrentProcess().ProcessName);
while (true) {
Console.WriteLine($"Time in GC: {timeInGC.NextValue():F1}%");
Console.WriteLine($"Gen0: {gen0.NextValue()}, Gen2: {gen2.NextValue()}");
Thread.Sleep(1000);
}
}
}
六、减少暂停的策略
6.1 减少GC分配
// 最根本的策略: 减少GC分配 → 减少GC触发频率
// 1. 对象池
class ObjectPool<T> where T : new() {
private readonly Stack<T> _pool = new();
private readonly int _maxSize;
public ObjectPool(int maxSize = 100) {
_maxSize = maxSize;
}
public T Get() {
return _pool.Count > 0 ? _pool.Pop() : new T();
}
public void Return(T item) {
if (_pool.Count < _maxSize) {
_pool.Push(item);
}
}
}
// 使用对象池
class Bullet : MonoBehaviour {
private static ObjectPool<Bullet> _pool = new(200);
public static Bullet Spawn(Vector3 pos) {
var bullet = _pool.Get();
bullet.transform.position = pos;
bullet.gameObject.SetActive(true);
return bullet;
}
public void Despawn() {
gameObject.SetActive(false);
_pool.Return(this);
}
}
// 2. 避免在热路径分配
void BadUpdate() {
// 每帧分配 → 触发GC
var enemies = GameObject.FindGameObjectsWithTag("Enemy");
foreach (var e in enemies) { /* ... */ }
}
private List<GameObject> _enemyCache = new();
void GoodUpdate() {
// 复用列表
_enemyCache.Clear();
GameObject.FindGameObjectsWithTag("Enemy", _enemyCache);
foreach (var e in _enemyCache) { /* ... */ }
}
// 3. 使用stackalloc
unsafe void ProcessData() {
// 栈上分配,不触发GC
int* buffer = stackalloc int[256];
for (int i = 0; i < 256; i++) {
buffer[i] = i;
}
}
// 4. 使用Span和ArrayPool
void ProcessWithSpan() {
// 从ArrayPool租用数组
int[] buffer = ArrayPool<int>.Shared.Rent(1024);
try {
var span = buffer.AsSpan(0, 1024);
// 使用span...
}
finally {
ArrayPool<int>.Shared.Return(buffer);
}
}
6.2 控制GC触发时机
// 在安全时机手动触发GC
class GCController {
// 在Loading场景时触发GC
public static void CollectDuringLoading() {
// 切换到高吞吐模式
var oldMode = GCSettings.LatencyMode;
GCSettings.LatencyMode = GCLatencyMode.Batch;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect(); // 再次收集终结后的对象
GCSettings.LatencyMode = oldMode;
}
// 在帧间隙触发增量GC
public static void CollectBetweenFrames() {
// Unity增量GC: 每帧自动做一小部分
// 也可以手动控制
if (GC.GetTotalMemory(false) > THRESHOLD) {
GC.Collect(0); // 只收集Gen0
}
}
private const long THRESHOLD = 100 * 1024 * 1024; // 100MB
}
// Unity中在场景切换时触发GC
class SceneLoader : MonoBehaviour {
IEnumerator LoadSceneAsync(string sceneName) {
// 先触发GC
GCController.CollectDuringLoading();
// 等待一帧
yield return null;
// 加载场景
var op = SceneManager.LoadSceneAsync(sceneName);
yield return op;
}
}
6.3 使用低延迟GC模式
// .NET的低延迟GC模式
class LowLatencyGC {
public static void Configure() {
// SustainedLowLatency: 持续低延迟
// - 避免Gen2 GC
// - Gen0和Gen1正常GC
// - 适合游戏和实时应用
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// 注意: SustainedLowLatency模式下
// 如果内存压力过大,仍会触发Gen2 GC
// 需要配合减少分配使用
}
// 临时低延迟区域
public static void RunLowLatency(Action action) {
var oldMode = GCSettings.LatencyMode;
try {
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
action();
}
finally {
GCSettings.LatencyMode = oldMode;
}
}
// 在关键帧使用低延迟
void Update() {
// 检测是否在关键帧
if (IsCriticalFrame()) {
LowLatencyGC.RunLowLatency(() => {
// 关键帧逻辑
// 期间不会触发Gen2 GC
});
}
}
bool IsCriticalFrame() {
// 例如: 战斗中、过场动画中
return inBattle || inCutscene;
}
}
6.4 固定对象避免移动
// 使用fixed/GCHandle.Alloc固定对象
// 防止GC移动对象 → 减少压缩开销
// 1. fixed语句
void ProcessArray(int[] array) {
fixed (int* ptr = array) {
// array不会被GC移动
for (int i = 0; i < array.Length; i++) {
ptr[i] *= 2;
}
}
// fixed块结束后,对象可以被移动
}
// 2. GCHandle.Alloc with Pinned
class PinnedBuffer {
private GCHandle _handle;
private byte[] _buffer;
public PinnedBuffer(int size) {
_buffer = new byte[size];
_handle = GCHandle.Alloc(_buffer, GCHandleType.Pinned);
// _buffer在_handle释放前不会被GC移动
}
public IntPtr Pointer => _handle.AddrOfPinnedObject();
public void Free() {
_handle.Free(); // 释放后可以被移动
}
}
// 注意: 固定对象会影响GC的压缩效率
// 不要长期固定大量对象
6.5 LOH优化
// 大对象堆(LOH)优化
// LOH中的对象 >= 85,000字节
// LOH不压缩 → 碎片化
// LOH GC需要Gen2 GC → 暂停时间长
// 策略1: 避免大对象
// - 使用多个小对象代替一个大对象
// - 使用ArrayPool租用大数组
// 策略2: 复用大对象
class LargeBufferManager {
private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
public static byte[] GetBuffer(int size) {
return _pool.Rent(size);
}
public static void ReturnBuffer(byte[] buffer) {
_pool.Return(buffer);
}
}
// 策略3: 手动压缩LOH
void CompactLOH() {
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
// 压缩后恢复默认
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.Default;
}
// 策略4: 使用NativeMemory分配超大对象
unsafe void AllocateLargeBuffer() {
// 直接分配非托管内存,不受GC管理
byte* buffer = (byte*)NativeMemory.Alloc(1024 * 1024 * 100); // 100MB
try {
// 使用buffer...
}
finally {
NativeMemory.Free(buffer);
}
}
七、Unity中的GC暂停
7.1 Unity GC暂停的特殊性
// Unity的GC暂停有特殊影响:
// 1. 主线程是游戏循环 → GC暂停直接影响帧率
// 2. Boehm GC不支持并发 → 全程STW
// 3. 没有分代 → 每次GC都是Full GC
// 4. 没有压缩 → 碎片化问题
// Unity GC暂停时间估算:
// - 小堆(50MB): ~10-30ms
// - 中堆(200MB): ~50-100ms
// - 大堆(500MB+): ~100-300ms
//
// 60FPS游戏每帧16.67ms
// → 任何超过16ms的GC暂停都会导致掉帧
// Unity增量GC改善:
// - 将50ms的GC暂停分摊到17帧
// - 每帧约3ms → 不影响帧率
// - 但总GC时间不变(甚至略增)
7.2 Unity GC暂停的测量
// 使用Unity Profiler测量GC暂停
class GCProfiler : MonoBehaviour {
private struct GCStats {
public long totalMemory;
public long allocatedMemory;
public float frameTime;
}
private GCStats _lastStats;
private float _maxSpike;
void Update() {
float frameTime = Time.realtimeSinceStartup - _lastStats.frameTime;
long currentMem = GC.GetTotalMemory(false);
long allocDelta = currentMem - _lastStats.totalMemory;
// 检测GC暂停(内存突然减少 + 帧时间飙升)
if (allocDelta < 0 && frameTime > 0.020f) {
Debug.LogWarning($"GC detected! Pause: {frameTime * 1000:F1}ms, " +
$"Memory freed: {-allocDelta / 1024}KB");
if (frameTime > _maxSpike) {
_maxSpike = frameTime;
}
}
_lastStats = new GCStats {
totalMemory = currentMem,
allocatedMemory = GC.GetTotalAllocatedMemory(),
frameTime = Time.realtimeSinceStartup
};
}
void OnGUI() {
GUILayout.BeginArea(new Rect(10, 10, 300, 100));
GUILayout.Label($"Max GC Spike: {_maxSpike * 1000:F1}ms");
GUILayout.Label($"Total Memory: {GC.GetTotalMemory(false) / 1024 / 1024}MB");
GUILayout.Label($"GC Mode: {(UnityEngine.Scripting.GarbageCollector.isIncremental ? "Incremental" : "Full")}");
GUILayout.EndArea();
}
}
八、暂停时间优化总结
8.1 优化策略对比
| 策略 | 效果 | 适用场景 | 代价 |
|---|---|---|---|
| 减少分配 | 减少GC频率 | 所有场景 | 代码复杂度增加 |
| 对象池 | 减少分配 | 频繁创建/销毁 | 内存占用增加 |
| 增量GC | 分摊暂停 | Unity实时 | 总GC时间略增 |
| 并发GC | 减少STW | .NET服务 | 写屏障开销 |
| 低延迟模式 | 延迟Gen2 GC | .NET实时 | 内存占用增加 |
| 手动触发GC | 控制GC时机 | 所有场景 | 需要合适时机 |
| LOH优化 | 减少Gen2 GC | 大对象场景 | 代码复杂度 |
8.2 暂停时间目标
不同应用的暂停时间目标:
移动游戏 (60FPS):
目标: < 5ms (Gen0), < 16ms (Gen2)
策略: 增量GC + 对象池 + 减少分配
PC游戏 (60FPS):
目标: < 10ms (Gen0), < 30ms (Gen2)
策略: Server GC + 低延迟 + 对象池
VR游戏 (90FPS):
目标: < 3ms (任何GC)
策略: 极端减少分配 + 增量GC
Web服务器:
目标: < 50ms (Gen2)
策略: Server GC + 并发GC
交易系统:
目标: < 1ms (任何GC)
策略: 几乎零分配 + System.GC.TryStartNoGCRegion
8.3 使用NoGCRegion
// .NET提供NoGCRegion API,在关键区域禁止GC
void CriticalOperation() {
// 尝试进入NoGC区域
// 预留足够的内存,确保期间不需要GC
if (GC.TryStartNoGCRegion(10 * 1024 * 1024)) { // 10MB
try {
// 在这个区域内不会触发GC
// 适合关键帧或实时操作
DoCriticalWork();
}
finally {
GC.EndNoGCRegion();
}
}
else {
// 无法进入NoGC区域(内存不足)
// 降级处理
DoNormalWork();
}
}
// Unity中没有NoGCRegion API
// 但可以通过减少分配达到类似效果
九、总结
GC暂停时间的本质是GC为了保证堆一致性而暂停应用线程的时间。理解暂停的来源和组成,是优化GC性能的基础:
-
安全点是STW的基础,线程必须在安全点才能暂停。长时间运行的循环可能导致STW启动延迟。
-
标记阶段耗时取决于对象图大小,分代GC通过缩小标记范围大幅减少耗时。
-
清扫阶段耗时取决于堆大小,并发清扫可以减少STW。
-
压缩阶段是最耗时的操作,移动对象和更新引用开销大。避免不必要的压缩是减少暂停的关键。
-
测量暂停使用GC通知API、ETW事件、Unity Profiler等工具。
-
减少暂停的核心策略:减少分配、控制GC时机、使用低延迟模式、优化LOH。
-
Unity特殊注意:Boehm GC不支持并发,增量GC是主要的优化手段。配合对象池和减少分配可以达到较好的效果。
下一篇 :02-08 GC的视野边界