17.垃圾回收
17.1 知识点
垃圾回收机制详解
Lua 从 5.1 版本起就采用了增量式标记-清除(incremental mark-and-sweep)的垃圾回收策略,并在 5.4 版本中引入了分代式回收(generational GC)以进一步优化短命对象的回收效率。下面分几点来说明其工作原理及可调参数。
垃圾回收的基本概念
- 自动内存管理:Lua 会自动跟踪所有在 Lua 空间中创建的对象(字符串、表、函数、userdata、线程等),不再由程序员手动
malloc
/free
; - “可达性”决定存活:只要一个对象能从全局变量、栈或 C 变量链路追溯到,它就被认为是“可达”(alive);否则就成为“垃圾”(dead)([lua.org][1])。
增量式三色标记-清除(Incremental Mark-and-Sweep)
三色标记状态的含义
在增量式标记阶段,Lua 使用“白—灰—黑”三色机制来追踪对象状态:
白色(White)
- 含义:尚未被标记为“可达”,也就是尚未检查的对象。
- 初始状态:所有堆上分配的对象在一轮 GC 开始时都先标记为白色。
- 最终命运:如果在标记阶段结束时仍保持白色,则被认为不可达,在清除阶段会被回收。
灰色(Gray)
- 含义:已知为可达,需要进一步“扫”它所引用的子对象。
- 转变时机:
- GC 从根集合(如全局表、Lua 栈、注册表)扫描,并将每个可达的白色对象标记为灰色。
- 在后续的小步增量标记中,每当一个灰色对象被“处理”时,它本身会变为黑色,同时将它引用的所有白色子对象标记为灰色。
黑色(Black)
- 含义:不仅自身已被确认可达,且其所有直接引用的子对象都已至少被标记为灰色(即排除了它指向任何白色对象)。
- 转变时机:当一个灰色对象在某次标记子步中被“扫描”后,它被涂成黑色。
状态流转示例
- 标记(Mark)阶段
GC 开始
- 所有对象:白色。
- 根集合引用的对象 → 标记为灰色。
处理灰色队列(多次小步):
- 取出一个灰色对象 A:
- 扫描 A 引用的每个子对象 B:
- 若 B 是白色 → 标记为灰色。
- 将 A 本身标记为黑色。
- 扫描 A 引用的每个子对象 B:
- 取出一个灰色对象 A:
标记阶段结束
- 灰色队列清空,剩余的灰色对象(理论上无)全部变黑。
- 白色对象即为不可达对象。
- 清除(Sweep)阶段
- 扫描所有对象:
- 白色 → 释放内存;
- 黑色 → 重置为白色,为下一轮 GC 做准备。
- 扫描所有对象:
- 分步执行
- 将标记与清除拆成若干小步,与普通程序执行交替,避免长时间停顿;
- 原子阶段(Atomic)
- 当内存达到一定阈值时,执行一次完整、不可中断的标记-清除,以保证无遗留死对象。
分代式垃圾回收(5.4+)
Lua 5.4 在上述基础上引入了两代区:
新生代(Young)
- 存放刚创建的对象;
- 回收频率高,快速回收短命对象。
老生代(Old)
- 存放多次新生代回收后仍存活的对象;
- 回收频率低,减少对长寿对象的重复扫描。
晋升策略
- 在多次 Minor GC 后仍存活的新生代对象会被晋升到老生代;
- Major GC(全堆回收)同时扫描新生代与老生代。
垃圾回收机制其它特性
弱引用表与缓存、循环引用
什么是弱引用表
在 Lua 中,普通的表(table
)对键(key)和值(value)都会保持“强引用”——只要表还存在,对应的键、值就不会被垃圾回收器(GC)回收。
如果我们希望“让表不阻止某些对象被回收”,就要用“弱引用表”。
local t = setmetatable({}, { __mode = "v" })
__mode = "v"
:告诉 Lua “这个表的值(value)是弱引用”。- 如果把一个对象放到这个表的 value 里,且它在程序其他地方没有任何强引用了,那么当 GC 运行时,这个对象就会被回收,表中对应的 value 就会变成
nil
。
同理,
__mode = "k"
会让键变成弱引用,__mode = "kv"
(或"vk"
)则是键、值都弱引用。
缓存示例详解
-- 创建一个值为弱引用的缓存表
local cache = setmetatable({}, { __mode = "v" })
function expensiveComputation(obj)
-- 假设这是一个昂贵计算,返回一个结果表
return { /* ... 复杂数据 ... */ }
end
function getCachedResult(obj)
-- 1. 先从 cache[obj] 取:如果有缓存且尚未被回收,就直接用
local result = cache[obj]
if not result then
-- 2. 否则重新计算,并把结果放入 cache
result = expensiveComputation(obj)
cache[obj] = result
end
return result
end
-- 使用示例
local myObject = {}
local r1 = getCachedResult(myObject) -- 计算并缓存
local r2 = getCachedResult(myObject) -- 直接从 cache 取,不再重复计算
-- 后续如果程序中再也没有其他变量引用 r1/r2(即只有 cache 持有),GC 依旧会回收那个结果表
要点
缓存表不阻止结果被回收
- 因为
cache
的 value 是弱引用,GC 看到一个“只在弱表里”的结果对象时,就会回收它。
- 因为
避免内存无限增长
- 普通缓存如果不手动清理,可能导致内存按用过的 key 越积越多;弱引用表则自动让不再需要的缓存对象释放掉。
对 key(
obj
)的引用依然是强的- 只有 value 是弱引用,所以只要
obj
本身还在用,cache[obj]
这个键还存在;但对应的值可能是nil
,这样下次调用就会重新计算。
- 只有 value 是弱引用,所以只要
使用弱表打破循环引用
循环引用问题
当两个或多个对象互相以强引用方式引用对方时,就形成了循环引用。即使程序中不再有“外部”引用,GC 也会认为它们互相“还活着”,从而永远不会回收,造成内存泄漏。
local A = {} local B = {} A.other = B B.other = A -- 即使之后 A、B 都不被其他变量引用,GC 也回收不了它们
弱表打破循环
local A = {} -- 创建一个“值为弱引用”的表 weakTable local weakTable = setmetatable({}, { __mode = "v" }) local B = {} -- 不直接用 A.other = B,而是把 B 放入弱表 weakTable[1] = B -- 通过弱表来访问 B A.other = weakTable[1] -- B 仍然可以引用 A(假设这里是强引用) B.other = A -- B 如果只被 weakTable 弱引用,外部强引用消失后即可被回收,打破了 A ↔ B 的循环引用 -- 此时: -- 1. 如果程序中没有其他对 B 的强引用,只有 weakTable[1](弱引用)和 A.other(强引用) -- 2. 那么 B 会因为 A.other 的存在保持“活着” -- 3. 但如果改成只通过弱表来访问 B(即不再有 A.other 的强引用),当外部没有任何强引用时,B 就会被回收
关键
- 弱表存放那个环中的一端,让 GC 能看到“唯一剩下的引用”是弱的,这样就能回收。
- 真正使用时,通常会让某一方只保留弱引用,而另一方保留强引用。这样当“外部”强引用消失后,剩下的那一端因为只有弱引用,也能被正确回收,断开循环。
弱引用表小结
- 弱引用表:通过
__mode = "k"
/"v"
/"kv"
控制键或值是否为弱引用,让 GC 在对象仅被弱表引用时也能回收它。 - 缓存场景:常用
__mode = "v"
,让缓存不阻止结果对象被 GC,自动释放不再需要的缓存数据。 - 打破循环:在互相引用的对象环中,引入弱表来存放其中一端,保证在外部强引用消失后,环内对象能够被 GC 正确回收。
终结器(Finalizers)
什么是终结器
终结器(Finalizer)是垃圾回收器在回收 userdata
时,允许你插入自定义清理逻辑的一种机制。具体来说,当一个 userdata
对象被判定为“不可达”即将被回收时,如果它的元表中定义了 __gc
元方法,这个方法就会被调用一次,让你有机会释放或清理它所持有的外部资源(比如文件句柄、网络连接、C 语言分配的内存等)。
为什么需要终结器
Lua 的垃圾回收只会释放 Lua 堆上的内存,对于 userdata
(通常是 C/C++ 侧分配的内存或资源),GC 本身并不知晓如何正确释放它们。加入终结器后,你可以在 Lua 层面告诉 GC “回收我之前,请先执行这里的清理工作”。
工作流程
创建 userdata 并设置元表
-- 1) newproxy(true) 创建一个带元表的 userdata local u = newproxy(true) -- 2) 定义含 __gc 的元方法 local mt = { __gc = function(self) -- 释放外部资源的逻辑 end } -- 3) 把元方法关联到 userdata debug.setmetatable(u, mt)
触发垃圾回收
当u
没有任何强引用时(例如所有引用都离开了作用域或显式赋nil
),下一次 GC 识别到它是“白色”且不可达,就会:- 先将它加入“终结队列”(finalization queue),而不是立即释放;
- 在标记-清除过程结束后,按照队列顺序调用每个对象的
__gc
方法; - 最后才真正释放
userdata
占用的内存。
注意事项
- 执行时机:所有终结器会在本轮 GC 结束后统一执行,不保证在某一行代码后马上执行;
- 一次性:每个
userdata
的__gc
方法只会被调用一次; - 引用恢复:在终结器中,如果你给自己重新创建了某个强引用(例如把自己赋值到一个全局表),那么 GC 会检测到它“又活过来了”,不再回收——这可以用来实现某些延迟销毁或对象池逻辑;
- 避免循环:终结器自身又引用其它对象时,也可能导致循环引用,所以通常只在真正需要释放外部资源时才使用。
终结器示例场景
假设你在 C 模块中创建了一个文件句柄 FILE*
并把它包装成一个 Lua userdata
,在 Lua 层就可以这样写终结器来保证文件最终被 fclose()
:
-- 假设 c_open 返回一个 userdata 包含 FILE* 指针
local u = c_open("data.txt", "w")
-- 设置元表让 __gc 调用 fclose
local mt = {
__gc = function(self)
c_close(self) -- C 函数,内部调用 fclose
end
}
debug.setmetatable(u, mt)
-- 使用 u 后不用显式调用 c_close,离开作用域后 GC 会自动触发终结器
这样,无论你有没有显式调用 c_close(u)
,在 Lua 对象不再被引用时,GC 都会自动帮你把文件关闭,不留资源泄漏。
终结器和析构函数的区别
Lua 的终结器在用法上和面向对象语言里的析构函数(destructor)很类似,都是“对象要被销毁前自动调用清理逻辑”。但也有几个关键区别:
非确定性调用时机
- 析构函数通常在对象生命周期结束、明确
delete
或超出作用域时立即执行。 - Lua 的
__gc
终结器在下一次垃圾回收周期执行时才会被调用,不保证立即。
- 析构函数通常在对象生命周期结束、明确
只针对 userdata
- 大多数语言的析构函数作用于所有对象。
- Lua 的终结器机制只对
userdata
生效,用来释放 C 侧资源;Lua 表、函数等纯 Lua 对象没有__gc
。
可复活(Resurrection)
- 在终结器中,如果你给自己重建了一个强引用,Lua 会取消对它的回收——它“复活”了。
- 这在传统析构函数中较少见(大多数析构函数执行后对象就彻底销毁)。
一次性调用
- 每个
userdata
的__gc
终结器只会执行一次,和大多数语言析构函数也是一次的性质一致。
- 每个
作用范围
- 析构函数可以往往结合语言的类型系统,在对象销毁前自动调用。
- Lua
__gc
仅在 GC 清扫userdata
时触发,不影响表、函数等。
垃圾回收方法
- 垃圾回收相关方法
collectgarbage("命令")
通过传入不同命令进行不同操作
collectgarbage("count")
获取当前lua占用的内存数 以KB为单位 用返回的内存数*1024可以得到具体的内存占用字节数
print(collectgarbage("count")) -- 20.8671875
collectgarbage("collect")
进行垃圾回收 理解有点像C#的 GC
collectgarbage("collect")
print(collectgarbage("count"))-- 19.771484375 少占用了一些内存
其他垃圾回收方法
-- 运行一次完整 GC
collectgarbage("collect")
-- 查询当前已用内存(KB)
local used = collectgarbage("count")
-- 设置 pause(默认 200),控制下一次完整 GC 的触发阈值
collectgarbage("setpause", 200)
-- 设置 stepmul(默认 200),控制增量步长大小
collectgarbage("setstepmul", 200)
-- 执行一次增量回收步(参数越大,本步工作量越大)
collectgarbage("step", 100)
-- 停止或重启垃圾回收
collectgarbage("stop")
collectgarbage("restart")
- pause:内存使用量增至上次回收时的 pause%(默认 200%)才触发下一轮完整 GC。
- stepmul:增量 GC 的速度为分配速度的 stepmul 倍(默认 200)。
通过合理调整这两个参数,可在内存占用和停顿时间间取得平衡。
简单感受垃圾回收
- 把lua对象置空 内存占用变小
- lua中的垃圾回收机制和C#中垃圾回收机制很类似 切断引用就会变成垃圾
test = {id = 1, name = "123123123123123123",age =10,}
print(collectgarbage("count")) --19.9560546875 多占用了些内存
test = nil
collectgarbage("collect")
print(collectgarbage("count"))-- 19.771484375 回收了{id = 1, name = "123123123123123123",age =10,} 少占用了一些内存
什么时候手动垃圾回收?
- lua中有自动定时进行垃圾回收的方法
- 但是在Unity中热更新开发 尽量不要去用自动垃圾回收 以免产生性能问题
- 建议过场景或某些情况下手动管理垃圾回收
总结
- Lua 垃圾回收从 5.1 的增量式三色标记-清除演进到 5.4 的分代式回收,既兼顾低停顿,又优化短命对象的处理。
- 三色标记机制将对象分为白、灰、黑三种状态:
- 白色:未检查、潜在垃圾;
- 灰色:已可达、待扫描其子引用;
- 黑色:自身及子对象均已扫描、安全保留。
- 分代回收将“新生代”对象高频回收、“老生代”对象低频处理,通过晋升策略减少对长寿对象的重复扫描。
- 弱引用表(
__mode="k"|"v"|"kv"
)让缓存或循环引用的一端不阻止 GC,自动释放不再需要的对象,避免内存泄漏。 - 终结器(
__gc
元方法)为userdata
提供析构式清理入口,在对象回收前执行自定义逻辑,确保外部资源(文件、网络、C 内存等)得到正确释放。 collectgarbage()
系列接口允许:- 统计内存(
"count"
)、触发完整回收("collect"
); - 调节回收阈值(
"setpause"
)与步长("setstepmul"
); - 手动步进(
"step"
)、暂停/重启 GC("stop"
/"restart"
)。
- 统计内存(
- 在性能敏感场景(如游戏关键帧或热更新逻辑)建议结合手动触发以控制停顿。
以上机制协同作用,使 Lua 能在保持简洁“零管理”语义的同时,为高实时性应用提供可控、高效的内存回收支持。
17.2 知识点代码
print("**********垃圾回收************")
print("**********知识点一 垃圾回收方法************")
--垃圾回收相关方法 collectgarbage("命令") 通过传入不同命令进行不同操作
--collectgarbage("count") 获取当前lua占用的内存数 以KB为单位 用返回的内存数*1024可以得到具体的内存占用字节数
print(collectgarbage("count")) -- 20.8671875
--collectgarbage("collect") 进行垃圾回收 理解有点像C#的 GC
collectgarbage("collect")
print(collectgarbage("count")) -- 19.771484375 少占用了一些内存
print("**********知识点二 垃圾回收机制************")
--lua中的垃圾回收机制和C#中垃圾回收机制很类似 切断引用就会变成垃圾
test = { id = 1, name = "123123123123123123", age = 10, }
print(collectgarbage("count")) --19.9560546875 多占用了些内存
test = nil
collectgarbage("collect")
print(collectgarbage("count")) -- 19.771484375 回收了{id = 1, name = "123123123123123123",age =10,} 少占用了一些内存
print("**********知识点三 自动垃圾回收************")
--lua中有自动定时进行垃圾回收的方法
--但是在Unity中热更新开发 尽量不要去用自动垃圾回收 以免产生性能问题
--建议过场景或某些情况下手动管理垃圾回收
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com