02-07-原理篇-GC暂停时间的本质

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性能的基础:

  1. 安全点是STW的基础,线程必须在安全点才能暂停。长时间运行的循环可能导致STW启动延迟。

  2. 标记阶段耗时取决于对象图大小,分代GC通过缩小标记范围大幅减少耗时。

  3. 清扫阶段耗时取决于堆大小,并发清扫可以减少STW。

  4. 压缩阶段是最耗时的操作,移动对象和更新引用开销大。避免不必要的压缩是减少暂停的关键。

  5. 测量暂停使用GC通知API、ETW事件、Unity Profiler等工具。

  6. 减少暂停的核心策略:减少分配、控制GC时机、使用低延迟模式、优化LOH。

  7. Unity特殊注意:Boehm GC不支持并发,增量GC是主要的优化手段。配合对象池和减少分配可以达到较好的效果。

下一篇 :02-08 GC的视野边界

相关推荐
WarPigs1 小时前
UI路径配置SO>字典json工作流
ui·unity
北i2 小时前
Dubbo 默认超时重试:把下游打雪崩的隐形放大器
开发语言·qt·dubbo
10mAh2 小时前
【Java】ArrayList 遍历删除为什么报错?三种写法与漏删复现
java·开发语言·windows
wewowilling3 小时前
AI大模型开发--01Python基础(无废话)
开发语言·python·函数
小羊没烦恼!3 小时前
OS X 上使用.NET开发应用程序
开发语言·前端·数据库·人工智能·c#·策略模式
棉猴3 小时前
玩游戏学Python5-函数
开发语言·python·函数·玩游戏·游戏编程
谢亮_vipxieliang3 小时前
JavaScript this 指向完全指南:调用位置决定一切
开发语言·javascript·ecmascript
denlaku3 小时前
JDK 13 新特性详解
java·开发语言
念越4 小时前
题库、练习与错题本代码阅读
开发语言·数据结构