lua-源码带读-05-垃圾回收器-GC
Lua 5.4 源码带读 第5篇:垃圾回收器(GC)
本篇目标
深入理解 Lua 5.4 的垃圾回收器实现:三色标记法、增量GC、分代GC(5.4新增)、写屏障机制。
前置知识
- 了解标记-清除垃圾回收的基本思想
- 了解三色标记法
- 阅读过第1-4篇
1. GC 概述
1.1 为什么需要GC
Lua 是动态类型语言,所有值在栈上或堆上分配。GC自动管理内存,避免程序员手动malloc/free。
1.2 GC 相关API
// lua.h
lua_gc(L, LUA_GCSTOP, 0); // 停止GC
lua_gc(L, LUA_GCRESTART, 0); // 重启GC
lua_gc(L, LUA_GCCOLLECT, 0); // 全量GC
lua_gc(L, LUA_GCCOUNT, 0); // 当前已用内存(KB)
lua_gc(L, LUA_GCCOUNTB, 0); // 余数(字节)
lua_gc(L, LUA_GCSTEP, 100); // 执行100步GC
lua_gc(L, LUA_GCGEN, 10, 10); // 启用分代GC
lua_gc(L, LUA_GCINC, 10, 10); // 切换回增量GC
2. GCObject 公共头部
// lobject.h
#define CommonHeader struct GCObject *next; lu_byte tt; lu_byte marked
typedef struct GCObject {
CommonHeader;
} GCObject;
next:指向下一个GC对象(所有GC对象通过链表连接)tt:类型标签marked:GC标记位(颜色信息存储在这里)
2.1 marked 字段的位布局
// lgc.h
#define bitmask(s) (1<<(s))
#define bit(s) (bitmask(s) & marked)
// GC颜色位
#define WHITE0BIT 0 // 白色0
#define WHITE1BIT 1 // 白色1
#define GRAYBIT 2 // 灰色
#define BLACKBIT 3 // 黑色
#define FINALIZEDBIT 4 // 已终结
#define KEYBIT 5 // 是表键
#define FIXEDBIT 6 // 不参与GC(已固定)
#define UNIONBIT 7 // 全局状态(已访问)
// 颜色宏
#define iswhite(x) testbits((x)->marked, bitmask(WHITE0BIT)|bitmask(WHITE1BIT))
#define isblack(x) testbit((x)->marked, BLACKBIT)
#define isgray(x) testbits((x)->marked, bitmask(GRAYBIT))
#define WHITEBITS bitmask(WHITE0BIT)|bitmask(WHITE1BIT)
#define otherwhite(g) ((g)->currentwhite ^ WHITEBITS) // 另一个白色
#define isdead(g, v) iswhite(v) && !isfixed(v)
3. 三色标记法
3.1 三色含义
| 颜色 | 含义 | 状态 |
|---|---|---|
| 白色(white) | 未访问 | 可能被回收 |
| 灰色(gray) | 正在处理 | 已发现但子对象未扫描 |
| 黑色(black) | 已完成 | 已扫描完所有子对象 |
3.2 标记过程
1. 从根对象开始,标记为灰色
根对象 = 全局表、注册表、活跃线程、open upval
2. 取出灰色对象,扫描其引用的子对象
- 子对象如果是白色,标记为灰色
- 当前对象变黑
3. 重复步骤2直到灰色队列为空
4. 所有仍为白色的对象 = 不可达对象,可以回收
3.3 GCObject引用扫描
// lgc.c
static void gcsweep(lua_State *L) {
GCObject *p;
global_State *g = G(L);
// 遍历所有GC对象
for (p = g->allgc; p != NULL; ) {
GCObject *next = p->next;
if (iswhite(p)) {
// 白色对象 = 不可达,可以回收
luaC_freeobj(L, p);
} else {
// 保留对象
p = next;
}
}
}
4. 增量GC
4.1 概念
增量GC将整个标记-清除过程分成小步,每次GC步进执行一小部分工作:
- 标记几个灰色对象
- 清理几个字符串
- 重分配哈希表
这避免了长时间的STW(Stop-The-World)暂停。
4.2 GC状态机
// lgc.h
enum luaGCState {
GCSpause, // 暂停(开始新周期)
GCSpropagate, // 传播阶段(标记灰色对象)
GCSatomic, // 原子阶段(最终标记)
GCSsweepstring, // 清理字符串驻留表
GCSsweep, // 清理对象
GCSfinalize, // 终结弱引用
GCSGcouvcount, // 5.4新增:计算代间引用
};
4.3 GC步进函数
// lgc.c
int luaC_step(lua_State *L) {
global_State *g = G(L);
int work = 0;
switch (g->gcstate) {
case GCSpause:
// 新周期开始
g->gcstate = GCSpropagate;
break;
case GCSpropagate:
// 标记阶段:处理灰色队列
work = atomic(L);
// 每步处理几个对象
if (g->gcstate != GCSpropagate)
break;
break;
case GCSsweepstring:
// 清理字符串
work = sweepstrings(L, 100); // 清理100个字符串
break;
case GCSsweep:
// 清理对象
work = sweepgen(L, &g->allgc, 100);
break;
}
return work;
}
5. 分代GC(5.4新增)
5.1 概念
分代GC假设"大部分对象存活时间短",将对象分为年轻代和老年代。
| 代 | 回收频率 | 对象特征 |
|---|---|---|
| 年轻代 | 高频 | 新创建的对象 |
| 老年代 | 低频 | 存活多次GC的对象 |
5.2 实现
// lgc.h
typedef struct {
GCObject *young; // 年轻代对象链表
GCObject *old; // 老年代对象链表
} GCGeneration;
5.3 切换分代模式
// lua.h
lua_gc(L, LUA_GCGEN, minormul, majormul);
// minormul: 年轻代回收频率
// majormul: 老年代回收频率
lua_gc(L, LUA_GCINC, pause, stepmul);
// 切换回增量GC
5.4 写屏障(分代模式)
分代GC中,老年代对象引用年轻代对象时,需要特殊处理:
// lgc.c
void luaC_barrier_(lua_State *L, GCObject *o) {
if (isblack(o) && iswhite(o)) {
// 老年代对象引用年轻代对象
// 将年轻代对象标灰
makewhite(G(L), o);
// 或者将老年代对象标灰(使它重新被扫描)
}
}
6. 写屏障机制
6.1 为什么需要写屏障
在增量GC中,黑色对象(已扫描)可能引用新的白色对象。如果不处理,白色对象会被错误回收。
6.2 写屏障类型
// lgc.c
#define luaC_barrier(L,p,v) \
(iscollectable(v) && isblack(p) && iswhite(v) ? \
luaC_barrier_(L, p, v) : cast_void(0))
// 写屏障:当黑色对象p引用白色对象v时
// 将p标灰(使它重新被扫描)
6.3 表操作的写屏障
// ltable.c
static void luaH_setfinish(lua_State *L, Table *t) {
if (isblack(t)) {
// 表是黑色的,修改时需要触发写屏障
luaC_barrier(L, t, &t->node[0]);
}
}
7. weak引用
7.1 weak表
Lua 支持弱引用表(weak table),表中的键或值如果是弱引用,GC可以回收它们。
-- 创建弱引用表
setmetatable(t, {__mode = "k"}) -- 弱键表
setmetatable(t, {__mode = "v"}) -- 弱值表
setmetatable(t, {__mode = "kv"}) -- 弱键值表
7.2 weak表实现
// lgc.c
static void clearweak(lua_State *L) {
// 遍历weak表
// 删除所有引用了白色对象的键或值
while (node != NULL) {
if (iswhite(key) || iswhite(value)) {
// 删除此节点
}
}
}
8. GC 统计与调优
// lua.h
// 获取GC统计信息
size_t total = lua_gc(L, LUA_GCCOUNT, 0);
int remain = lua_gc(L, LUA_GCCOUNTB, 0);
8.1 关键参数
| 参数 | 说明 |
|---|---|
| GCpause | 新周期开始前等待的步数 |
| GCstepmul | 每步GC相对于分配的速度 |
| minormul | 年轻代回收频率 |
| majormul | 老年代回收频率 |
9. 本篇小结
| 概念 | 要点 |
|---|---|
| 三色标记 | 白色=未访问,灰色=正在处理,黑色=已完成 |
| 增量GC | 分步执行,避免长时间STW |
| 分代GC | 年轻代高频回收,老年代低频回收 |
| 写屏障 | 防止黑色对象漏掉对白色对象的引用 |
| weak引用 | 允许GC回收不再使用的键或值 |
| GC状态机 | Spause → propagate → atomic → sweepstring → sweep → finalize |
思考题
- 为什么增量GC需要写屏障?全量GC需要吗?
- 分代GC的"代间引用"如何处理?
- weak表中的值是nil时会发生什么?
- 如何用lua_gc API控制GC暂停时间?
- GCObject链表的头指针在global_State的哪个字段?
思考题解答
1. 为什么增量GC需要写屏障?全量GC需要吗?
增量GC需要写屏障的原因:
增量GC将标记过程分成多步,每次只处理一部分。在两步之间,程序可能修改对象引用:
GC步骤1:扫描对象A,标记为黑色(已扫描完所有子对象)
↓(此时程序执行:A.new_ref = 白色对象B)
GC步骤2:跳过A(因为A是黑色,不再扫描)
↓
结果:B是白色,会被错误回收!但A仍然引用B!
写屏障解决方案:当黑色对象A引用白色对象B时,将A标灰(重新扫描)或标白B。
#define luaC_barrier(L,p,v) (iscollectable(v) && isblack(p) && iswhite(v) ? luaC_barrier_(L, p, v) : cast_void(0))
全量GC不需要写屏障:全量GC是STW(Stop-The-World)的,标记期间程序不执行,不会出现"已扫描对象引用新白色对象"的情况。
2. 分代GC的"代间引用"如何处理?
分代GC中,老年代对象可能引用年轻代对象。如果只回收年轻代,老年代中的引用不会被扫描。
写屏障处理:
// 当老年代对象(黑色)引用年轻代对象(白色)时
void luaC_barrier_(lua_State *L, GCObject *o) {
// 方案1:将老年代对象标灰,使其重新被扫描
// 方案2:将年轻代对象标灰,使其不被回收
// Lua选择方案1:将黑色对象标灰
makewhite(G(L), o); // 将o从黑色变为白色(重新扫描)
}
代间引用列表:Lua还维护一个 grayagain 链表,记录所有可能有代间引用的对象,在每次GC步进中优先扫描。
3. weak表中的值是nil时会发生什么?
weak表中的值如果变为nil(被回收),在下次GC清理时会被删除:
local t = setmetatable({}, {__mode = "v"})
local key = {}
t[key] = "hello"
key = nil -- key变为不可达
collectgarbage("collect")
-- t[key] 现在是nil(key已被删除)
GC清理过程:
// lgc.c
static void clearweak(lua_State *L) {
// 遍历weak表
// 如果键或值是白色(被回收),删除该节点
}
具体行为:
- 弱值表(
__mode = "v"):值被回收时,整个键值对被删除 - 弱键表(
__mode = "k"):键被回收时,整个键值对被删除 - 弱键值表(
__mode = "kv"):任一被回收时,整个键值对被删除
注意:__gc 元方法在对象被回收前调用,如果在 __gc 中重新引用该对象,它会"复活"(从白色变为黑色),弱表中的条目暂时不会被删除。
4. 如何用lua_gc API控制GC暂停时间?
// 设置GC步进速度
lua_gc(L, LUA_GCSETSTEPMUL, 200); // 步进速度 = 200(默认100)
// 200意味着每分配100字节内存,GC执行200字节的工作
// 手动执行GC步进
while (lua_gc(L, LUA_GCSTEP, 0)) {
// 继续执行直到步进完成
// 返回值:0表示步进完成,非0表示还需要更多工作
}
// 获取当前内存使用
size_t used = lua_gc(L, LUA_GCCOUNT, 0) * 1024 + lua_gc(L, LUA_GCCOUNTB, 0);
// 在关键路径前暂停GC
lua_gc(L, LUA_GCSTOP, 0);
// ... 执行性能关键代码 ...
lua_gc(L, LUA_GCRESTART, 0);
最佳实践:
- 在帧率敏感的游戏主循环中,使用
LUA_GCSTEP控制每帧GC时间不超过1ms - 在内存敏感场景,设置较小的stepmul避免内存峰值
- 使用
collectgarbage("setpause")控制GC启动延迟
5. GCObject链表的头指针在global_State的哪个字段?
在 global_State 的 allgc 字段:
// lstate.h
typedef struct global_State {
GCObject *allgc; // 所有GC对象的链表头
GCObject *finobj; // 待终结对象链表
// ...
} global_State;
所有GC对象通过 next 字段(CommonHeader中)连接成单链表,头指针就是 g->allgc。
遍历所有GC对象:
GCObject *p;
for (p = g->allgc; p != NULL; p = p->next) {
// 处理每个GC对象
}
finobj 存放有 __gc 元方法的对象,在GC的finalize阶段处理。

浙公网安备 33010602011771号