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。
相关阅读
- https://plumephp.com/lua-profiling-debugging-toolchain/
- 客户端内存泄漏检测思路
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。