Lua String

Lua 中的字符串(String)实现非常精巧,它兼顾了执行效率和内存管理的便利性。自 Lua 5.2.1 版本起,Lua 对字符串的底层实现进行了重大优化,将其明确区分为了短字符串(Short String)和长字符串(Long String)。

##核心特性

  • 不可变性(Immutable):Lua 中的字符串一旦创建,其内容就不能被修改。任何对字符串的修改操作(如拼接、截取)都会生成一个新的字符串。
  • 8 位安全(8-bit clean):Lua 字符串不仅可以包含普通的文本字符,还可以包含任意的二进制数据(包括 \0)。
  • 自动内存管理:字符串生命周期由 Lua 的垃圾回收器(GC)自动管理。

底层数据结构 (TString)

在 Lua 的 C 源码(lobject.h)中,字符串由 TString 结构体表示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Lua 5.4 中的 TString 结构 */
typedef struct TString {
  CommonHeader;         /* 垃圾回收相关的通用头部 (GCHeader) */
  lu_byte extra;        /* 短字符串: 标记是否为保留字; 长字符串: 标记是否已计算哈希值 */
  lu_byte shrlen;       /* 短字符串的长度 */
  unsigned int hash;    /* 字符串的哈希值 */
  union {
    size_t lnglen;         /* 长字符串的长度 */
    struct TString *hnext; /* 用于链接哈希表冲突节点 (链表法) */
  } u;
  char contents[1];     /* 柔性数组,实际存放字符串数据的起始地址 */
} TString;
  • hash:缓存该字符串的哈希值,避免重复计算,极大地加速了表(Table)的查找。
  • u(联合体):为了节省内存,Lua 复用了内存空间。如果是短字符串,它需要放在全局哈希表中,hnext 用于处理哈希冲突;如果是长字符串,它不需要放进全局哈希表,这个位置用来存储长字符串的真实长度 lnglen
  • contents[1]:这是一个 C 语言中的“柔性数组”技巧。字符串的真实数据紧接着 TString 结构体存放在同一块内存中,减少了内存碎片和指针跳转。

短字符串

在 Lua 中,长度小于等于 LUAI_MAXSHORTLEN(默认通常是 40 字节)的字符串被称为短字符串。

实现机制:字符串驻留

  • 全局唯一:Lua 维护了一个全局的字符串哈希表(stringtable)。当你要创建一个短字符串时,Lua 会先计算它的哈希值,然后去这个全局表中查找。
  • 复用:如果全局表中已经存在完全相同的字符串,Lua 不会分配新内存,而是直接返回已存在字符串的指针。
  • 新建:如果不存在,Lua 才会分配内存创建它,并将其插入到全局哈希表中。

优势:

  • 比较极快:因为保证了内存中相同的短字符串只有一份,Lua 比较两个短字符串是否相等时,只需要比较它们的指针地址是否相同即可,时间复杂度为 $O(1)$。
  • 节省内存:大量重复使用的短字符串(例如 Table 的键、状态标识等)只占用一份内存。

长字符串

长度大于 40 字节的字符串被归为长字符串。针对长字符串,Lua 采取了完全不同的策略。

实现机制:

  • 不再强制驻留:创建长字符串时,Lua 不会去全局哈希表中查重,而是直接分配一块新内存。这意味着内存中可能存在多个内容完全相同的长字符串。
  • 惰性求哈希(Lazy Hashing):长字符串在创建时不会立即计算哈希值(因为长字符串计算哈希非常耗时)。只有当这个长字符串被用作 Table 的键(Key)时,Lua 才会计算它的哈希值,并将 extra 字段标记为已计算,以便后续复用。

为什么这样设计?

在早期版本(Lua 5.2.0 之前),所有字符串都会被哈希并驻留。如果程序频繁读取大型文本(例如几 MB 的文件内容)并生成字符串,计算哈希和查表的过程会严重拖慢性能,甚至引发恶意的哈希碰撞攻击(Hash DoS)。区分长字符串后,大幅提升了 Lua 处理大段文本的吞吐量。

垃圾回收

  • 短字符串:当全局哈希表中的某个短字符串没有被任何地方引用时,GC 会在清理阶段将其从哈希表中移除,并释放其占用的内存。如果哈希表中的元素数量变化很大,Lua 还会自动对全局哈希表进行扩容或缩容(Rehash)。
  • 长字符串:处理方式与普通的 Lua 对象(如 Table、Function)一致,没有引用就直接释放。

一些运用时的"坑"

字符串拼接

因为 Lua 的字符串是不可变的,每次使用 .. 操作符进行拼接时,Lua 都必须在内存中分配一块全新的空间,把原来两个字符串的内容拷贝进去,然后等待垃圾回收器(GC)去清理旧的字符串。

如果在循环中不断累加字符串,会导致大量无用的内存分配和释放,时间复杂度会退化为 $O(N^2)$,引发严重的性能卡顿。

1
2
3
4
local result = ""
for i = 1, 10000 do
    result = result .. tostring(i) -- 极度消耗内存和 CPU
end

正确做法:使用 table.concat

将需要拼接的片段先存入一个 Table 数组中,最后使用 table.concat 一次性拼接。由于 Table 在底层会预分配和动态扩容,这能将时间复杂度降到 $O(N)$。

1
2
3
4
5
local temp = {}
for i = 1, 10000 do
    temp[i] = tostring(i)
end
local result = table.concat(temp) -- 高效,只分配一次大内存

字符串处理函数

Lua 提供了非常轻量级的字符串处理函数,如 string.sub、string.match、string.gsub 等。

所有这些提取或修改字符串的操作,返回值都是全新的字符串对象。在解析巨型文本(如大型 JSON 或 XML)时,如果频繁使用 string.sub 切割文本,会瞬间产生大量短命的字符串对象,造成 GC 峰值(GC Spike)。

在进行高频文本解析时,尽量利用索引位置(基于数字下标的滑动窗口)来进行逻辑判断,而不是物理切分字符串。在 C 层面编写扩展库来处理复杂的文本解析也是常见的优化手段。

Lua 与 C 语言交互时的边界开销

当你在 Lua 和 C/C++ 之间传递字符串时:

从 C 传到 Lua (lua_pushstring): Lua 会把 C 字符串完全拷贝一份,并经历哈希和驻留(如果是短字符串)的过程。如果 C 层传递的是大块二进制数据,应使用 lua_pushlstring 传入明确的长度,避免 Lua 内部调用 strlen 带来额外的性能损耗。

二进制数据承载: 尽量不要用 Lua 的 string 去频繁接收和修改底层的庞大二进制流(如音视频缓存、大型网络数据包)。对于这种场景,使用 Lua 的 Userdata 或 Lightuserdata 直接操作 C 内存区域会高效得多。

使用 Hugo 构建
主题 StackJimmy 设计