Lua 表的内存布局与优化实践

深入 Lua table 的内部内存模型:数组部分与哈希部分的分区存储规则、rehash 扩容与收缩机制、紧凑表的边界效应、内存占用估算方法,以及如何从内存布局层面减少碎片与 GC 压力的工程实践。

table 的两段式内存模型

很多语言里「数组」和「字典」是两种数据结构,而 Lua 只有一种:table。之所以一个类型能同时胜任两者,是因为每个 table 在内存里其实是一个「联合体」——同时包含一块连续的数组(array part)和一张散列表(hash part),由运行时按需分配、按需切换。理解这种两段式布局,是优化 Lua 内存的起点。

-- 以下四个 table 的内存形态完全不同
local a = {1, 2, 3, 4, 5}              -- 几乎全部落在数组部分
local b = {x = 1, y = 2}               -- 全部落在哈希部分
local c = {[1] = 10, [3] = 30}         -- 数组部分 + 哈希部分各占一部分
local d = {1, 2, 3, name = "plume"}    -- 混用:连续数字进数组,字符串进哈希

PUC Lua 从 5.0 起就采用这种布局,LuaJIT 也沿用了同一思想。Table 结构体里,数组部分是一段连续的 TValue(16 字节,64 位平台),哈希部分是一个 Node 数组(每个节点约 32 字节),外加表头、元表指针等固定开销。关于 TValue 的具体大小,可对照 Lua 与 C 语言结合 中对 lua_State 内部结构的讲解。

一句话概括:table = 数组部分 + 哈希部分 + 固定表头。数组部分管连续整数键,哈希部分管其他所有键。

数组部分与哈希部分的分区规则

运行时并不简单地「整数进数组、其他进哈希」,而是维护一个非常精巧的启发式规则:数组部分只存放从 1 开始的一段连续整数前缀。写入键 1、2、3 会依次进入数组部分;一旦出现空洞(比如跳过了 4 直接写 5)、或键不是正整数(0、-1、3.5、字符串),对应的值就进入哈希部分。

local t = {}
t[1] = "a"     -- 数组部分
t[2] = "b"     -- 数组部分
t[5] = "c"     -- 中间有空洞,5 进哈希部分
t[0] = "z"     -- 0 不是正整数,进哈希部分
t["k"] = "v"   -- 字符串键,进哈希部分

每次插入可能触发一次「重新分区」:虚拟机用 computesizes 估算「数组部分放多少个槽位最划算」,原则是数组部分的实际利用率不能太低。如果一个表的整数键非常稀疏,比如只写了 t[1] 和 t[1000000],运行时绝不会傻到分配一百万长度的数组,而是把 t[1000000] 放进哈希部分,数组部分只保留 t[1]。

local sparse = {}
sparse[1] = "first"
sparse[1000000] = "million"   -- 不会撑爆内存,因为进了哈希部分
print(#sparse)                -- 输出 1(数组部分的边界)

这种「按利用率自动分区」的机制让同一个 table 既能当数组用又能当字典用,但也带来了不确定性:同一个表,键的写入顺序不同,最终的内存布局可能不同。

rehash 机制与容量增长

当数组部分或哈希部分需要扩容时,虚拟机执行一次 rehash:分配一块更大的连续内存,把旧数据逐个搬进去,然后释放旧块。数组部分的扩容通常是翻倍(2、4、8、16……),哈希部分也是按倍数扩张。整体搬运是 O(n) 的,n 越大单次 rehash 越贵。

local t = {}
for i = 1, 100000 do
    t[i] = i     -- 1 -> 2 -> 4 -> 8 ... 一路上反复 rehash
end

上面的循环大约触发 17 次 rehash,累计搬运 2 * 10^5 个槽位,整体仍是 O(n) 均摊,但每次 rehash 都会产生一块短暂存活的大内存,这正是 GC 压力与碎片的重要来源之一。LuaJIT 提供了预分配接口:

-- LuaJIT:一次性预分配数组 100000 槽、哈希 0 槽,全程零 rehash
local t = table.new(100000, 0)
for i = 1, 100000 do
    t[i] = i
end

PUC Lua 5.4 没有 table.new,常用的预分配技巧是「先写一个远端键强制扩容再删除」:

local t = {}
t[100000] = false   -- 强制扩到足够大
t[100000] = nil     -- 再删掉,避免多占一个值
for i = 1, 100000 do
    t[i] = i
end

删除键同样可能触发 rehash:当数组部分利用率下降(比如删除了一半元素),虚拟机可能把数组部分收缩、把余下数据搬到哈希部分,避免内存浪费。频繁「插了删、删了插」的表会在这条路上反复横跳,应当避免。

紧凑表 packed array 与边界效应

当 table 的所有键恰好是 1..n 的完整前缀、没有任何哈希节点时,我们称它为 紧凑表(packed array)。紧凑表是 Lua 内存优化的理想形态:数据连续、无空洞、# 运算结果确定、迭代顺序稳定,访问也最快。

紧凑表有一个重要的边界效应:只要在边界外插入一个键,布局就可能被整体打乱。

local packed = {}
for i = 1, 1000 do packed[i] = i end   -- 紧凑表,数组部分 1000 槽
packed[0] = "zero"                      -- 0 进哈希,数组部分不受影响
packed[2000] = "far"                    -- 超出数组边界,触发 rehash 决策
print(#packed)                          -- 结果取决于 rehash 后的边界,不稳定

更隐蔽的坑来自 # 运算符。# 只对「有明确边界」的数组可靠:它返回数组部分的长度,但遇到空洞时行为未定义。用 # 遍历带空洞的表,可能多算、可能少算、可能得到 0。

local a = {10, 20, 30}
a[5] = 50            -- 下标 4 是空洞
print(#a)            -- 未定义行为:可能 3,可能 5,取决于实现细节
for i = 1, #a do     -- 这种遍历有风险
    print(a[i])
end

工程结论:把 table 当数组用时,保证键连续;要稀疏存储,就明确走哈希部分,别指望 # 给出稳定答案。

字符串键与 intern 机制

字符串键的内存成本并不只是哈希节点本身。Lua 对字符串做了 intern(驻留):内容相同的字符串在同一个 Lua 状态内只保留一份,重复出现的 "name"、"hp" 都指向同一个对象。这意味着哈希部分的「键」字段往往只是复用一个已存在的字符串指针,而不是复制一份内容。

local s1 = "player_name"
local s2 = "player_name"
print(s1 == s2)      -- true:intern 后是同一个对象

由此得到一个反直觉的优化点:用常数字符串做键几乎不额外耗内存,因为键字符串已被 intern;真正耗内存的是「动态拼接出来的字符串键」,比如 string.format("user:%d", id) 这种,每出现一个新 id 就产生一个新字符串对象,连同哈希节点一起留在表里。

-- 动态键:每个 uid 都会产生新字符串 + 哈希节点
local online = {}
for _, uid in ipairs(online_users) do
    online["user:" .. uid] = true
end

-- 若是固定枚举键,则所有键都是 intern 的同一批对象
local Status = { idle = 0, busy = 1, offline = 2 }

因此,表的内存规划要同时考虑两个维度:键的数量,以及键字符串本身是否可复用。若一组键是动态拼接的,即使表结构一样,内存也可能相差数倍。

rehash 的可见影响与测量

rehash 虽然由运行时自动完成,但它的代价可以直观测量。下面用一个简单的循环演示「逐步扩容」与「一步到位」两种构造方式的内存与时间差异:

local function build_incremental(n)
    local t = {}
    for i = 1, n do t[i] = i end
    return t
end

local function build_prealloc(n)
    local t = table.new(n, 0)      -- LuaJIT 专属
    for i = 1, n do t[i] = i end
    return t
end

local N = 1000000
local t0 = os.clock()
build_incremental(N)
print(string.format("逐步扩容耗时: %.3f 秒", os.clock() - t0))

local t1 = os.clock()
build_prealloc(N)
print(string.format("预分配耗时:   %.3f 秒", os.clock() - t1))

在 LuaJIT 下,预分配通常比逐步扩容快 10%-30%,且全程不产生中间大块内存。差异的来源正是 rehash 期间的「分配新块 + 整体搬运 + 释放旧块」三步开销。

需要注意的是,rehash 的次数取决于增长曲线。数组部分按倍数增长,翻倍扩容意味着最多 log2(n) 次 rehash;哈希部分为了维持负载因子,扩容时还要把已插入的节点重新散列到更大的表,这部分搬运成本通常高于数组的 memcpy 式复制。

会话表内存剖析实战

把前面的理论串起来,看一个真实场景:游戏服务器维护在线玩家会话,键是玩家 ID(整数),值是会话对象(table)。

-- 玩家会话表:playerId -> session
local sessions = {}
local SESSION_LIMIT = 10000

local function login(playerId)
    if sessions[playerId] then return end        -- 已在线上
    sessions[playerId] = { lastSeen = os.time(), hp = 100 }
end

local function logout(playerId)
    sessions[playerId] = nil
end

如果 playerId 是连续生成的整数(1、2、3……),sessions 会自然形成紧凑数组,一万个会话只需约 160 KB 的数组槽位加每个会话对象本身。如果 playerId 是稀疏的(10001、30002、70005……),这些键全部进入哈希部分,内存会翻倍以上,且每次 login/logout 都可能触发 rehash。

-- 改进:固定上限时,直接预分配数组部分
local sessions = table.new(SESSION_LIMIT, 0)

-- 更省:如果 session 只有固定几个字段,考虑并行数组而不是对象表
local lastSeen = table.new(SESSION_LIMIT, 0)
local hp = table.new(SESSION_LIMIT, 0)

当访问模式是「按 ID 频繁读写少量字段」时,把对象表拆成多个平行数组,每个字段一张紧凑表,既能保持连续内存,又避免了每会话一个 table 的固定开销。这是从「面向对象思维」切换到「面向内存布局思维」的典型例子。

用差分测量验证优化效果

优化是否有效,最终要靠测量说话。推荐把差分测量写成可复用的基准函数,放进测试用例(配合 Lua 测试与 BDD 实践 可以做成性能回归测试):

local function mem_used(t)
    collectgarbage("collect")
    return collectgarbage("count") * 1024
end

local function diff_report(label, before, after)
    print(string.format("%s: 内存差 %.0f 字节", label, after - before))
end

-- 对比紧凑 vs 稀疏
local base = mem_used({})
local packed = {}
for i = 1, 1000 do packed[i] = i end
diff_report("紧凑表", base, mem_used(packed))

local base2 = mem_used({})
local sparse = {}
for i = 1, 1000 do sparse[i * 100] = i end   -- 稀疏键
diff_report("稀疏表", base2, mem_used(sparse))

一个可以长期维护的习惯:为关键数据结构编写「内存清单」,每次改动结构后跑一遍,用数字而不是直觉判断是否值得。这样既能发现意外的内存回归,也能沉淀团队对每种数据形态的量化认知。

内存占用估算

精确测量 Lua 内部布局需要阅读源码,但工程上可以用 collectgarbage("count") 做差分估算,再结合 TValue 与 Node 的已知尺寸推算。下面的脚本对比「纯数组」与「纯哈希」构造一万项的内存差异:

local function measure(label, build)
    collectgarbage("collect")
    local before = collectgarbage("count")   -- 单位 KB
    local t = build()
    collectgarbage("collect")
    local after = collectgarbage("count")
    print(string.format("%s: %.1f KB", label, after - before))
    return t
end

measure("纯数组 10000 项", function()
    local t = {}
    for i = 1, 10000 do t[i] = i end
    return t
end)

measure("纯哈希 10000 项", function()
    local t = {}
    for i = 1, 10000 do t[string.format("k%d", i)] = i end
    return t
end)

64 位平台上各组成部分的典型尺寸:

组成部分典型字节数(64 位)说明
表头 Table 结构48-56含数组指针、哈希指针、元表引用等
数组槽位 TValue16值联合体 + 类型标记
哈希节点 Node32-40键、值、next 指针与对齐填充
每个字符串变长字符串本身独立分配,长度 + 头部

由此可以估算:纯数组一万项约 160 KB,纯哈希一万项约 320-400 KB。哈希部分比数组部分贵一倍以上,这还不算字符串键在哈希部分之外的独立分配。

避免内存碎片与 GC 压力

rehash 的本质是「分配新块、复制、释放旧块」。在长生命周期进程中,反复 rehash 会让堆上出现大量大小不一的短暂空洞,与其他对象交织后形成外部碎片;即使 GC 能回收,也可能因为碎片化而无法合并出大块连续内存。

碎片的主要来源有三个:

  1. 反复扩容的数组表:每次 rehash 翻倍,旧块被丢弃,如果新块更碎,就产生浪费。
  2. 临时表泛滥:函数内每次调用都新建 table、用完即弃,产生大量短命对象(详细见 Lua 垃圾回收机制与优化实践)。
  3. 大表的中间增删:在大数组中间 table.insert / table.remove 会让元素整体搬移,并频繁改变边界,诱使 rehash。

降低碎片与 GC 压力的手段:

-- 1. 预分配:让容量一步到位,避免 rehash 抖动
local items = table.new(8192, 0)   -- LuaJIT

-- 2. 复用:常驻临时表,而不是每次重建
local _scratch = {}
local function classify(v)
    _scratch.bucket = math.floor(v / 10)
    _scratch.size   = v
    return _scratch
end

-- 3. 批量构造字符串用 table.concat,不要逐次拼接出大量中间串
local parts = {}
for i = 1, 1000 do parts[i] = tostring(i) end
local s = table.concat(parts, ",")

另外,把「只读的配置表」构造完成后,尽量避免后续增删,让它稳定在某个布局上,比反复调整更有利于长期内存健康。关于复用表与对象池的更完整做法,可回看 Lua 性能优化实战指南。

数据结构的选型决策

针对不同的访问模式,table 内部布局的优劣有明显差异,工程选型可以参考下表:

场景推荐形态原因
固定长度、顺序访问纯数组部分连续内存、零哈希开销、可 JIT 优化
动态增删的集合哈希部分(少量节点)避免数组整体搬移
稀疏 ID 到对象映射哈希部分避免巨大空洞浪费内存
常驻缓存、读多写少预分配 + 复用减少 rehash 与 GC 抖动
超大数值向量FFI cdata 数组见 Lua FFI 外部函数接口

一个常见的反直觉结论:用整数做键并不总是比字符串好。如果整数键是稀疏的(1、100、10000),它们大概率被塞进哈希部分,与紧凑字符串键的哈希部分相比并没有本质优势,反而失去了可读性。

-- 稀疏整数键:别期待数组化
local byId = {}
for _, row in ipairs(rows) do
    byId[row.id] = row        -- id 可能是 1、500、99999,全部进哈希部分
end
-- 此时 byId 的内存成本约等于一张等规模字典

注意事项

使用 table 进行内存优化时,需注意以下要点:

  • 数组部分只在「键是 1 起始的连续正整数前缀」时生效,插入空洞或 0、负数键都会把数据推向哈希部分。
  • # 运算符对带空洞的表结果是未定义行为,遍历稀疏表不要依赖 #。
  • rehash 会把旧块整体搬移,反复扩容的进程级表是内存碎片的高发点,尽量预分配或复用。
  • LuaJIT 的 table.new(narray, nhash) 是预分配的正规手段;PUC Lua 5.4 需用「远端键扩容再删除」的惯用法。
  • 删除操作也可能触发收缩 rehash,「边插边删」的表会反复横跳,能批量重建就批量重建。
  • 哈希节点比数组槽位贵一倍以上,能用数组表达的集合优先用数组。
  • 内存优化要与 GC 调优配合,二者本质是同一件事的两个视角,详见 Lua 垃圾回收机制与优化实践。

常见问题(FAQ)

为什么 # 对带空洞的数组结果不确定?

因为 # 返回的是「数组部分的边界」,而边界只在键恰好是 1..n 连续前缀时才有明确定义。出现空洞时,运行时可选择把边界定在最后一个连续整数处,也可能在 rehash 后把数据搬进哈希部分,两种选择都会改变 # 的结果。官方文档明确将空洞情况的行为定义为未定义。

数组部分一定比哈希部分省内存吗?

绝大多数情况下是,但不绝对。数组部分是 16 字节的连续槽位,哈希节点是 32 字节以上,且哈希部分还要预留负载因子余量。只有一种例外:当数组部分利用率极低(比如只用了 1024 个槽位中的 5 个)时,运行时可能反向选择「不扩容数组,把稀疏键放进哈希」,此时省下的内存来自避免大数组空洞。

如何判断一个 table 当前用的是数组还是哈希?

纯 Lua 层面没有直接 API,但有两个旁证:一是 # 返回的值(数组部分边界),二是迭代顺序(PUC Lua 5.4 遍历先数组后哈希,但顺序本身不保证)。精确判断需借助 luaH_get 之类的调试手段或 debug 库的扩展,日常开发用内存差分估算即可。

table.new 在 PUC Lua 5.4 里能用吗?

不能。table.new 是 LuaJIT 的扩展 API,PUC Lua 5.4 没有它。Lua 5.4 提供了 table.move 等批量操作,但预分配仍需用「写远端键触发扩容、再删除」的惯用法,或干脆让运行时按需 rehash,绝大多数场景性能差异很小。

频繁往 table 里插入删除会造成内存碎片吗?

会的。插入触发扩容 rehash(旧块释放、新块分配),删除触发收缩 rehash(数据搬移、布局重排),两者都会在堆上留下大小不一的短命块。高频路径上的「临时建表又清空」是碎片的第一大来源,建议改用池化复用或一次性预分配。

空表也会占用内存吗?

会。任何 table 至少要分配一个表头结构(约 48-56 字节),即使它既没有数组部分也没有哈希部分。大量创建又丢弃的空表是最容易被忽视的内存浪费——比如在循环里反复 local t = {} 而不复用,就会积累大量表头对象。这正是 Lua 垃圾回收机制与优化实践 中强调「表复用」的原因。

为什么同样的表,两个版本内存差异很大?

通常是因为键的形态不同。紧凑整数键走数组部分(16 字节/槽),字符串或稀疏整数键走哈希部分(32 字节以上/节点),动态拼接的字符串键还会额外占用字符串本身的内存。改一个键的形态,就可能让同一张表的内存差出 2-3 倍。

LuaJIT 与 PUC Lua 的内存布局完全一样吗?

大体一致,细节不同。LuaJIT 的表同样分为数组部分与哈希部分,但其哈希节点更紧凑、扩容策略略有差异,且配合 JIT 后数组访问可被直接编译为内存偏移。因此同一份代码在两者上的绝对内存数值会有差异,但「紧凑优于稀疏、复用优于重建」的结论完全通用。

相关阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「lua」更多文章

  1. Lua 剖析与调试工具链:从 luaprofiler 到火焰图
  2. OpenResty WAF 与安全防护实战:用 Lua 构建 Web 防火墙
  3. Lua 设计模式落地:用 table 与元表实现经典模式