lua 中的内存管理

GC

基本流程

Garbage Collection
lua 是 oop 语言,拥有 GC 机制
实现方式是基于引用标记的(incremental mark-and-sweep collector.)

默认情况下,所有会使用 lua 内存的 reference,都可以借助 nil 来清除引用标记,
当某个 reference 不再被任何其他 reference 引用时,这个对象就会作为 dead objects 等待释放,
GC 将定期进行收集

GC 收集的周期由 两个参数决定,两个参数都是百分比,比如 100 就代表 1倍:

  • garbage-collector pause:表示GC周期结束过后,等待内存充满到什么程度才再次收集;
  • garbage-collector step multiplier:表示GC收集的速度,并且不能填太慢,否则GC周期永远结束不了;但调太高会影响性能

两个参数都需要通过 collectgarbage 方法去改(在 C语言里是 lua_gc),默认情况下不需要改

方法

lua 内存分配默认情况下是自动的,你需要释放内存就得把 reference(引用) 标记为 nil,然后这个对象就会作为 dead objects 等待释放
相对的,全局变量因为 lua 不确定你什么时候用,所以默认情况下是一直不会释放的

如果不借助内存分析之类的工具,不容易不出效果,需要你自己分析结果

手动GC可以调用 collectgarbage()
其参数是一些状态string:

  • “collect”:默认策略,进行完整 GC
  • “stop”:停止自动的CG,直到调用”restart”
  • “restart”:重启自动GC
  • “count”:返回当前 Lua 占用的总内存(单位是KB,所以可能有小数,要乘以1024)
  • “step”:模拟 GC 步骤,需要追加一个数值参数,表示当 Lua 已经有申请到指定数额(单位KB)内存后才进行GC,返回 true|false 表示GC的收集循环是否结束
  • “setpause”:设置 pause 参数(参考前面介绍)
  • “setstepmul”:设置 mul 参数(参考前面介绍)
  • “isrunning”: 检查 GC 是否在运行中

利用 gc 检查内存

你可以借助 count 参数来粗略地检查内存

collectgarbage("stop") -- 先停止自动GC
local memorySize = collectgarbage("count") -- 记录先前状态
local tb = {}
local newMemorySize = collectgarbage("count") -- 记录新的状态
print((newMemorySize-memorySize) * 1024)

-> 56

比如这里 table 返回是 56 字节,但实际上的大小跟 lua 的版本有关,所以仅供参考
但学习阶段我们可以借助这个机制来进行内存原理的探索

引用类型

和值类型比较

引用类型类似于传统 OOP 语言中的概念,在 lua 里也称为 对象类型(object)
和传统OOP一样,

  • 引用类型只是传递指针
  • 引用类型退出语法域的时候不会立即释放,需要等待GC

可以用前面 GC 的方法测试

collectgarbage("stop")
local gc_count = function()
    return collectgarbage("count")
end
local memory = gc_count()
local tb 
for i=1,10 do
    tb = {} -- 重复申请 10次 table 的内存,会发现最终结果是 10倍内存(例如单个 table 56B 就得到 560B)
    -- tb = 1 -- 而如果全部都只是赋值一个数字,则不会产生 GC
end
print((gc_count() - memory) * 1024)

值得一提的是, string 虽然也可以被 GC 收集,但是比较特殊
table 等对象在创建时,都是明确被指定为 引用类型,并且告知调用者内存地址,
string 在一般情况下是不占 GC 的,只有使用了 .. 这种连接符才会触发 GC 收集,
因此一般都认为 string 是值类型 1

内存占用

table 和 function 的每次创建,都会申请内存,
当引用计数为0时,会被 GC 收集

具体数额如果依赖 collectgarbage() 去分析,是比较困难的
测试时可以借助比较夸张的数据来抵消掉细微的误差,

collectgarbage("stop")
local function gc_count()
    return collectgarbage("count")
end
local function gc_print(num)
    print(string.format("%s B", num*1024))
end
collectgarbage() -- 先收集一次保证没有其他条件干扰

-- 为了追求尽量精确,不能在执行过程中 print,因为 print 本身会产生GC
local memory = gc_count() -- 这个语句本身,或者接收值的时候,疑似会产生 48B 的GC

-- 局部变量在退出语法域以后,会丢失引用,视为 dead object
-- 但它并不像值类型一样立刻被释放
for i=1, 50 do
    local tb = {}
end

-- 外部局部变量,因为在该 chunk 中仍然保持着引用,所以不会被 GC 掉
local outerTable = {}
for i=1, 100 do
    outerTable[i] = {}
end

-- outerTable = nil --可以分两次执行,第二次解开这个注释

-- 尝试计算大致的总内存
local memory2 = gc_count()
gc_print(memory2 - memory)

-- 尝试在收集垃圾后计算总内存
collectgarbage()
local memory3 = gc_count()
gc_print(memory3 - memory)

从结论上来说:

  • table 的创建语句 table={} 会分配新的内存(无论在什么地方、什么作用域)
  • 当退出局部作用域,作为局部变量的 table 引用丢失,会在稍后进行 GC 时作为收集目标
  • 同理,当指向 table 的引用(outerTable)被标记为 nil 使得引用丢失,也会在稍后触发 GC

如果在使用完之后立刻将底层的的引用置为 nil 可以一定程度上减缓 GC 压力(幅度很小)
而 function 的创建也是类似的道理,将示例中的:

local tb = {}

替换为

local fun = function()        
end

跑一下就能看出效果

weak table

为了支持用户自己管理 GC,lua 拥有 weak reference 机制,也就是弱引用,
弱引用和 strong reference 强引用 相对应,

在 强引用 的环境下执行这样的代码

local tb = {}
for i=1,10 do
    tb[{}] = i
end

collectgarbage()
for k, v in pairs(tb) do
    print(v)
end

-> 1,2 ... 10

当你创建若干个 table,他们每个都拥有新的地址,lua 默认的强引用机制会把这些地址视为具体的值,让 table 和这些地址保持引用关系
在例子中创建了许多局部变量的 table,理论上退出作用域以后这些table就“无主了”,
但实际上这些 “无主的” table 目前仍然被 tb 持有,只是我们写逻辑的人认为它丢失了引用,而lua不这么认为,
GC 也不认为这些东西该被释放,所以后续流程里,tb仍然能遍历到这些“入口”

如果用户创建了一套内存管理机制,持有所有 lua 虚拟机中的变量,那么这个全局的超级 table 就时刻保持着对于所有变量的引用,使得 GC 直接停摆,
为了解决这种问题, lua 允许你设置 weak table 属性

weak table 用 metatable 的元方法 __mode 触发,可以参考 原文档
它的值应该是(包含) “k” 或者 “v”,分别表示:

  • “k”:将 table 的 key 设为 weak
  • “v”:将 table 的 value 设为 weak

例如,我们将刚才例子中 table 的 key 设为 weak,表示这个 table 并不想和目标产生正常的 “强引用” 关系:

local tb = {}
setmetatable(tb, {__mode="k"}) -- 新增
for i=1,10 do
    tb[{}] = i
end

collectgarbage()
for k, v in pairs(tb) do
    print(v)
end

-> 

这时,那些本应该因为退出作用域而释放的 局部的table,无视了外层 tb 的引用关系,被 GC 视为可收集的目标触发了回收

同样的,你也可以设置 value,或者 key 和 value 一起

local tb = {}
setmetatable(tb, {__mode="v"})

-- 只有 value 是弱引用,而 key 保持强引用,使得 GC 不会自动释放 
for i=1,10 do
    tb[{}] = i
end

collectgarbage()
for k, v in pairs(tb) do
    print(k, v)
end

如果同时指定 key 和 value 是弱引用,只需要让字符串包含 ‘k’ ‘v’ 两个字符

setmetatable(tb, {__mode="kv"})

内存的连续性

next

TODO

len

len 操作符表达为 #
例如 print(#"123") 就会返回3,表示取字符串的 byte 数量(如果1字节代表1字符,那么就等价于字符串的字符数量)
lua 在判定长度的时候,是用的类似于二分查找的算法,因此对于容器的内容有一定限制

  • 操作时,返回的值实际上是一个 边界(border) 位置,它需要满足:
    • (border == 0 or t[border] ~= nil) and
    • (t[border + 1] == nil or border == math.maxinteger)
  • 也就是:
    • 情况1:边界为0
    • 情况2:边界为table中的某个位置,满足:
      • 当前位置有值
      • 下个位置是 nil

换句话说,这个边界值并不一定是 table 的结尾
如果你进行取长度的容器并不是 连续的容器 —— 序列(sequence)
即容器被一些 nil 值截断,产生了若干 子序列,
这些造成截断后果的 nil 值被称为 洞(hole)

例如:

{nil, 20, 30, nil, nil, 60, nil}
               1
      2                 2

-> 6

如图,当二分让最后三个元素成为子序列,无法继续二分;
而最后一位是 nil,导致提前判定结束
从结果上看:# 处理时会忽略 table 末尾的 nil

官方对于 array size 的建议是,使用 getn 和 setn,但这两个函数已经移除
一般来讲如果出于准确性考虑,可以自己实现一套去维护


  1. https://www.lua.org/pil/17.html These are implementation details. Thus, from the programmer’s point of view, strings are values, not objects. Therefore, like a number or a boolean, a string is not removed from weak tables (unless its associated value is collected). 

Leave A Reply

Your email address will not be published. Required fields are marked *