Lua GC基础
root对象
① 注册表(Registry)中的对象
② 执行栈(Lua_Stack)上的对象 注:严格说来,注册表引用了主线程,执行栈在主线程结构内
全量GC:Lua5.0及以前
Lua5.0及以前的版本使用的是双色标记清除算法(Two-Color Mark and Sweep)。
该算法的原理是:系统中的每个对象非黑即白,也就是要么被引用,要么没有被引用。
具体的伪代码如下:
每个新创建的对象颜色为白色(white) // 初始化阶段 遍历在root链表中的对象,加入到对象链表 // 标记阶段 当对象链表中还有未扫描的元素: 从中取出一个对象,标记为黑色(black) 遍历这个对象关联的其他所有对象: 标记为黑色(black) // 回收阶段 遍历所有对象: 如果为白色(white): 这些对象都是没有被引用的对象,逐个回收 否则: 这些对象时被引用的对象,重新加入对象链表中等待下一轮的GC检查
颜色状态转换图如下:
该算法的问题是:整个GC过程不能被打断,必须暂停程序(Stop the World),一次性扫描并清除完所有对象。
步进式GC(增量模式):Lua5.1
Lua5.1在双色标记清除算法上改进实现了三色增量标记清除算法(Tri-Color Incremental Mark and Sweep)。
该GC算法不再要求一次性标记清除完所有对象,可以被中断去执行业务逻辑,然后再恢复回来继续执行,是一种增量式垃圾收集器(Incremental Collector)。
增加一种颜色状态(灰色),说明如下:
白色(White):表示当前对象为待访问状态,用于表示对象还没有被GC的标记过,这也是任何一个Lua对象在创建之后的初始状态。
换言之,如果一个对象,在一个GC扫描完毕之后,仍然是白色的,那么说明该对象没有被系统中任何一个对象所引用,可以回收其空间了。
灰色(Gray):表示当前对象为带扫描状态,用于表示对象已经被GC访问过,但是被该对象直接引用的其他对象还没有被访问到。
黑色(Black):表示当前对象为已扫描状态,用于表示对象已经被GC访问过,并且被该对象直接引用的其他对象也已经被访问过了。
注:完整地执行一次GC之后,黑色的对象会被重置为白色
白色对象集就是后续会被回收的部分;黑色对象集就是需要保留的部分;灰色对象集是黑色集和白色集的边界。
随着标记过程的继续,通过充分遍历灰色对象,就可以把它们转变为黑色对象,从而扩大黑色集。一旦所有灰色对象消失,标记过程也就完成了。
具体的伪代码如下:
每个新创建的对象颜色为白色(white) // 初始化阶段 遍历在root节点中引用的对象从白色(white)置为灰色(gray),并且放入到灰色节点列表中. // 标记阶段 当灰色链表中还有未扫描的元素: 从中取出一个对象,标记为黑色(black) 遍历这个对象关联的其他所有对象: 如果是白色(white): 标记为灰色(gray),加入灰色链表 // 回收阶段 遍历所有对象: 如果为白色(white): 这些对象都是没有被引用的对象,逐个回收 否则: 重新加入对象链表中等待下一轮的GC检查
颜色状态转换图如下:
可以看到,引入了新的灰色节点的概念之后,算法不再要求一次性完整地执行完毕,而是可以把已经扫描但是其引用的对象还未被扫描的对象置为灰色
在标记阶段中,只要灰色节点集合中还有元素在,那么这个标记过程就会继续下去,即使在中间该阶段被打断转而执行其它的操作了也没有关系
对象引用关系图如下:
双白色
从上面的算法可以看出,没有被引用的对象其颜色在一个扫描过程中始终保持不变为白色。
那么假如一个对象在一个GC过程的标记阶段之后被创建,根据前面对颜色的描述,它应该是白色的,这样在紧跟着的回收阶段,这个对象就会在没有被扫描标记的情况下被认为是没有被引用的对象而删除。
因此,Lua除了前面的三色概念之外,又细分出来一个"双白色"的概念。简单的说,Lua中的白色分为"当前白色(currentwhite)"和"非当前白色(otherwhite)"。
这两种白色的状态交替使用,第N次GC使用的是第一种白色,那么下一次就是另外一种,以此类推...
代码在回收时候会做判断,如果某个对象的白色不是此次GC回收使用的白色状态将不会被认为是没有被引用的对象而回收。
这样的白色对象将留在下一次GC中进行扫描,因为在下一次GC中上一次幸免的白色将成为这次的回收颜色。
不变条件(invariant)
在任何时间点,下列条件始终成立:
① 所有被根集引用的对象要么是黑色,要么是灰色的。
② 黑色的对象不可能指向白色的。
但由于GC标记清除过程不是一次性完成的,GC执行逻辑和虚拟机的正常指令逻辑是交替执行的,不变条件(invariant) 会被虚拟机的正常指令逻辑打破,因此需要在打破时维持不变条件。
例如:原有一个黑色对象 t ,和一个白色对象 {} ,当我们运行 t.x = {} (触发了一个 mutator ) 时,就会让这个黑色对象指向一个白色对象。这时,我们需要在所有这种赋值的地方插入一个 write barrier 检查这种情况,恢复不变条件(invariant) 。
我们有两种方式维持不变条件:一种是把白色对象变为灰色的(forward),另一种是把黑色对象变回 (backward) 灰色。如果参考 Lua 5.1 以后的代码,在 lgc.h 中能找到两个 api 对象这两种操作,luaC_barrier
和 luaC_barrierback
。
什么时候采用什么方法更好是实现的时候凭经验(感觉?)决定的。比方说,给 table 赋值的时候,就直接把被赋值的黑色 table 变回灰色。我猜是因为大部分时候被修改的 table 都不会是黑色,同时不需要检查赋值的量的类型和颜色。
如果一个黑色的 table 变回了灰色,就证明在扫描中途被打断(处于某种不常见的临界状态),就把它单独放在一个独立的链表 (grayagain)里,留待后面原子处理,避免它在黑和灰之间反复折腾。
对于堆栈,则干脆不让它变黑,这样对栈的操作就不需要 barrier ,提高栈写入的性能。
如果是给对象设置一个 metatable ,例如 setmetatable(obj, mt) 这样的,我们可以采用 forward 策略,当 obj 为黑,而 mt 为白色的,将 mt 置灰。
将含有__gc元方法(终结器,finalizer)的死对象进行复活
一遍标记是不够的,不能简单的把死掉的对象简单剔除,那样就无法正确的调用 __gc
,执行终结器(finalizer)清理逻辑了。
所以标记流程需要分两步来做:
第一步:在死对象中找回有 __gc
方法的,对它们再做一次标记复活,这样才能保证这些对象的 __gc
可以正确运行。
第二步:执行完 __gc
的对象最终会在下一轮 gc 中释放(如果没有在 __gc
中复活)。
这些对象还有一个单向标记,标记 __gc
方法是否有运行过,这可以保证 __gc
只会执行一次,即使在 __gc
中复活(重新被根集引用),也不会再次分离出来反复运行 finalizer 。
注1:把带 __gc
方法的对象重新扫描一次复活所有相关对象,保证在 __gc
调用时相关对象都还在,和普通的扫描流程不同,这一步必须原子的单步完成,不可拆解。要注意这个过程导致的GC卡顿。
注2:原子步骤还包括清理需要清理的弱表(弱表中有至少一个白色对象的引用),将弱表中的对象置nil。
增量GC过程
代码详见:
/*** Lua\src\lgc.c ***/ static lu_mem singlestep (lua_State *L) { global_State *g = G(L); lu_mem work; lua_assert(!g->gcstopem); /* collector is not reentrant */ g->gcstopem = 1; /* no emergency collections while collecting */ switch (g->gcstate) { case GCSpause: { PROFILER_ENTERGC(GCSpause) restartcollection(g); g->gcstate = GCSpropagate; work = 1; PROFILER_LEAVEGC(GCSpause) break; } case GCSpropagate: { PROFILER_ENTERGC(GCSpropagate) if (g->gray == NULL) { /* no more gray objects? */ g->gcstate = GCSenteratomic; /* finish propagate phase */ work = 0; } else work = propagatemark(g); /* traverse one gray object */ PROFILER_LEAVEGC(GCSpropagate) break; } case GCSenteratomic: { PROFILER_ENTERGC(GCSenteratomic) work = atomic(L); /* work is what was traversed by 'atomic' */ entersweep(L); g->GCestimate = gettotalbytes(g); /* first estimate */; PROFILER_LEAVEGC(GCSenteratomic) break; } case GCSswpallgc: { /* sweep "regular" objects */ PROFILER_ENTERGC(GCSswpallgc) work = sweepstep(L, g, GCSswpfinobj, &g->finobj); PROFILER_LEAVEGC(GCSswpallgc) break; } case GCSswpfinobj: { /* sweep objects with finalizers */ PROFILER_ENTERGC(GCSswpfinobj) work = sweepstep(L, g, GCSswptobefnz, &g->tobefnz); PROFILER_LEAVEGC(GCSswpfinobj) break; } case GCSswptobefnz: { /* sweep objects to be finalized */ PROFILER_ENTERGC(GCSswptobefnz) work = sweepstep(L, g, GCSswpend, NULL); PROFILER_LEAVEGC(GCSswptobefnz) break; } case GCSswpend: { /* finish sweeps */ PROFILER_ENTERGC(GCSswpend) checkSizes(L, g); g->gcstate = GCScallfin; work = 0; PROFILER_LEAVEGC(GCSswpend) break; } case GCScallfin: { /* call remaining finalizers */ PROFILER_ENTERGC(GCScallfin) if (g->tobefnz && !g->gcemergency) { g->gcstopem = 0; /* ok collections during finalizers */ work = runafewfinalizers(L, GCFINMAX) * GCFINALIZECOST; } else { /* emergency mode or no more finalizers */ g->gcstate = GCSpause; /* finish collection */ work = 0; } PROFILER_LEAVEGC(GCScallfin) break; } default: lua_assert(0); return 0; } g->gcstopem = 0; return work; }
解释如下:
状态 | 分步(step) | 说明 |
---|---|---|
GCSpause(gc开始阶段) | 不分步(原子操作) |
开启新一轮 gc,从root节点遍历并开始标记(mark),table/proto/closure/thread 对象标记为 gray,加入 g->gray 链表,string/userdata 对象标记为黑色。 遍历完成后,切换到GCSpropagate |
GCSpropagate(扫描标记阶段) | 分步 |
每次从 g->gray 链表取出一个对象,先标记为 black。如果对象是弱表或 thread,则加入 g->grayagain 链表,然后遍历这个对象的子项开始 mark,把没有标记的对象都标记了。 直到 g->gray 链表为空,切换到 GCSatoimic |
GCSatoimic(再次扫描标记,并处理finalizer和弱表) | 不分步(原子操作) |
再次从root节点遍历并开始标记(mark),对 GCSpropagate 期间可能的改变再重新标记,将未标记的对象加入 g->gray 链表。 遍历 g->gray 链表取所有对象完成标记。遍历 g->grayagain 链表取所有对象完成标记,遍历弱表,将白色的项置为nil。 遍历 g->finobj 链表,把白色的对象移到 g->tobefnz 链表。 遍历 g->tobefnz 链表,完成标记(mark)。 切换当前白色到另一种白色。 切换到 GCSswpallgc |
GCSswpallgc(清除阶段) | 分步 |
每次取 g->allgc 链表一定数量的对象(最多GCSWEEPMAX个), 将还是上一种白色的对象清理掉。 // #define GCSWEEPMAX 100 同时将黑色对象标记为当前白色。 当 g->allgc遍历完成,切换到 GCSswpfinobj |
GCSswpfinobj(清除阶段) | 分步 |
每次取 g->finobj 链表一定数量的对象(最多GCSWEEPMAX个), 将还是上一种白色的对象清理掉。 // #define GCSWEEPMAX 100 同时将黑色对象标记为当前白色。 当 g->finobj 遍历完成,切换到 GCSswptobefnz |
GCSswptobefnz(清除阶段) | 分步 |
每次取 g->tobefnz 链表一定数量的对象(最多GCSWEEPMAX个), 将还是上一种白色的对象清理掉。 // #define GCSWEEPMAX 100 同时将黑色对象标记为当前白色。 当 g->tobefnz 遍历完成,切换到 GCSswpend |
GCSswpend | 不分步(原子操作) | 收缩全局 string 的hash表,保证hash桶利用率超过 1/4, 切换到 GCScallfin |
GCScallfin(执行finalizer) | 分步 |
每次取 g->tobefnz 链表一定数量的对象(最多GCFINMAX个),执行 __gc元方法,然后将对象标记为白色,加入 g->allgc 链表,等下一次 gc 清理。 // #define GCFINMAX 10 当 g->tobefnz 为空时,切换到 GCSpause |
增量GC过程中的变量说明:
g->allgc: 所有可回收的对象链表。
g->finobj: 带__gc元方法的对象链表。
g->tobefnz: 没有引用的带 __gc元方法的对象链表。
新建对象时,对象会加入 g->allgc 链表,当对象设置 __gc元方法时,这个对象会从 g->allgc 移到 g->finobj 链表,当这个对象不再引用后,从 g->finobj 移到 g->tobefnz,执行 __gc元方法后,对象再移回到 g->allgc,等下一次 gc 清理。
g->gray: 等待遍历的对象链表
g->grayagain: 等待 g->gray 为空后,再遍历的对象链表
g->grayagain 的用途:
1. GCSpropagate阶段的弱表。要扫描完所有对象后,才可以对弱表进行处理。
2. 黑色 table 引用白色对象时。如果又加入 g->gray,会导致 table 又被反复扫描。放入g->grayagain可避免这个问题。
3. 协程。要等协程栈中对象都处理完,才可以对协程进行处理。
从上面过程可以看出, GCSatoimic 最有可能造成卡顿。
1、GCSatoimic 不能分步执行
2、GCSpropagate 分步执行是有副作用的,这段时间, root节点可能会引用新的白色对象,已标记黑色的 table 引用了白色对象,这些数据都需要在 GCSatoimic 重新遍历和标记
3、弱表处理,没有分步执行
所以,以下几种情况 GCSatoimic 的开销会比较大:
1、系统不断产生大量的大table
2、不断有大量的大table引用新数据
3、大量使用弱表
分代GC:Lua5.4
lua5.2引入了分代gc(Generational Collector),属于实验性质,实际效果不好,在5.3的时候又移除了,lua5.4重新设计实现了分代gc并正式发布。
lua 5.4支持两种 gc 方式:增量式 gc 和分代 gc ,默认是增量式,可以在运行时随时动态切换。
切换为增量gc
在c语言中:lua_gc(L, LUA_GCINC, 0, 0, 0);
在lua语言中:collectgarbage("incremental");
切换为分代gc
在c语言中:lua_gc(L, LUA_GCGEN, 0, 0);
在lua语言中:collectgarbage("generational");
分代原理
经验表明,大部分对象在被分配之后很快就被回收掉了(如栈上的临时变量),长时间存活的对象很大可能会一直存活下去。所以,垃圾回收可以集中精力去回收刚刚造出来的对象。
将所有gc对象分成两种,young和old;过程也分为minor(浅扫描,或次级收集周期)和major(深扫描,或主收集周期)。
对象创建时标记为young,minor过程扫描young对象,当young节点活过了两次gc过程,就会标记成old对象 。注:活过两次gc过程也是与lua5.2 gc过程的最大不同点。lua5.2 gc只需要活过一次,就算做old。
minor过程只对young 对象进行遍历清除工作。这样就避免了每次都遍历大量并不活跃却长期存活的old对象,又可以及时清理掉大量生命短暂的young对象。
minor过程越密集,单个过程内的young对象数量就越少,需要做的工作也越少,停顿时间也就缩小了。可见增加minor过程并不会太多的增加整体工作量,却可以更及时的回收内存。
当总的内存增长超过阈值时,会执行major过程,来做一次完整的标记清除(带来严重的GC卡顿),处理全部young对象和old对象,清理垃圾对象后,把所有存活对象都变为old对象。
只不过分代 GC 模式下,major过程(全量 GC)的频率可以降的非常低,因为大量临时内存都通过minor过程清理掉了,内存并不会增长太快。
当遇到必须消除停顿的环境,我们可以手工精确调整:发现内存持续增长,不要主动触发完整的major过程
而是主动切换到增量gc模式,然后周期性的调用 gc step (不等内存分配器来触发)在合理的时间内分步处理完一个完整的 GC 周期,再切换回分代gc模式。
不变条件(invariant)
old对象不会指向young对象。
但是,分步却变得更困难了。当变化发生时,无论是 forward 还是 backward 都有问题:
对于 forward ,也就是把young对象变成old,无疑会制造大量old对象,还需要递归变量,否则就会打破规则。
如果是采用 backward 策略,更很难保持条件成立(对象很难知道谁引用了自己,就无法准确的把old对象变回young)。
所以,需要引入第三态:触碰过的对象(The Touched Objects)。
当 back barrier 需要解决old对象指向young对象的问题时,old对象被标记为触碰过,并放入一个特别的集合。
被触碰的对象在minor过程中也会参与遍历,但是不会被清理。被触碰的对象如果不再被触碰,那么在活过两次后,它会回到old集合。
自动触发gc step
/* ** Does one step of collection when debt becomes positive. ** 'condchangemem' is used only for heavy tests */ #define luaC_condGC(L,pre,pos) \ { if (G(L)->GCdebt > 0) { pre; luaC_step(L); pos;}; \ condchangemem(L,pre,pos); } #define luaC_checkGC(L) luaC_condGC(L,(void)0,(void)0)
在以下代码中,使用 luaC_checkGC 检查 gc 阈值 GCdebt ,当 GCdebt 大于0 时,执行 gc step
1、创建新数据时 string, thread, userdata, table, closure
3、语法解析时
4、错误发生时
5、字符串拼接时 concat
6、栈增长时
注:这里提下GCdebt,字面意思就是 gc 债务,用以调节 gc 是否触发。当 GCdebt 大于0时,触发 gc, 而且,gc 单次的工作量也受这个参数影响。
手动触发gc step
在c语言中:lua_gc(L, LUA_GCSTEP, 0);
在lua语言中:collectgarbage("step");
手动触发全量gc
在c语言中:lua_gc(L, LUA_GCCOLLECT, 0);
在lua语言中:collectgarbage("collect");
GC API用法
标签 | 类型 | 说明 | c语言示例 | lua语言示例 |
#define LUA_GCSTOP 0 | 控制GC行为 | 停止垃圾收集器(如果在执行的话)注:会将gc.threshold设置为一个巨大的值,不再触发gc step操作 | lua_gc(L, LUA_GCSTOP, 0); | collectgarbage("stop"); |
#define LUA_GCRESTART 1 | 控制GC行为 | 重启垃圾收集器(如果已经停止的话) | lua_gc(L, LUA_GCRESTART, 0); | collectgarbage("restart"); |
#define LUA_GCCOLLECT 2 | 控制GC行为 | 发起一次完整的垃圾收集循环 | lua_gc(L, LUA_GCCOLLECT, 0); |
collectgarbage(); // 等同collectgarbage("collect") collectgarbage("collect"); |
#define LUA_GCCOUNT 3 | 获取GC数据 | 返回 Lua 使用的内存总量(以 K 字节为单位) | lua_gc(L, LUA_GCCOUNT, 0); |
print(collectgarbage("count")); // 输出22174.333984375 local integer, decimal = math.modf(collectgarbage("count")); // integer即为该项的值 |
#define LUA_GCCOUNTB 4 | 获取GC状态 | 返回当前内存使用量除以 1024 的余数 | lua_gc(L, LUA_GCCOUNTB, 0); |
print(collectgarbage("count")); // 输出22174.333984375 local integer, decimal = math.modf(collectgarbage("count")); // decimal*1024,然后取整即为该项的值 |
#define LUA_GCSTEP 5 |
控制GC行为 |
发起单步增量垃圾收集。步长“大小”由 arg 控制。 传入 0 或空时,收集器步进(不可分割的)一步。 传入非 0 值, 收集器收集相当于 Lua 分配这些多(K 字节)内存的工作。 如果完成了一个GC周期将返回 true 。 |
lua_gc(L, LUA_GCSTEP, 0);
lua_gc(L, LUA_GCSTEP, 1); |
collectgarbage("step"); collectgarbage("step", 0); // 等同collectgarbage("step") collectgarbage("step", nil); // 等同collectgarbage("step") collectgarbage("step", 1); |
#define LUA_GCSETPAUSE 6 |
用于增量GC |
把 data 设为 垃圾收集器间歇率 (使用百分数为单位,100表示 1),并返回之前设置的值。 垃圾收集器间歇率控制着收集器需要在开启新的循环前要等待多久。 增大这个值会减少收集器的积极性。 当这个值小于或等于100时,收集器执行完一个循环周期之后不会等待,直接进入下一个循环周期。 当这个值为200的时候,就代表当内存达到上一个循环周期结束时的两倍的时候,才进入下一个循环周期。 通过调整gc.threshold的大小,来影响触发下一次gc的时间 |
lua_gc(L, LUA_GCSETPAUSE, 120); | collectgarbage("setpause", 120); |
#define LUA_GCSETSTEPMUL 7 |
用于增量GC |
把 data 设为 垃圾收集器步进倍率(使用百分数为单位,100表示 1),并返回之前设置的值。 垃圾收集器步进倍率控制着收集器运作速度相对于内存分配速度的倍率。 默认值是 200 ,这表示收集器以内存分配的“两倍”速工作。 增大这个值不仅会让收集器更加积极,还会增加每个增量步骤的长度。 不要把这个值设得小于 100 , 那样的话收集器就工作的太慢了以至于永远都干不完一个循环。 如果把步进倍率设为一个非常大的数字 (比整个进程用到的字节数还大 10% ), 收集器的行为就像一个 stop-the-world 收集器。 通过调步进速率的大小,来影响每次gc step的速率 |
lua_gc(L, LUA_GCSETSTEPMUL, 200); | collectgarbage("setstepmul", 200); |
#define LUA_GCISRUNNING 9 | 获取GC状态 | 返回收集器是否在运行(即没有停止) | lua_gc(L, LUA_GCISRUNNING, 0); | collectgarbage("isrunning"); |
#define LUA_GCGEN 10 | 切换成分代GC |
lua5.4版本新增。设置为分代GC。 还支持2个参数,分别为 minormul, majormul,缺省值为 0, 不改变原设定。 minormul 控制 minor 回收的频率,默认值20,没有数值范围限制,建议最大值 200。 minormul 的值越大,gc 触发的频率越低。这个参数同时影响了minor和 major majormul 控制major回收的频率,默认值100,没有数值范围限制,建议最大值 1000。 |
lua_gc(L, LUA_GCGEN, 0, 0); |
collectgarbage("generational"); |
#define LUA_GCINC 11 | 切换成增量GC |
lua5.4版本新增。设置为增量GC。 还支持3个参数,分别为 pause, stepmul, step(作用同上),缺省值为0, 不改变原设定。 |
lua_gc(L, LUA_GCINC, 0, 0, 0); lua_gc(L, LUA_GCINC, 120, 200, 0); |
collectgarbage("incremental"); collectgarbage("incremental",120, 200, 0); |
参考