Lua入门教程:垃圾回收

Lua 使用的是自动内存管理,所以我们不需要自己手动取删除创建后的对象,Lua 通过垃圾回收(garbage collection)的方式自动删除成为来及的对象,从而将程序员从内存管理的负担中解放出来。

虽然在理想的环境中,垃圾回收对我们来说是不可见的,但其却不是万能的,比如某些关键的性能点,我们可能需要停止垃圾回收,或者让它只在特定的时间点运行,这就需要额外的垃圾回收方式来辅助。

Lua 采用了弱引用表(weak table)、析构器(finalizer)和函数 collectgarbage 的机制来作为辅助垃圾回收。

弱引用表

所谓弱引用(weak reference)是一种不在垃圾回收考虑范围内的对象引用。弱引用表允许收集 Lua 中还可以被程序访问的对象,它告知 Lua 语言一个引用不应该阻止对一个对象的回收机制。

一个典型的内存泄漏的场景是如果我们在数组中存储一些活跃的对象,我们只需要把对象插入到数组中去即可,一旦对象成为了数组中一部分,在数组销毁之前,该对象是永远无法回收的,虽然数组中的对象可能没有任何其他地方引用它,但其仍然被数组引用,除非我们告诉 Lua 数组中的引用不应该阻止此对象的回收。

表由键和值组成,键和值都是强引用,垃圾回收默认不会回收一个可访问的作为表中键和值的对象。而在一个弱引用表中,键和值都可以是弱引用,一旦某个键或值被回收,那么其对应的整个键值对也会被回收。

一个表是否为弱引用表由其元表中的 __mode 字段决定,其值应该是个字符串:

  • 当字符串是 “k” 时,这个表的键是弱引用。
  • 当字符串是 “v” 时,这个表的值时弱引用。
  • 当字符串是 “kv” 时,表中的键和值都是弱引用。
1
2
3
4
5
6
7
8
9
10
11
12
t = {}
setmetatable(t, {__mode = "k"})

k = {}
t[k] = 1

k = {} -- 上一个 k 对象引用被清除
t[k] = 2

collectgarbage() -- 强制垃圾回收

for _, v in pairs(t) do print(v) end --> 2

上面例子中,第二个 k = {} 覆盖了指向了第一个键的索引,调用 collectgarbage 强制垃圾回收器进行一次完整的垃圾回收,由于第一个键没有了其他引用,所以 Lua 会回收该键对象,并从表中删除对应的元素。

最后,请注意,只有对象可以从弱表中移除,而像数字和布尔这样的“值”是不可回收的。

析构器

析构器(Finalizer)是一个与对象关联的函数,当该对象即将被会收时该函数会被调用。Lua语言通过元方法 __gc 实现析构器:

1
2
3
4
o = {x = "hello"}
setmetatable(o, {__gc = function(o) print(o.x) end})
o = nil
collectgarbage() --> hi

上例中,对象 o 创建了一个带有 __gc 元方法的元表,然后清理对象 o 的引用,调用 collectgarbage 函数强制执行一次完整的垃圾回收,对象 o 被回收时,会调用表的析构器,也就是元方法 __gc

逆序调用

当垃圾回收期在同一个周期析构多个对象时,它会按照对象被标记为需要析构处理的顺序逆序调用这些对象的析构器:

1
2
3
4
5
6
7
8
9
10
11
12
mt = {__gc = function(o) print(o.x) end}

obj = nil
for i = 1, 3 do
obj = setmetatable({x = i}, mt)
end
obj = nil

collectgarbage()
--> 3
--> 2
--> 1

上面代码,创建了三个对象,分别设置了析构器,运行垃圾回收后,可以看到第一个被析构的对象是最后被标记的对象。

复苏

当一个析构器被调用时,它的参数是正在被析构的对象。因此,这个对象会至少在析构期间重新变成活跃状态,我们称之为临时复苏。而在析构期间,我们无法阻止该对象被存储在某个全局变量中,使得该对象在析构器返回后任然可访问,我们称之为永久复苏

1
2
3
4
5
a = {x = "hello"}
b = {f = a}
setmetatable(b, {__gc = function(o) print(o.f.x) end})
a, b = nil
collectgarbage()

上面代码中,对象 b 的析构会访问 a,所以在 a 在 b 析构前都不能被回收,Lua 会在运行析构器之前同时复苏 b 和 a。

需要注意的是,Lua 会在两个阶段回收具有析构器的对象:

  • 第一个阶段为发现不可达的带有析构器的对象,并调用析构器开始执行,执行完成后标记为已被析构
  • 第二个阶段的垃圾回收又发现该对象不可达时,它就会将该对象删除。

因此,如果我们想保证我们程序所有垃圾都能真正被释放,那么我们必须调用两次 collectgarbage 函数。

垃圾回收器

垃圾回收(Garbage Collector, GC)算法的原理大体可以概括为:遍历系统中的所有对象,看哪些对象没有被引用,没有引用关系的就认为是可以回收的对象,对其进行删除。

对于如何找出没有“引用” 的对象有以下几种主流算法:

  • 引用计数 GC 算法,当对象被引用时,该对象引用计数加一,反之则减一。当引用计数为 0 时则认为该对象没有被引用,可以被回收删除。该算法有点是只需要对引用计数,不需要对每个对象进行扫描遍历,但有个先天的不足就是很难处理循环引用的问题。
  • 标记清楚 GC 算法,每次做GC扫描时,首先扫描并且标记所有对象,被扫描过并且被标记的对象认为时可达的,不能被回收;而没有被标记的对象认为是可回收的。

双色标记-清除算法

Lua 5.0 使用的 GC 算法是双色标记-清除(Tow-Color Mark and Sweep)算法,其算法原理也很简单:

  • 创建新对象,对象的颜色被标记为白色,并加入到对象链表中。
  • 标记阶段,取出对象链表中未扫描的元素,标记为黑色,并遍历该对象关联的其他对象,也标记为黑色。
  • 回收阶段,遍历所有对象,如果是白色,则认为该对象没有被引用,逐个回收;否则加入重新加入到对象链表等待下一轮GC检查。

这种垃圾收集器被称为“stop-the-world”(全局暂停)式的收集器,其垃圾回收过程中是不能被打断的,因为你无法在标记与清除的过程中对新加入的对象进行标记判断,无法确认新对象到底应该是白色还是黑色。假设在 GC 回收阶段,如果把新对象标记为白色,那么该对象会在没有遍历其关联对象的情况下被回收;如果被标记为黑色,那么这个对象本轮并没有被扫描就被认为不能回收。所以,在双色清除算法中,标记阶段和回收阶段必须合并在一起才能完成。

正因为双标记清除算法不能被打断,所以其每次GC操作的代价都非常大。GC 过程中,程序必须停下来,不能做任何其他操作。

三色增量标记清除算法

Lua 5.1 使用了增量式的三色标记清除算法(Thi-Color-Incremental Mark and Sweep),这种算法与前面的相比,每个对象新增加了一种颜色,这样的好处在于:它不必在要求GC阶段停止主程序的运行,这个GC过程是增量的,可以被终端再恢复并继续运行。3中颜色分类如下:

1
2
3
- 白色,表示对象没有被GC标记过,任何对象创建时的默认状态,如果GC扫描结束后任然是白色,则说明该对象没有被引用,可以被回收。
- 灰色,表示对象已经被GC标记过,但该对象关联的其他对象还没有没访问标记过。
- 黑色,表示对象已经被GC标记过,且该对象关联的其他对象也被访问标记过。

引入灰色节点后,算法不再要去一次性完整的执行完毕,而是把已经扫描但是其引用的还未扫描的对象置为灰色。在标记过程中,只要还要元素是灰色,则算法标记过程会一直持续下去,即使中间被打断并去执行其他操作,也不会受影响。

紧急垃圾回收

Lua 5.2 引入了紧急垃圾收集(emergency collection),当内存分配失败时,Lua 会强制进行一次完整的垃圾收集,然后再次尝试重新分配。这种紧急功能可以发生在程序进行内存分配的任意时刻。

控制垃圾回收

Lua 提供了函数 collectgarbage ([opt [, arg]]) 来让我们可以辅助垃圾收集器进行一些额外的控制。该函数第一个参数是一个可选字符串,用来说明执行何种操作,而有些选项需要第二个参数:

  • “collect”: 做一次完整的垃圾收集循环,如果不提供任何参数,这将是默认选项。
  • “isrunning”: 返回表示收集器是否在工作的布尔值。
  • “stop”: 停止垃圾收集器的自动运行,直到再次调用restart前,只能显示的调用垃圾收集器。
  • “restart”: 重启垃圾收集器到自动运行。
  • “count”: 以 KB 字节数为单位返回 Lua 使用的总内存数。其结果为浮点数,所以只需要乘上 1024 就能得到 Lua 使用的准确字节数。
  • “step”: 单步运行垃圾收集器。 步长“大小”由 arg 控制,指定在分配了多少个字节后垃圾回收一个做什么。
  • “setpause”: 将 arg 设为收集器的间歇率。
  • “setstepmul”: 将 arg 设为收集器的 步进倍率。

任何垃圾回收器都是使用CPU时间来换内存空间的。在极端情况下,垃圾收集器可能无法运行。但是,不消耗CPU时间将会消耗大量的内存空间。而在另外一种极端情况下,垃圾回收器可能在一次赋值后就得完成一次完整的垃圾收集。程序如果想使用尽可能小的内存,是要以付出大量的CPU计算量为代价的。

collectgarbage 函数的 setpausesetstepmul 参数可以让我们在CPU与内存消耗之间找一个平衡点,以寻求尽可能大的运行效率。

参数 setpause 用于控制垃圾回收器在一次收集完成后需要等待多久时间再开始新一轮的收集。当其选项的值设为 0 时,表示上一次垃圾回收完成后立刻开始新一轮的收集工作;当选项值设为 200% 时,表示重启垃圾回收器前需要等待内存使用翻倍。如果想消耗更多的 CPU 时间去换取更低的内存消耗,可以把这个值设的小一点。

参数 setstepmul 控制对没分配 1KB 内存时垃圾回收器应该进行多少工作。该选项值越高则垃圾回收器使用的增量越小。其默认值为 200%,当参数值低于 100 % 时会使垃圾回收运行的很慢,以至于可能一次收集都完成不了;而当参数设置为 100000000% 这样巨大的值时,其表现将是一个非增量的垃圾回收器。

对于上面的一些参数来说,其默认值已经足够好了,而对于一些特殊应用来说,手工实验控制则可能更好,比如游戏应用中,某些关键阶段我们必须调用 collectgarbage("stop") 来停止回收,并在关键阶段执行完成后,再调用 collectgarbage("restart") 来重启垃圾收集器。