Lua 内存泄漏检测与定位

系统讲解 Lua 内存泄漏的检测与定位方法:用 collectgarbage 建立内存基线、通过弱引用表与快照对比定位悬挂引用,并逐一剖析全局变量污染、闭包 upvalue 持有、回调未注销、缓存无上限等常见泄漏源的成因与修复手段,助你快速定位线上内存问题。

Lua 内存泄漏(Memory Leak)是指对象在逻辑上已不再需要,却因为仍被某条引用链持有而无法被垃圾回收器回收,导致内存占用随时间单调上升的现象。它与 C 语言中忘记 free 的泄漏形态不同,根源往往不是"忘记释放",而是"引用没断开"——一个残留在全局表里的条目、一个未注销的回调、一个没有上限的缓存,都可能让整个对象图无法回收。

本文从可测量的角度出发,先讲如何建立可靠的内存基线,再讲如何用弱引用表与快照对比把泄漏对象"钓"出来,最后归纳几类高频泄漏源和修复手段。排查前建议先理解 https://plumephp.com/lua-garbage-collection-optimization/,因为"什么会被回收"直接决定了"什么算泄漏"。

内存泄漏在 Lua 中的典型形态

Lua 的 GC 采用可达性分析:只要一个对象从根集合(注册表、全局表 _G、当前调用栈上的局部变量、upvalue)出发可达,它就不会被回收。因此泄漏的本质是多了一条不该存在的可达路径。

常见的三种形态:

形态表现典型原因
全局表膨胀_G 或某个单例表条目数只增不减用全局变量当临时存储、模块级缓存无清理
回调悬挂事件监听器数量持续增长注册了监听但对象销毁时未注销
缓存无上限表条目数与业务量正相关LRU 缺位、弱引用未启用

需要注意:内存占用上涨不等于泄漏。分代 GC 下新生对象堆积、字符串驻留池扩张、预分配的空闲容量都会让 collectgarbage("count") 短期升高。判定泄漏的标准是——手动触发完整回收后,内存基线仍呈阶梯式单调上升。

一个更精确的判据是看回收后存活量的斜率。把每次完整 GC 后的占用画成折线,正常的锯齿波是"升—降—升—降",泄漏则是"降点逐次抬高"。这条基线就是后续所有对比的锚点。

用 collectgarbage 建立内存基线

排查的第一步不是猜,而是量。collectgarbage("count") 返回当前 Lua 占用的内存量(单位 KB,浮点数)。可靠的做法是先 collect 再读,排除待回收垃圾的干扰。

-- 每次测量前强制完整回收,得到"存活对象"的真实占用
local function live_memory()
    collectgarbage("collect")        -- 完整 GC 一轮
    collectgarbage("collect")        -- 再走一轮,处理终结器产生的对象
    return collectgarbage("count")   -- 单位 KB
end

local base = live_memory()
print(string.format("基线: %.1f KB", base))

-- 模拟业务循环
for round = 1, 100 do
    do_work()                        -- 待测的业务函数
    if round % 10 == 0 then
        local now = live_memory()
        print(string.format("第 %d 轮: %.1f KB (增量 %.1f)", round, now, now - base))
    end
end

如果每 10 轮的增量稳定回落(锯齿形波动),说明是正常的工作集;如果增量持续为正且累积,就是泄漏信号。

调用两次 collect 不是冗余:第一次回收普通垃圾,第二次回收那些在终结器(__gc)中被释放或新建的对象,只有两轮跑完,读数才真正稳定。分代模式下则可用 collectgarbage("collect") 强制一次大回收,效果等价。

弱引用表:让引用不阻止回收

弱引用表(weak table)是定位泄漏最有力的武器。当表的 __mode 设为 "v"、"k" 或 "kv" 时,对应的键或值不会被 GC 视为强引用——对象一旦在别处失去强引用,弱表中的条目会自动消失。

利用这一特性,可以给对象"登记"一个弱引用,然后观察它在业务上"应该已经销毁"之后是否还在:

-- 登记所有创建出来的对象,但用弱引用持有,不阻止回收
local tracked = setmetatable({}, {__mode = "v"})

local function make_entity(id)
    local e = {id = id, hp = 100}
    tracked[id] = e          -- 弱引用登记
    return e
end

-- 业务上认为实体已经销毁后:
local leaked = 0
for id, e in pairs(tracked) do
    if e ~= nil then         -- 条目还在,说明对象没被回收
        leaked = leaked + 1
    end
end
print("疑似未回收对象:", leaked)

如果业务代码已经把所有强引用置为 nil 并调用过 collectgarbage("collect"),弱表里却仍有残留条目,就说明还有别处持有着强引用。下一步是找到那条引用链。

__mode 的取值语义需要记牢:

__mode 值含义适用场景
"k"键是弱引用以对象为键的旁路属性表
"v"值是弱引用对象缓存、资源池
"kv"键和值都弱双向登记的引用追踪

弱引用本身依赖元表机制,理解 __mode 只是元方法的一种用法,有助于举一反三。

快照对比法:定位增长对象

弱表能告诉你"有对象没被回收",但不能告诉你"是什么对象"。快照对比(snapshot diff)解决后者:在两个时间点各拍一份对象统计,比较差异。

-- 用 debug 库遍历堆上的所有对象并归类计数(Lua 5.4 / 5.3 支持)
local function snapshot()
    local count = {}
    local function walk(o, depth)
        if depth > 4 then return end          -- 限制深度,避免遍历过深
        local t = type(o)
        count[t] = (count[t] or 0) + 1
        if t == "table" then
            for k, v in pairs(o) do
                walk(k, depth + 1)
                walk(v, depth + 1)
            end
        end
    end
    -- 从注册表遍历可达对象
    walk(debug.getregistry(), 0)
    return count
end

local before = snapshot()
run_suspect_code()
local after = snapshot()

for kind, n in pairs(after) do
    local delta = n - (before[kind] or 0)
    if delta > 0 then
        print(string.format("%s: +%d", kind, delta))
    end
end

深度限制是必要的:真实项目里对象图极深,无限制递归既慢又可能因循环引用而爆炸。把深度压到 3~5 层,足以区分"表在增多"还是"字符串在增多"。

生产环境更常用的是 LuaJIT 的 jit.util + 内存分析器,或直接对进程做堆采样。若运行在 OpenResty 上,则用 collectgarbage("count") 结合 ngx.timer 定时上报,配合分段归因的方法排查。

一个常见误区是只看对象总数。字符串泄漏往往数量增长不明显但单个体积很大(如拼接出的长字符串),此时应改看字节数而非个数:#s 累加求和,才能发现"少量巨型字符串"型泄漏。

高频泄漏源与修复

全局变量与 _G 污染

最常见的一类。Lua 中忘记 local 的赋值会写进全局表,且永不被回收:

-- 错误:用全局表当缓存,只增不减
function cache_user(user)
    temp_cache = temp_cache or {}
    temp_cache[user.id] = user
end

-- 修复:改为局部 + 有上限的结构
local cache = {}
local MAX = 1000
function cache_user(user)
    if next(cache) == nil then cache = {} end
    cache[user.id] = user
    -- 或用 LRU 淘汰
end

排查技巧:在启动时给 _G 设一个 __newindex 元方法,任何全局赋值都打印堆栈,快速揪出意外全局。

setmetatable(_G, {
    __newindex = function(_, k, v)
        print("全局赋值:", k, debug.traceback("", 2))
        rawset(_G, k, v)
    end
})

闭包与 upvalue 持有

闭包会捕获它用到的外部变量(upvalue)。如果闭包活得比预期久,它捕获的对象也跟着活:

-- 错误:闭包捕获了整个 config,导致 config 无法回收
local config = load_big_config()
local function handler(req)
    return process(req, config.name)   -- 只用了一个字段,却持有了整个 config
end

-- 修复:只捕获需要的值
local name = config.name
local function handler(req)
    return process(req, name)
end
config = nil   -- 现在可以回收了

判断某个 upvalue 是否被共享,可以用 debug.getupvalue 逐个检视:

for i = 1, math.huge do
    local name, val = debug.getupvalue(handler, i)
    if not name then break end
    print(i, name, type(val))
end

闭包与 upvalue 的完整机制见 https://plumephp.com/lua-closures-and-upvalues/。

事件监听器与回调未注销

对象销毁时若忘记把注册的回调从事件表里移除,回调持有的对象就永远可达:

local listeners = {}

function on(event, cb)
    listeners[event] = listeners[event] or {}
    table.insert(listeners[event], cb)
end

function off(event, cb)
    local list = listeners[event]
    if not list then return end
    for i = #list, 1, -1 do
        if list[i] == cb then table.remove(list, i) end
    end
end

-- 对象销毁时必须调用 off,否则泄漏
function destroy(obj)
    off("update", obj.on_update)   -- 关键:显式注销
    obj.on_update = nil
end

用弱引用保存监听器是另一种兜底:把 listeners[event] 换成 setmetatable({}, {__mode = "v"}),当监听函数在别处无引用时自动从表中消失。但要注意,弱引用会让"注销"的语义变隐晦,仍需显式 off 作为主路径。

缓存无上限

用普通表当缓存且不淘汰,等价于把数据永久驻留。修复手段是用弱引用表让 GC 自动清理,或实现带容量上限的 LRU:

-- 方案 A:弱值表,对象在别处无引用时自动清理
local cache = setmetatable({}, {__mode = "v"})

-- 方案 B:容量受限的 LRU(保留最近 N 个)
local LRU = {}
LRU.__index = LRU
function LRU.new(max)
    return setmetatable({max = max, n = 0, map = {}}, LRU)
end
function LRU:get(k)
    local node = self.map[k]
    if not node then return nil end
    -- 移动到队首(此处省略链表细节)
    return node.value
end

表的内存布局优化(数组部分与哈希部分的选择)对缓存场景影响很大:数组部分连续存储,哈希部分用散列,条目数多但键稀疏时哈希部分的浪费尤其明显。

生产环境监控

线上服务不可能靠手工排查,需要把内存观测常态化:

-- 定时采样,超过阈值时告警并 dump 关键表的规模
local function monitor()
    local now = collectgarbage("count")
    if now > MEM_THRESHOLD_KB then
        log_warn("内存超阈值: %.1f KB", now)
        for name, t in pairs(WATCHED_TABLES) do
            local n = 0
            for _ in pairs(t) do n = n + 1 end
            log_warn("表 %s 条目数: %d", name, n)
        end
    end
end

监控要点:

  • 采样前先 collectgarbage("collect"),只看存活对象,避免被垃圾抖动误导;
  • 关注趋势而非瞬时值,用滑动窗口判断斜率;
  • 记录 collectgarbage("count") 与进程 RSS 两条曲线,前者涨后者不涨说明是 Lua 堆问题,两者同涨可能是 C 层分配。

两条曲线的对照能快速区分泄漏的"楼层"。Lua 堆涨、RSS 不涨,通常是逻辑层的引用问题;RSS 涨而 Lua 堆平稳,则几乎可以断定是 C 侧或 FFI 侧的手工内存未释放。

常见问题(FAQ)

手动调了 collectgarbage(“collect”) 内存还是降不下来,为什么?

说明这些内存对应的是仍可达的对象,不是待回收垃圾。请检查是否有全局表、模块级缓存或未注销的回调持有它们。collect 只能回收不可达对象,无法断开仍然存在的引用。

弱引用表里的条目什么时候会消失?

在下一次 GC 周期中,当键或值对象在弱表之外不再有强引用时,条目会被清除。注意:弱表条目的清除是异步的,collectgarbage("collect") 后立即读取通常能看到结果,但不能假设它同步发生在赋值的那一刻。

LuaJIT 的内存泄漏和标准 Lua 一样排查吗?

思路一致,但工具不同。LuaJIT 的 GC 是分代 + 增量混合,collectgarbage("count") 依然可用;堆分析则依赖 jit.util 或 luajit -jdump。另外 LuaJIT 的 JIT 编译产物(trace)会占用额外内存,trace 爆炸也会表现为"内存只涨不降",需要单独区分。

FFI 分配的内存会被 Lua GC 回收吗?

不会。通过 ffi.C.malloc 分配的裸指针必须手工 free;ffi.new 创建的 cdata 对象由 Lua GC 管理,但其中若嵌套了手工分配的指针则不受管。这类泄漏在 Lua 侧完全不可见,只有进程 RSS 会上涨,排查时要用 valgrind 或 massif。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 时间日期处理与时区
  2. Lua 在嵌入式与 IoT 中的开发实践
  3. Lua 与 WebAssembly 互操作