在上一篇文章中,我们深入剖析了 Unity 传统 Boehm GC 的完整工作机制——从触发条件、STW(Stop-The-World)线程暂停,到 Mark 阶段的递归可达性分析,再到 Sweep 阶段的分级回收策略。我们看到了保守式 GC 在设计与实现上的诸多权衡:无法整理内存、无法精确区分指针与数据、依赖全局扫描来完成回收。这些特性使得传统 GC 在一次回收中往往带来不可忽视的卡顿,尤其是在托管堆较大、对象存活率较高的游戏场景下。

然而,游戏对帧率稳定性的要求极为苛刻。一次 50ms 甚至 100ms 的主线程停顿,足以让玩家明显感知到“掉帧”或“卡一下”。为了解决这个问题,Unity 引入了增量式 GC(Incremental GC)。它并没有改变 Boehm GC 的基本算法,而是通过一种精巧的工程手段:将原本一次性完成的 Mark 阶段拆分成多个短时间的增量步骤,穿插在多帧中执行,从而把一次长时间的 STW 卡顿,转化为多次几乎不可感知的微停顿。

增量式 GC 的核心挑战在于:如何在 GC 与用户线程并发运行的前提下,仍然保证标记结果的正确性?​ 答案就是本文要重点讲解的 Write Barrier(写屏障)机制,以及与之配套的 dirty page 跟踪、状态机驱动的增量标记流程

WriteBarrier

// 赋值field1生成的il2cpp代码
    __this->___field1 = (RuntimeObject*)L_4;
    Il2CppCodeGenWriteBarrier((void**)(&__this->___field1), (void*)(RuntimeObject*)L_4); // 调用WriteBarrier

void Il2CppCodeGenWriteBarrier(void** targetAddress, void* object)
{
    il2cpp::gc::GarbageCollector::SetWriteBarrier(targetAddress);
}

il2cpp::gc::GarbageCollector::SetWriteBarrier(void **ptr)
{
    GC_END_STUBBORN_CHANGE(ptr);
}

GC_API void GC_CALL GC_end_stubborn_change(const void *p)
{
    GC_dirty(p); /* entire object */
}

相较于普通GC,增量式GC引入了Write Barrier写屏障,在每次修改引用字段所引用的对象时,都会对该字段执行WriteBarrier代码,将被修改引用字段的对象所对应的hblk的dirty位设置为1,修改位置位于一个单独的GC_dirty_pages数组中,从而告诉增量GC——这个对象所在的内存区域的指针指向可能发生变化了,需要重新扫描

为什么需要依赖于WriteBarrier?

因为增量式GC和用户线程是并发的,GC期间用户线程依然有可能修改对象间的引用关系,而WriteBarrier负责记录”发生的引用变化“,从而保证Mark阶段扫描时,可对先前的扫描结果进行”更正补救“以符合当前最新的引用关系

而之所以普通GC/full GC不需要依赖于WriteBarrier,是因为二者不会和用户线程并发执行,而是完全暂停用户线程后才进行mark-and-sweep,其扫描对象时获取的引用关系是实时最新的

触发回收

在普通GC下,对于小对象的内存分配,只有在ok_freelist和GC_hblkfreelist中都没有空闲内存块的情况下,才有可能触发GC;而在增量GC下,只要ok_freelist中没有空闲内存块,则有可能直接触发GC,不需要管GC_hblkfreelist,而对于大内存的分配,每次都有可能触发GC。

具体来说,增量模式下判断是否触发GC的函数为GC_maybe_gc:

STATIC void GC_maybe_gc(void)
{
    if (GC_should_collect()) 
    {
        static int n_partial_gcs = 0;

        if (!GC_incremental) 
        {
            ...
        }
        else 
        {
            if (GC_need_full_gc || n_partial_gcs >= GC_full_freq) 
            {
                GC_COND_LOG_PRINTF(
                    "***>Full mark for collection #%lu after %lu allocd bytes\n",
                    (unsigned long)GC_gc_no + 1, (unsigned long)GC_bytes_allocd);

                GC_promote_black_lists();
                (void)GC_reclaim_all((GC_stop_func)0, TRUE);
                GC_notify_full_gc();
                GC_clear_marks();
                n_partial_gcs = 0;
                GC_is_full_gc = TRUE;
            }
            else 
            {
                n_partial_gcs++;
            }
        }

        /* We try to mark with the world stopped.       */
        /* If we run out of time, this turns into       */
        /* incremental marking.                         */
        if (GC_stopped_mark(GC_time_limit == GC_TIME_UNLIMITED ?
            GC_never_stop_func : GC_timeout_stop_func)) 
        {
            GC_finish_collection();
        }
        else 
        {
            if (!GC_is_full_gc) 
            {
                /* Count this as the first attempt */
                GC_n_attempts++;
            }
        }
    }
}

其主要逻辑同样是普通GC下调用的GC_should_collect,但是参数值略有不同:普通GC下其中的一个判断条件是——如果“自从上次GC到现在分配的内存”超过一个阈值,则触发GC,而在增量GC下,该阈值变为了原先的一半

这意味着,触发增量GC的条件更加宽松了

状态转换

STATIC GC_bool GC_stopped_mark(GC_stop_func stop_func)
{
    ...
    STOP_WORLD();
    ...

    GC_initiate_gc();

    unsigned i;
    for (i = 0;; i++) 
    {
        if ((*stop_func)()) 
        {
            GC_COND_LOG_PRINTF("Abandoned stopped marking after"
                " %u iterations\n", i);
            GC_deficit = i;     /* Give the mutator a chance.   */

            START_WORLD();

            return(FALSE);
        }
        if (GC_mark_some(GC_approx_sp())) break;
    }
    ...
}

在确定了需要触发GC后,调用GC_stopped_mark会走以下流程:

  • 不会进行标记位清除:增量GC不会像普通GC一样在线程暂停前将hblk中所有的mark标志清空。

  • 线程暂停:在增量GC下,单次GC的最长时间为3ms,如果耗时超过3ms,则会先停止回收,并让用户线程继续运行

  • 收集dirty信息:调用GC_initiate_gc()来获取到自从上一次GC以来到现在的dirty标志并在本次GC中使用,以防在整个GC期间引用关系被修改,GC_dirty_pages再次发生变化。具体来说,GC_initiate_gc()会将GC_dirty_pages数组复制到一个相同规格的GC_grungy_pages数组中,并将GC_dirty_pages清空

  • 进入GC_mark_some状态机,进行本次增量式GC的核心阶段

MS_PUSH_RESCUERS

在收集完成dirty信息后,状态由MS_NONE变为MS_PUSH_RESCUERS:

MS_PUSH_RESCUERS阶段的主要任务是”补救“,处理两次增量式GC之间用户线程发生的新变化,即处理dirty hblk和重新扫描root

STATIC GC_bool GC_mark_some_inner(ptr_t cold_gc_frame)
{
    switch(GC_mark_state) {
        case MS_NONE:
            break;

        case MS_PUSH_RESCUERS:
            if ((word)GC_mark_stack_top
                >= (word)(GC_mark_stack_limit - INITIAL_MARK_STACK_SIZE/2)) 
            {
                /* Go ahead and mark, even though that might cause us to */
                /* see more marked dirty objects later on.  Avoid this   */
                /* in the future.                                        */
                GC_mark_stack_too_small = TRUE;
                MARK_FROM_MARK_STACK();
                break;
            } 
            else 
            {
                scan_ptr = GC_push_next_marked_dirty(scan_ptr);
                if (scan_ptr == 0) 
                {
#if !defined(GC_DISABLE_INCREMENTAL)
                    GC_COND_LOG_PRINTF("Marked from %lu dirty pages\n",
                                       (unsigned long)GC_n_rescuing_pages);
#endif
                    GC_push_roots(FALSE, cold_gc_frame); //扫描root
                    GC_objects_are_marked = TRUE;
                    if (GC_mark_state != MS_INVALID) 
                    {
                        GC_mark_state = MS_ROOTS_PUSHED;
                    }
                }
            }
            break;

        ...

对于dirty hblk的处理逻辑在GC_push_next_marked_dirty中:

/* mark only from dirty pages   */
STATIC struct hblk* GC_push_next_marked_dirty(struct hblk* h)
{
    hdr* hhdr = HDR(h);

    for (;;) 
    {
        if (EXPECT(IS_FORWARDING_ADDR_OR_NIL(hhdr)
            || HBLK_IS_FREE(hhdr), FALSE))
        {
            h = GC_next_used_block(h);
            if (h == 0) return(0);
            hhdr = GC_find_header((ptr_t)h);
        }

        if (GC_block_was_dirty(h, hhdr))
            break;

        h += OBJ_SZ_TO_BLOCKS(hhdr->hb_sz); 
        hhdr = HDR(h);
    }

    GC_push_marked(h, hhdr);
    return(h + OBJ_SZ_TO_BLOCKS(hhdr->hb_sz));
}

为了处理所有的dirty hblk,会从heap的起始地址开始遍历,对于大对象来说,一次循环会跳过其包含的多个hblk,对于小对象来说,一次循环会跳过当前的hblk。在整个heap遍历完成,所有的dirty hblk都被扫描完成后退出循环,并开始扫描root,扫描方式和普通GC一样

在遍历过程中,对于小对象会检查当前object所属的hblk是否是dirty,如果检测到有hblk为dirty,则通过GC_push_marked重新扫描当前hblk;对于大对象,则会检查当前object中是否有任意一个hblk为dirty,如果检测到有hblk为dirty,则通过GC_push_marked重新扫描对象下的所有hblk

GC_push_marked中,会扫描hblk(大对象为object),其扫描、判断值是否为指针的逻辑和正常GC一致,对于认定为指针的值,会将其指向的对象的mark标记位置为1,并将该指针压入到mark stack中(注意,被压入到mark stack中的是”一个需要被继续扫描其内部指针的对象的地址“),等待下一阶段进行递归式扫描标记

注意区分“遍历”和“扫描”:遍历只是按照步长判断堆中的hblk是否是dirty的;而扫描指的是对dirty对象本身进行扫描,即判断其对应的内存中是否有疑似指针的值,二者的成本不同,后者成本高于前者

可能存在的问题:

为什么步长还是object,而不是hblk(4KB)?

个人猜测:选择object为步长,可以保证每个object只会被扫描一次,如果按照hblk4KB大小来遍历,虽说不会出现明显问题,但是对于占据了多个hblk的大对象来说,会导致该对象被多次扫描并加入到mark stack中,而扫描标记是整个GC中最昂贵的部分,带来了额外的性能消耗


明明有全局的GC_dirty_pages位图结构,记录了到底哪个hblk为dirty,为什么还是需要遍历整个堆?

进一步的,完全可以根据bitmap中为dirty的序号,反推出是哪个hblk为脏,还能顺便通过全局__top_index定位到header,不需要遍历整个堆呀?确实是这样的,JVM的GC做法和这个思路类似,不会扫整个heap,而是根据set扫描对应的区域,具体原因尚不清楚


对于小对象来说,实际上只有某个对象是dirty的,但是dirty标记的粒度为hblk,所以不得不把整个hblk全扫描一遍,这合理吗?

这是一个工程上的权衡,其保证了在WriteBarrier时速度较快,牺牲了GC时的扫描速度。并且对于GC_dirty_pages来说,其没法确定object有多少个、大小是多少,这是运行时决定的,所以没法实现WriteBarrier时以object为粒度进行标记


为什么还需要扫描root?

void newChange()
{
    GameObject obj = new GameObejct();
    obj.field = otherObj;
    //....
    func(obj);  //出错!obj已经被回收
}

比如,在两次GC期间创建了新的对象obj,如果不重新扫描root,会导致该新对象没有被mark,进而导致该obj被错误地回收,后续调用出错


看起来增量式GC的工作量更大了,每一次GC都要扫描dirty对象、扫描root、递归地扫描heap中被引用的对象,这不是效率比普通GC更低了吗?

增量式GC的总体工作量确实略高于普通GC,因为他需要额外处理每次GC间隔期间产生的dirty对象并且重新扫描root。但是,增量GC的核心优化在于将最昂贵的”从root出发递归扫描所有可达对象“拆分为了多次执行,从而将大规模的Mark操作摊平到了多个时间片中完成

具体来说,每次GC都会进入MS_PUSH_RESCUERS阶段,对dirty hblk重新扫描,并对root重新扫描,用于修复在上次GC到此次GC发生的新变化,比如创建了新变量、变更了引用关系等,其会将相应的新对象的地址push到mark stack中并等待扫描。实际上,这一阶段的工作量通常很小,因为需要扫描的对象并不多,其是增量式GC相较于普通GC的额外开销。

随后,会递归式地扫描标记mark stack,这才是真正耗时的工作,但是该工作会被均摊到多次增量式GC中完成,每次增量式GC的结果都保存在了mark stack中,达到上限时间后会重新启动用户线程,暂停GC,并等待下一轮增量式GC再继续扫描mark stack,从而避免了突然暂停用户线程较长时间


full GC

可以看到,在GC_stopped_mark之前实际上还有一层full GC的判断逻辑

STATIC void GC_maybe_gc(void)
{
    if (GC_should_collect()) 
    {
        static int n_partial_gcs = 0;

        if (!GC_incremental) 
        {
            ...
        }
        else 
        {
            if (GC_need_full_gc || n_partial_gcs >= GC_full_freq) 
            {
                GC_COND_LOG_PRINTF(
                    "***>Full mark for collection #%lu after %lu allocd bytes\n",
                    (unsigned long)GC_gc_no + 1, (unsigned long)GC_bytes_allocd);

                GC_promote_black_lists();
                (void)GC_reclaim_all((GC_stop_func)0, TRUE);
                GC_notify_full_gc();
                GC_clear_marks();
                n_partial_gcs = 0;
                GC_is_full_gc = TRUE;
            }
            else 
            {
                n_partial_gcs++;
            }
        }

        /* We try to mark with the world stopped.       */
        /* If we run out of time, this turns into       */
        /* incremental marking.                         */
        if (GC_stopped_mark(GC_time_limit == GC_TIME_UNLIMITED ?
            GC_never_stop_func : GC_timeout_stop_func)) 
        {
            GC_finish_collection();
        }
        else 
        {
            if (!GC_is_full_gc) 
            {
                /* Count this as the first attempt */
                GC_n_attempts++;
            }
        }
    }
}

什么时候触发full GC?为什么?

  • 周期性强制full GC,即n_partial_gcs >= GC_full_freq,意思是没进行N次增量式GC,就强制进行一次full GC,N为1。原因:在两次增量GC期间,如果用户线程对象创建或修改引用关系非常频繁,那么会导致mark stack中的增长速度快于每次GC扫描的处理速度,进而导致增量GC永远处理不完,所以需要full GC来进行一次全量标记

  • 如果内存增长过多,即GC_need_full_gc(会在每次增量式GC后判断,从上一次回收以来,如果新投入使用的hblk大于当前objects大小除以2,则为true),那么也会触发full GC。同样的原因,如果有增量回收的内存满足不了内存分配的趋势,则同样会触发一次Full GC来进行全量标记,进而将内存回收

full GC会做什么?

full GC会进行一次完整的普通GC。首先会对之前mark stack中的对象进行扫描,完成收尾工作,然后按照类似于普通GC的逻辑,将所有的用户线程暂停,将mark bit清空,扫描root并递归式地扫描标记所有被引用的对象,此时才允许启动用户线程,后续再通过后台线程进行回收工作


总结

通过本篇对增量式GC的拆解,我们可以清晰地看到这种权衡的全貌:

Write Barrier​ 是增量并发的基石。它通过在引用变更时记录dirty状态,解决了“GC扫描时用户线程修改引用”这一经典难题,代价是每次赋值多了一层函数调用的开销。

Dirty Page 重扫描​ 是增量正确性的保障。它确保了两次GC间隔中新创建的对象或变更的引用不会被遗漏,即便这意味着需要遍历堆中的标记位。

Full GC 的兜底​ 揭示了工程的现实。无论增量机制多么精巧,当内存增长速度超过回收速度,或者碎片化严重时,一次彻底的全量暂停仍是不可避免的终极手段。

增量式 GC 并未改变 Boehm GC 的保守式本质,而是通过 Write Barrier 与状态机驱动的增量标记,将一次不可避免的“大停顿”平滑化为多次可控的“小停顿”。这是 Unity 在跨平台兼容性与实时性要求之间,做出的一项极具工程智慧的折中设计。