17.垃圾回收

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 使用“白—灰—黑”三色机制来追踪对象状态:

  1. 白色(White)

    • 含义:尚未被标记为“可达”,也就是尚未检查的对象。
    • 初始状态:所有堆上分配的对象在一轮 GC 开始时都先标记为白色。
    • 最终命运:如果在标记阶段结束时仍保持白色,则被认为不可达,在清除阶段会被回收。
  2. 灰色(Gray)

    • 含义:已知为可达,需要进一步“扫”它所引用的子对象。
    • 转变时机
      1. GC 从根集合(如全局表、Lua 栈、注册表)扫描,并将每个可达的白色对象标记为灰色。
      2. 在后续的小步增量标记中,每当一个灰色对象被“处理”时,它本身会变为黑色,同时将它引用的所有白色子对象标记为灰色。
  3. 黑色(Black)

    • 含义:不仅自身已被确认可达,且其所有直接引用的子对象都已至少被标记为灰色(即排除了它指向任何白色对象)。
    • 转变时机:当一个灰色对象在某次标记子步中被“扫描”后,它被涂成黑色。
状态流转示例
  1. 标记(Mark)阶段
    1. GC 开始

      • 所有对象:白色。
      • 根集合引用的对象 → 标记为灰色。
    2. 处理灰色队列(多次小步):

      • 取出一个灰色对象 A:
        • 扫描 A 引用的每个子对象 B:
          • 若 B 是白色 → 标记为灰色。
        • 将 A 本身标记为黑色。
    3. 标记阶段结束

      • 灰色队列清空,剩余的灰色对象(理论上无)全部变黑。
      • 白色对象即为不可达对象。
  2. 清除(Sweep)阶段
    • 扫描所有对象:
      • 白色 → 释放内存;
      • 黑色 → 重置为白色,为下一轮 GC 做准备。
  3. 分步执行
    • 将标记与清除拆成若干小步,与普通程序执行交替,避免长时间停顿;
  4. 原子阶段(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 依旧会回收那个结果表

要点

  1. 缓存表不阻止结果被回收

    • 因为 cache 的 value 是弱引用,GC 看到一个“只在弱表里”的结果对象时,就会回收它。
  2. 避免内存无限增长

    • 普通缓存如果不手动清理,可能导致内存按用过的 key 越积越多;弱引用表则自动让不再需要的缓存对象释放掉。
  3. 对 key(obj)的引用依然是强的

    • 只有 value 是弱引用,所以只要 obj 本身还在用,cache[obj] 这个键还存在;但对应的值可能是 nil,这样下次调用就会重新计算。
使用弱表打破循环引用
  1. 循环引用问题

    当两个或多个对象互相以强引用方式引用对方时,就形成了循环引用。即使程序中不再有“外部”引用,GC 也会认为它们互相“还活着”,从而永远不会回收,造成内存泄漏。

    local A = {}
    local B = {}
    A.other = B
    B.other = A
    -- 即使之后 A、B 都不被其他变量引用,GC 也回收不了它们
    
  2. 弱表打破循环

    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 “回收我之前,请先执行这里的清理工作”。

工作流程
  1. 创建 userdata 并设置元表

    -- 1) newproxy(true) 创建一个带元表的 userdata
    local u = newproxy(true)
    
    -- 2) 定义含 __gc 的元方法
    local mt = {
      __gc = function(self)
        -- 释放外部资源的逻辑
      end
    }
    
    -- 3) 把元方法关联到 userdata
    debug.setmetatable(u, mt)
    
  2. 触发垃圾回收
    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)很类似,都是“对象要被销毁前自动调用清理逻辑”。但也有几个关键区别:

  1. 非确定性调用时机

    • 析构函数通常在对象生命周期结束、明确 delete 或超出作用域时立即执行。
    • Lua 的 __gc 终结器在下一次垃圾回收周期执行时才会被调用,不保证立即
  2. 只针对 userdata

    • 大多数语言的析构函数作用于所有对象。
    • Lua 的终结器机制只对 userdata 生效,用来释放 C 侧资源;Lua 表、函数等纯 Lua 对象没有 __gc
  3. 可复活(Resurrection)

    • 在终结器中,如果你给自己重建了一个强引用,Lua 会取消对它的回收——它“复活”了。
    • 这在传统析构函数中较少见(大多数析构函数执行后对象就彻底销毁)。
  4. 一次性调用

    • 每个 userdata__gc 终结器只会执行一次,和大多数语言析构函数也是一次的性质一致。
  5. 作用范围

    • 析构函数可以往往结合语言的类型系统,在对象销毁前自动调用。
    • 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

×

喜欢就点赞,疼爱就打赏