Lua 分代 GC

Lua 5.4 引入的分代垃圾回收(Generational GC),相比于 Lua 5.3 的增量式 GC 专注于“降低停顿时间(Latency)”,Lua 5.4 的分代 GC 旨在解决“高吞吐量(Throughput)”场景下的性能瓶颈。特别是对于那些每一帧都产生大量临时对象(Short-lived objects)的游戏或高并发服务,分代 GC 能带来巨大的性能提升。

分代假设

分代假设(Generational Hypothesis) 认为:“绝大多数对象在创建后很快就会死亡。”

在 Lua 5.3 的增量 GC 中,哪怕我们把 GC 拆分成了很多小步,但为了完成一轮完整的 GC 周期,收集器最终还是需要遍历整个堆(或者至少是大部分活跃对象)。如果堆中有几百万个长期存活的对象(比如加载的配置表、全局注册表),每次 GC 都要去确认它们“还在不在”,这本身就是一种巨大的算力浪费。

Lua 5.4 的分代模式试图达成以下目标:

  1. 忽略老年代:默认老年代对象是“不死”的,除非有证据表明它们引用了新对象。

  2. 聚焦年轻代:集中火力快速扫描和清理新创建的对象。

  3. 零拷贝(Non-Moving):作为嵌入式语言,Lua 需要保持 C API 的指针稳定性,因此不能像 Java 或 Go 那样通过物理移动内存来整理堆。

这种设计使得 Lua 5.4 在处理短命对象(如临时字符串、闭包、表)密集的场景下,性能比 5.3 提升显著,同时保持了极低的延迟。

对象状态的深度解析

在分代模式下,Lua 利用 GCObject 头部的标记位(Mark bits)构建了一个精细的状态机。为了更平滑地管理晋升,Lua 5.4 实际上将老年代细分为了两个阶段。

状态全景图

  • G_NEW (Young):

    • 定义: 所有新创建的对象默认都是这个状态。

    • 命运: 在 Minor GC 中,要么死掉,要么变成 Survivor。

  • G_SURVIVAL:

    • 定义: 经历了一次 Minor GC 仍然存活,但还没“老”够两轮的对象。

    • 作用: 这是一个缓冲带。很多对象可能刚好活过了一次 GC(例如跨帧存在的临时对象),如果直接晋升为老年代,下次 GC 就得扫描老年代或者等待 Major GC 才能回收它,成本太高。给它第二次机会,能有效减少“假冒老年代”的数量。

  • G_OLD0 (The “Really” Old):

    • 定义: 真正的老年代。

    • 特征: 颜色为黑色(Black)。GC 在 Minor Collection 期间完全无视它们。

  • G_OLD1 (Old but visited):

    • 定义: 这也是老年代,但在当前的 GC 周期中,它们是“刚晋升”上来的,或者有着特殊的标记意义。在源码中,G_OLD0G_OLD1 经常通过位运算切换,用于区分不同周期的老年代,防止在同一个周期内重复处理。
  • G_TOUCHED (The Remembered Set):

    • 定义: 一个被“弄脏”的老年代对象。

    • 触发: 当 old_obj[key] = new_obj 发生时,old_obj 会从 G_OLD 变为 G_TOUCHED

    • 存储: 它们会被链入 grayagain 列表。

状态流转图

1
2
3
4
[ G_NEW ] --(Minor GC 存活)--> [ G_SURVIVAL ] --(下一次 Minor GC 存活)--> [ G_OLD1 ] --> [ G_OLD0 ]
    ^                                                                         |
    |                                                                         |
    +----(被引用)----< [ G_TOUCHED ] <----(写屏障触发)-------------------------+

关键机制:向前写屏障 (Forward Write Barrier)

分代 GC 的核心难题是:如何知道哪些老年代对象引用了年轻代对象? 如果不解决这个问题,回收年轻代时就必须扫描所有老年代对象,这会极其缓慢。

Lua 使用 向前写屏障(Forward Write Barrier) 来解决。

  1. 当执行 t[k] = v 时,如果 t 是老年代(Black),而 v 是新对象(White/Young)。
  2. 触发屏障:luaC_barrier_。
  3. 状态变更: t 被标记为 G_TOUCHED。
  4. 加入列表: t 被放入 grayagain 列表(在这个上下文中,它被用作待扫描的根集合的一部分)。

屏障触发逻辑

当执行 lua_settable 或类似操作(t[k] = v)时,虚拟机会调用 luaC_barrier_

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 伪代码解析 lgc.c 中的 luaC_barrier_
void luaC_barrier_ (lua_State *L, GCObject *o, GCObject *v) {
    // o 是父对象 (table), v 是子对象 (value)
    
    // 只有当父对象是老年代 (isold),且子对象是新对象 (iswhite) 时才触发
    if (isold(o) && iswhite(v)) {
        
        // 标记 1: 将父对象标记为 TOUCHED
        // 这意味着它不再是纯粹的 OLD,它持有对 Young 的引用
        setage(o, G_TOUCHED);
        
        // 标记 2: 将其放入 grayagain 列表
        // grayagain 本质上就是 "Remembered Set" (记忆集)
        linkgclist(o, g->grayagain);
    }
}

为什么叫“向前”?

Lua 使用 向前写屏障(Forward Write Barrier) 来解决。这与增量 GC 中使用的“向后写屏障(Backwards Barrier,将父节点颜色改回灰色)”不同。

在增量 GC 中,如果我们把黑色对象改回灰色(向后),GC 就需要重新扫描它。但在分代 GC 中,我们不希望重新扫描整个对象(如果它很大)。 Lua 的处理方式其实更接近于“把这个老年代对象暂时看作根节点”。在 Minor GC 开始时,它是扫描的起点之一。

次级回收 (Minor Collection) 的详细流程

次级回收(Minor Collection)的目标是:只清理年轻代对象,且必须是原子操作(不可中断,但由于只扫描年轻代和被触碰的老年代,速度极快)。

触发时机

genminormul 参数控制。默认情况下,当新分配的内存达到上次存活内存的 20% 时触发。

步骤详解

  1. 准备与根扫描 (Mark Roots):

    • GC 扫描主线程栈、全局注册表 (Registry) 等根节点。

    • 注意:这里只标记根节点直接指向的年轻代对象。

  2. 处理记忆集 (Scan GrayAgain / Touched):

    • 这是分代 GC 能够成立的基石。

    • GC 遍历 grayagain 列表。这里面全是 G_TOUCHED 的老对象。

    • 关键逻辑: 如果一个老对象在 grayagain 里,GC 会扫描它引用的所有子对象。如果子对象是 G_NEW,则将其标记为活跃。

    • 状态恢复: 扫描完后,这个老对象通常会被改回 G_OLD 状态。如果它之后又被修改,写屏障会再次捕获它。

  3. 递归追踪 (Trace):

    • 从上述步骤产生的灰色对象开始遍历。

    • 截断机制 (The Cut-off): 遍历过程中,一旦遇到 isold(obj) 为真的对象,立刻停止深入。因为老年代对象被默认认为是“这就到头了,不用管它的子节点(除非它在 grayagain 里)”。

    • 这保证了遍历仅限制在年轻代对象图中。

  4. 清扫与晋升 (Sweep and Promote):

    • 此时,所有未被标记的年轻代对象都是垃圾。Lua 遍历全局对象链表(这在 Lua 5.4 中通过优化,不再遍历整个 allgc,而是利用指针操作高效处理):
      • 死亡对象(Dead Young): 既不是老年代,也没被标记为活跃。 -> 释放内存。
      • 幸存对象(Survivor): 它是年轻代,但被标记为活跃。
        • 如果它之前是 G_NEW,将其改为 G_SURVIVAL(给它第二次机会证明自己是垃圾)。
        • 如果它之前是 G_SURVIVAL,将其晋升为 G_OLD(晋升)。
      • 老年代对象(Old): 忽略,不处理。

逆序链表与哨兵

这是 Lua GC 实现中最优雅的部分。Lua 不需要移动内存块来整理堆,它通过巧妙的链表操作实现了逻辑上的“分代移动”。

数据结构:时间逆序链表

Lua 的全局对象链表 allgc按时间逆序排列的。因为新对象总是插入到链表头部。

1
2
3
4
5
6
7
8
9
内存地址:  High  <-------------------------------------->  Low
逻辑链表:  HEAD (g->allgc)
            |
            v
      [ 新对象 A ] -> [ 新对象 B ] -> [ 幸存者 S ] -> [ 老对象 O1 ] -> [ 老对象 O2 ] -> NULL
      (G_NEW)         (G_NEW)        (G_SURVIVAL)     (G_OLD)          (G_OLD)
                                            ^
                                            |
                                      g->survival (哨兵指针)

g->survival 指针指向了上一次 GC 时的边界。换句话说,g->survival 之后的所有对象,在本次 Minor GC 开始前就已经存在了,它们要么是幸存者,要么是老年代,反正绝不是 G_NEW

截断式清扫 (Sweep)

由于这种排列特性,Minor GC 的清扫阶段不需要遍历整个链表

Sweep 逻辑演示 (伪代码):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
GCObject **p = &g->allgc;
GCObject *curr;
GCObject *limit = g->survival; // 我们的终点站

// 只要还没碰到老年代的边界,就一直循环
while ((curr = *p) != limit) {
    
    if (is_marked(curr)) { 
        // === 存活对象处理 ===
        
        // 晋升逻辑:
        // 如果是 G_NEW -> 变成 G_SURVIVAL
        // 如果是 G_SURVIVAL -> 变成 G_OLD1 (正式晋升)
        promote_age(curr); 
        
        // 清除标记位,为下一次 GC 做准备 (变为白色)
        make_white(curr); 
        
        // 指针步进:这个对象留下了,检查下一个
        p = &curr->next; 
        
    } else {
        // === 死亡对象处理 ===
        
        // 这是一个垃圾对象!
        GCObject *dead = curr;
        
        // 链表摘除操作:
        // *p 指向 curr->next,直接跳过 current
        *p = curr->next; 
        
        // 释放内存
        freeobj(L, dead); 
        
        // 注意:这里 p 不动,因为 *p 已经指向了原本的 next,
        // 下次循环会直接检查那个新对象。
    }
}

// 循环结束!
// 此时 *p 等于 limit。
// 后面那几百万个老年代对象完全不需要访问,Cache 友好度满分。

链表剪接与状态更新

在 Sweep 结束后,我们需要设定新的边界。

假设在上面的例子中,新对象 A 死了,新对象 B 活了。 现在的链表变成了: [ B (Survival) ] -> [ S (Old) ] -> [ O1 (Old) ] ...

此时,g->survival 指针需要更新,指向现在的表头 g->allgc这一步操作瞬间完成了“代”的切换。 今天幸存下来的对象,在下一次 GC 时就会位于 g->survival 指针的后面,成为受保护的老年代。

什么时候会“崩”?(回退到 Major GC)

分代 GC 虽好,但在某些模式下会失效,甚至比普通 GC 更慢。Lua 5.4 引入了回退机制。

触发 Major GC 的条件

  1. 老年代膨胀 (Memory Growth):

    • 通过 genmajormul 参数控制(默认 100%)。

    • 如果老年代的内存大小比上一次 Major GC 时翻了一倍,说明老年代本身在快速增长,Minor GC 已经无法控制内存总量了,必须做一次全量清理。

  2. Bad Touch (G_TOUCHED 过多):

    • 这是最阴险的性能杀手。

    • 原理: Minor GC 的成本 = 扫描年轻代 + 扫描 Touched 老年代

    • 如果 grayagain 列表非常长,G_TOUCHED 的对象太多了。这意味着老年代频繁指向新对象(例如把一个巨大的老表当成缓冲区不断写入新数据),Minor GC 就不再是“Minor”了,它会退化成一次接近全量的扫描。

    • Lua 内部会计数 grayagain 的大小,如果它超过了总内存的一定比例,就会强制转为 Major GC。

此时,Lua 会触发一次 Major Collection。这本质上是一次完整的标记-清除循环:

  1. 扫描所有对象。
  2. 清理所有死对象(无论老少)。
  3. 重置所有存活对象为 G_OLD。
  4. 清空 grayagain 列表。

“Bad Touch” 代码示例

这就是所谓的“把老年代当缓冲区用”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 场景:config 是一个常驻内存的老年代 Table
local config = { data = {} } 

-- 这是一个高频调用的函数,比如每帧调用
function onUpdate()
    -- 这是一个典型的 Bad Touch 模式
    -- 我们不断地创建新表 {},并把它赋值给老表 config 的字段
    
    -- 触发写屏障:config 变黑 -> 变灰 (TOUCHED) -> 加入 grayagain
    config.last_frame_data = { x = 1, y = 2 } 
    
    -- 这种写法会导致 config 表在每一次 Minor GC 中都被扫描!
    -- 如果 config 表很大(比如有几万个字段),GC 耗时会瞬间飙升。
end

优化建议: 如果必须频繁更新,尽量复用年轻代对象,或者将变动的数据隔离在一个独立的年轻代 Table 中,不要让它频繁污染巨大的老年代根节点。

实际应用中的问题

  • 分代回收的次级回收周期以及全量回收都是是 Stop-the-World"(全停顿) 的策略。
  • “脏”老年代过多 (内存过高)时的性能问题

在对延迟极度敏感的场景(如战斗逻辑、高频服务端)中,是不能接受了, 会导致大内存服务的卡顿问题

优化策略

在不同内存使用情况下采用最适合的垃圾回收方式,以优化性能

  • 在低内存时(比如500M)使用分代回收,享受他带来的性能提升
  • 当内存使用较高时,切换到增量模式,通过周期性地执行小步骤的垃圾回收,避免一次性长时间的GC暂停
使用 Hugo 构建
主题 StackJimmy 设计