AIGC标识 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

思考题

  1. 为什么增量GC需要写屏障?全量GC需要吗?
  2. 分代GC的"代间引用"如何处理?
  3. weak表中的值是nil时会发生什么?
  4. 如何用lua_gc API控制GC暂停时间?
  5. 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_Stateallgc 字段:

// 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阶段处理。

posted @ 2026-09-04 17:26  IcarusLee  阅读(3)  评论(0)    收藏  举报