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 的分代模式试图达成以下目标:
忽略老年代:默认老年代对象是“不死”的,除非有证据表明它们引用了新对象。
聚焦年轻代:集中火力快速扫描和清理新创建的对象。
零拷贝(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_OLD0和G_OLD1经常通过位运算切换,用于区分不同周期的老年代,防止在同一个周期内重复处理。
- 定义: 这也是老年代,但在当前的 GC 周期中,它们是“刚晋升”上来的,或者有着特殊的标记意义。在源码中,
G_TOUCHED (The Remembered Set):
定义: 一个被“弄脏”的老年代对象。
触发: 当
old_obj[key] = new_obj发生时,old_obj会从G_OLD变为G_TOUCHED。存储: 它们会被链入
grayagain列表。
状态流转图
| |
关键机制:向前写屏障 (Forward Write Barrier)
分代 GC 的核心难题是:如何知道哪些老年代对象引用了年轻代对象? 如果不解决这个问题,回收年轻代时就必须扫描所有老年代对象,这会极其缓慢。
Lua 使用 向前写屏障(Forward Write Barrier) 来解决。
- 当执行 t[k] = v 时,如果 t 是老年代(Black),而 v 是新对象(White/Young)。
- 触发屏障:luaC_barrier_。
- 状态变更: t 被标记为 G_TOUCHED。
- 加入列表: t 被放入 grayagain 列表(在这个上下文中,它被用作待扫描的根集合的一部分)。
屏障触发逻辑
当执行 lua_settable 或类似操作(t[k] = v)时,虚拟机会调用 luaC_barrier_。
| |
为什么叫“向前”?
Lua 使用 向前写屏障(Forward Write Barrier) 来解决。这与增量 GC 中使用的“向后写屏障(Backwards Barrier,将父节点颜色改回灰色)”不同。
在增量 GC 中,如果我们把黑色对象改回灰色(向后),GC 就需要重新扫描它。但在分代 GC 中,我们不希望重新扫描整个对象(如果它很大)。 Lua 的处理方式其实更接近于“把这个老年代对象暂时看作根节点”。在 Minor GC 开始时,它是扫描的起点之一。
次级回收 (Minor Collection) 的详细流程
次级回收(Minor Collection)的目标是:只清理年轻代对象,且必须是原子操作(不可中断,但由于只扫描年轻代和被触碰的老年代,速度极快)。
触发时机
由 genminormul 参数控制。默认情况下,当新分配的内存达到上次存活内存的 20% 时触发。
步骤详解
准备与根扫描 (Mark Roots):
GC 扫描主线程栈、全局注册表 (Registry) 等根节点。
注意:这里只标记根节点直接指向的年轻代对象。
处理记忆集 (Scan GrayAgain / Touched):
这是分代 GC 能够成立的基石。
GC 遍历
grayagain列表。这里面全是G_TOUCHED的老对象。关键逻辑: 如果一个老对象在
grayagain里,GC 会扫描它引用的所有子对象。如果子对象是G_NEW,则将其标记为活跃。状态恢复: 扫描完后,这个老对象通常会被改回
G_OLD状态。如果它之后又被修改,写屏障会再次捕获它。
递归追踪 (Trace):
从上述步骤产生的灰色对象开始遍历。
截断机制 (The Cut-off): 遍历过程中,一旦遇到
isold(obj)为真的对象,立刻停止深入。因为老年代对象被默认认为是“这就到头了,不用管它的子节点(除非它在 grayagain 里)”。这保证了遍历仅限制在年轻代对象图中。
清扫与晋升 (Sweep and Promote):
- 此时,所有未被标记的年轻代对象都是垃圾。Lua 遍历全局对象链表(这在 Lua 5.4 中通过优化,不再遍历整个 allgc,而是利用指针操作高效处理):
- 死亡对象(Dead Young): 既不是老年代,也没被标记为活跃。 -> 释放内存。
- 幸存对象(Survivor): 它是年轻代,但被标记为活跃。
- 如果它之前是 G_NEW,将其改为 G_SURVIVAL(给它第二次机会证明自己是垃圾)。
- 如果它之前是 G_SURVIVAL,将其晋升为 G_OLD(晋升)。
- 老年代对象(Old): 忽略,不处理。
- 此时,所有未被标记的年轻代对象都是垃圾。Lua 遍历全局对象链表(这在 Lua 5.4 中通过优化,不再遍历整个 allgc,而是利用指针操作高效处理):
逆序链表与哨兵
这是 Lua GC 实现中最优雅的部分。Lua 不需要移动内存块来整理堆,它通过巧妙的链表操作实现了逻辑上的“分代移动”。
数据结构:时间逆序链表
Lua 的全局对象链表 allgc 是按时间逆序排列的。因为新对象总是插入到链表头部。
| |
g->survival 指针指向了上一次 GC 时的边界。换句话说,g->survival 之后的所有对象,在本次 Minor GC 开始前就已经存在了,它们要么是幸存者,要么是老年代,反正绝不是 G_NEW。
截断式清扫 (Sweep)
由于这种排列特性,Minor GC 的清扫阶段不需要遍历整个链表。
Sweep 逻辑演示 (伪代码):
| |
链表剪接与状态更新
在 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 的条件
老年代膨胀 (Memory Growth):
通过
genmajormul参数控制(默认 100%)。如果老年代的内存大小比上一次 Major GC 时翻了一倍,说明老年代本身在快速增长,Minor GC 已经无法控制内存总量了,必须做一次全量清理。
Bad Touch (G_TOUCHED 过多):
这是最阴险的性能杀手。
原理: Minor GC 的成本 = 扫描年轻代 + 扫描 Touched 老年代。
如果
grayagain列表非常长,G_TOUCHED 的对象太多了。这意味着老年代频繁指向新对象(例如把一个巨大的老表当成缓冲区不断写入新数据),Minor GC 就不再是“Minor”了,它会退化成一次接近全量的扫描。Lua 内部会计数
grayagain的大小,如果它超过了总内存的一定比例,就会强制转为 Major GC。
此时,Lua 会触发一次 Major Collection。这本质上是一次完整的标记-清除循环:
- 扫描所有对象。
- 清理所有死对象(无论老少)。
- 重置所有存活对象为 G_OLD。
- 清空 grayagain 列表。
“Bad Touch” 代码示例
这就是所谓的“把老年代当缓冲区用”。
| |
优化建议: 如果必须频繁更新,尽量复用年轻代对象,或者将变动的数据隔离在一个独立的年轻代 Table 中,不要让它频繁污染巨大的老年代根节点。
实际应用中的问题
- 分代回收的次级回收周期以及全量回收都是是 Stop-the-World"(全停顿) 的策略。
- “脏”老年代过多 (内存过高)时的性能问题
在对延迟极度敏感的场景(如战斗逻辑、高频服务端)中,是不能接受了, 会导致大内存服务的卡顿问题
优化策略
在不同内存使用情况下采用最适合的垃圾回收方式,以优化性能
- 在低内存时(比如500M)使用分代回收,享受他带来的性能提升
- 当内存使用较高时,切换到增量模式,通过周期性地执行小步骤的垃圾回收,避免一次性长时间的GC暂停