1.什么是lua数据结构原理
Lua是一门用C语言编写的脚本语言,一共1w多行代码,非常的轻巧,适合做游戏脚本。
Lua是解释型语言,通过对Lua的语言进行语法解析,然后生成二进制字节码,然后转由C语言进行执行操作。编译型语言,则会进行编译后生成机器码,直接由机器进行执行即可,执行效率会比较高。
所以,lua数据结构原理,其实就是用c写的lua的源码。
2.lua数据结构原理大体架构
3.如何看lua数据结构原理,lua数据结构原理的不同组件比较
3.1 全局状态机
从lua源码中的main函数看整个状态机的初始化:
Lua的main函数方法中,lua_State *L = luaL_newstate(); 主要用于创建全局状态机。
luaL_newstate主要用来为每一个LUA线程创建独立的函数栈和线程栈,以及线程执行过程中需要用到的内存管理、字符串管理、gc等信息。
全局状态机的初始化、销毁的实现,主要在lstate.c文件中。
int main (int argc, char **argv) {
int status, result;
/* 第一步:创建一个主线程栈数据结构 */
lua_State *L = luaL_newstate(); /* create state */
if (L == NULL) {
l_message(argv[0], "cannot create state: not enough memory");
return EXIT_FAILURE;
}
lua_pushcfunction(L, &pmain); /* to call 'pmain' in protected mode */
lua_pushinteger(L, argc); /* 1st argument */
lua_pushlightuserdata(L, argv); /* 2nd argument */
status = lua_pcall(L, 2, 1, 0); /* do the call */
result = lua_toboolean(L, -1); /* get result */
report(L, status);
lua_close(L);
return (result && status == LUA_OK) ? EXIT_SUCCESS : EXIT_FAILURE;
}
数据结构lua_State和global_State
Lua使用LG结构(lua_State *L)(LG结构就是lua_State和global_State结构)贯穿整个线程解析的生命周期的始终。
lua_newstate函数主要用于创建一个lua_State和global_State的数据结构。
lua_State:主线程栈结构。数据栈和调用栈。以及 lua 虚拟机中的环境表、注册表、运行堆栈、虚拟机的上下文等数据。
global_State:全局状态机,维护全局字符串表、内存管理函数、gc等信息。
lua_State和global_State结构,通过LG(指针引用)的方式进行链接,将全局状态机global_State挂载到lua_State数据结构上
global_State结构是完全感知不到的:我们无法用Lua公开的API获取到它的指针、句柄或引用
lua_State和global_State之间通过 global_State *l_G 互相建立链接关系。
LUA语言没有实现独立的线程,但是实现了协程序,关于协程后续会单独开一章节讲解
3.2 数据栈:
主要由一个StkId结构的数组组成。StkId结构为TValue,支持字符串、函数、数字等数据结构。
所有的数据都通过lapi.c文件中的lua_push*函数向栈上压入不同类型的值(数字、字符串、函数等)。
L->stack:指向栈底部。每次将一个数据压入栈上,栈指针(L->top++)都会指向到下一个结构上。
L->stacksize:栈体的大小。在栈结构初始化的时候,会分配一个BASIC_STACK_SIZE=40大小的栈结构。(LUAI_MAXSTACK 32位:15000;64位:1000000)
L->stack_last:指向栈头部,但是会留空EXTRA_STACK=5个BUF,用于元表调用或错误处理的栈操作。
L->top:栈顶指针。压入数据,都通过移动栈顶指针来实现。
3.3 调用栈:
主要由一个CallInfo的结构组成。CallInfo是一个双向链表结构。通过双向链表结构来管理每一个Lua的函数调用栈信息。
Lua一共有三种类型的函数:C语言闭包函数(例如pmain)、Lua的C函数库(例如str字符串函数)和LUA语言
每一个函数的调用,都会新生产一个CallInfo的调用栈结构,用于管理函数调用的栈指针信息。当一个函数调用结束后,会返回CallInfo链表的前一个调用栈,直到所有的调用栈结束回到L->base_ci。
调用栈最终都会指向数据栈上,通过一个个调用栈,用于管理不同的函数调用。
每次调用栈调用函数完成后,都会将函数返回的结果在栈上移动位置,将结果集逐个从ci->func位置开始填充,并调整L->top栈顶指针。这样的好处就是调用完一个函数,数据栈上只需要存储最终函数返回的结果集,不会因为复杂的函数嵌套而导致整个栈体结构迅速扩大。
3.4.字符串
字符串操作对应的文件:lstring.c
字符串 - 数据结构
Lua的字符串管理都会统一挂载到global_State全局状态机上。
字符串都会存储在TString对象结构上。Lua的字符串管理分两种类型:链表方式存储(短字符串(<40)) 和 HashMap 缓存方式
链表方式:存储在stringtable strt 的 链表结构上。
缓存方式:存储在TString *strcache[STRCACHE_N][STRCACHE_M] HashMap的结构上。
TString存储结构图
Lua字符串的数据内容部分并未分配独立的内存来存储,而是直接追加在TString结构的后面。TString存储结构如下图:
• Lua字符串对象 = TString结构 + 实际字符串数据
• TString结构 = GCObject *指针 + 字符串信息数据
3.5 table结构
结合了hash中的开放定址法和链地址法
4.lua数据结构源码实现
4.1 lua中string的源码分析
从虚拟机的大体来看,字符串通过一个结构体存放在global_State里,这个结构stringtable(lstate.h)是:
GCObject(lstate.h)的结构是:
stringtable结构体的字段含义是:
GCObject **hash: 一个GCObject的二维指针,可以看作二维数组,根据字符串的离散值存放这我们创建的字符串。通过Hash值可以指向Hash值存放的GCObject,其中实际引用的是TString。
lu_int32 nuse: 已经创建的TString个数
int size:TString的总个数(初始值为32)
在global_State中,会有一个stringtable,通过它来访问虚拟机中的所有字符串(包括长短字符串)。
总结:
(1)Lua中通过字符串的hash值来对字符串进行查找,对比。
(2)Lua中的字符串变量存放的是字符串的引用,而不是字符串本身。
(3)同一个字符串在Lua虚拟机中只有一份。
(4)Lua虚拟机中global_State的strt字段存放着当前系统中的所有字符串
具体看看字符串的实现,主要数据结构体是TString(lobject.h)
TString关联的宏定义, L_Umaxalign(llimits.h)
TString关联的宏定义, CommonHeader(lobject.h),这一个定义很多地方会使用到
这是TString相关的结构体。
下面来是分析如何创建一个字符串,实现的文件是在lstring.c。
在lua里面,创建一个字符串,会根据字符串的长短来区分到底创建长还是短的字符串。
如果长度小于LUAI_MAXSHORTLEN(值为40),它就会创建一个短字符串;
否则的话就会创建长字符串。
短字符串的区别是:如果在创建的过程,发现hash值、长度和内容都一样的短字符串,就会复用它,不再去进行分配。
但是长的字符串就会不管三七二一都会创建一个新的TString.
在创建字符串之前,stringtable的长度会动态扩展,当字符串table分配的size都被用完了,并且当size不大于最大数量的一半时,即需要调用resize进行扩展:
if (tb->nuse >= cast(lu_int32, tb->size) && tb->size <= MAX_INT/2)
luaS_resize(L, tb->size*2); /* too crowded */
既然有动态扩展,也有动态缩小,在GC的时候,stringtable也会检查是否需要回收释放空间,当字符串table使用率小于50%的时候,会进行resize。
if (g->gckind != KGC_EMERGENCY) { /* do not change sizes in emergency */
int hs = g->strt.size / 2; /* half the size of the string table */
if (g->strt.nuse < cast(lu_int32, hs)) /* using less than that half? */
luaS_resize(L, hs); /* halve its size */
luaZ_freebuffer(L, &g->buff); /* free concatenation buffer */
}
具体创建逻辑
1. 创建字符串对象:
字符串的内存结构是:TString+字符串内容+'\0','\0'是结尾标识,读取的时候只需要读一块连续内存。
2.最最关键的地方来了,既然都创建了,那怎么存到stringtable里呢?怎么构造?好,前面的介绍其实并不是没用的,那是概览。stringtable里有一个GCObject** hash,其实就是需要通过它来索引到所有的字符串内容。
hash指向hash数组,数组里每一个单元存放的是该hash值下面所有TString链表的队尾元素的地址。内存模型如下(hash值的含义实质为字符串长度,只是通过hash计算和lmod取模)。
构造链表的函数是luaC_newobj (lgc.c):
最后一个,前面提了很多hash关键字,在lua虚拟机里,hash算法采用的是JSHash,虚拟机启动的时候会生成一个随机种子,这个随机种子会在创建hash的时候一并带进去,以防被猜到hash值。
在Lua字符串这一块最主要的是要理解下面几个东西:
(1)Lua字符串是会被GC的
(2)每个字符串在Lua内存中只存在一份
(3)Lua通过一个全局的散列桶(stringTable)管理所有的字符串
下面一张图帮助理解一下Lua字符串存储的结构:
4.2 lua中table的源码分析
table的定义:
typedef union TKey {
struct {
TValuefields;
int next; /* 用于标记链表下一个节点 */
} nk;
TValue tvk;
} TKey;
typedef struct Node {
TValue i_val;
TKey i_key;
} Node;
typedef struct Table {
CommonHeader;
lu_byte flags; /* 1<<p means tagmethod(p) is not present */
lu_byte lsizenode; /* log2 of size of 'node' array */
unsigned int sizearray; /* size of 'array' array */
TValue *array; /* array part */
Node *node;
Node *lastfree; /* any free position is before this position */
struct Table *metatable;
GCObject *gclist;
} Table;
- flags : 元方法的标记,用于查询table是否包含某个类别的元方法
- lsizenode : (1<<lsizenode)表示table的hash部分大小
- sizearray : table的数组部分大小
- array : table的array数组首节点
- node :table的hash表首节点
- lastfree : 表示table的hash表空闲节点的游标
- metatable : 元表
- gclist : table gc相关的参数
为了提高table的插入查找效率,在table的设计上,采用了array数组和hashtable(哈希表)两种数据的结合。
所以table会将部分整形key(注意,是key,比如a[1]就是整型key,a["abc"]就是其他类型key)作为下标放在数组中, 其余的整形key和其他类型的key都放在hash表中。(重点,整形放数组)
在table中的实现中,hash表占绝大部分比重,下面是table中hash表的结构示意简图:
hash表在解决冲突有两个常用的方法:
- 开放定址法:当冲突发生时,使用某种探查(亦称探测)技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定 的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。查找时探查到开放的 地址则表明表中无待查的关键字,即查找失败。
- 链地址法:又叫拉链法,所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数 组T[0…m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。在拉链法中,装填因子α可以大于 1,但一般均取α≤1。(装填因子α:散列表中的元素个数与散列表大小的比值)
开放定址法相比链地址法节省更多的内存,但在插入和查找的时候拥有更高的复杂度。
但是table中的hash表的实现结合了以上两种方法的一些特性:
- 查找和插入等同链地址法复杂度。
- 内存开销近似等同于开放定址法。
原因是table中的hash表虽然采用链地址法的形式处理冲突,但是链表中的额外的节点是hash表中的节点,并不需要额外开辟链表节点;下面是TKey结构的介绍:
(也可以这样说,先用开放定址法把节点存起来,但是给节点新增指针,指向和他key值一样的节点,这样查找起来就快很多了)
table表set的过程:
- 首先调用luaH_get查找table是否已经存在这个key了,有则直接返回。
- 否则调用luaH_newkey创建key,并返回对应的TValue。(注意此时key一定不在数组部分内)
例子:
//假设tb的hash表默认大小为8个元素
local tb = {}
tb[3] = 'a'
tb[11] = 'b'
tb[19] = 'c'
tb[6] = 'd'
tb[14] = 'e'
1. 执行完local tb = {}之后,tb中的hash表状态是这样:
空余节点指针lastfree指向了最后一个node。
注意:table创建默认hash表大小为0,这里为了方便描述假设初始大小为8,这样就不用管rehash部分了
2. 执行完tb[3] = 'a’之后,tb中的hash表状态是这样:
因为3的mainposition为3,所以放在了n3位置。
注意:key的manposition等于hash(k)%table_size, 所以mainposition(3)=3%8
3. 执行完tb[11] = 'b’之后,tb中的hash表状态是这样:
因为11的mainposition也为3,然而3位置已经被占用了,所以此时使用lastfree获取一个空节点n7,将当前key存储在n7位置上,并且使用头插法将n7节点插入在mainposition节点n3之后,所以这里的next = n7 - n3 = 4。
4. 执行完tb[19] = 'c’之后,tb中的hash表状态是这样:
原因和上面类似,只不过注意的是,19是插入在mainposition节点n3和mp的next节点n7之间,所以需要重新维护n3的next值。
5. 执行完tb[6] = 'd’之后,tb中的hash表状态是这样:
在这一步中有些不一样的处理,首先还是算出6的mainposition为6,然后发现n6已经被key:19占用了。但是此时我们不能直接使用lastfree来存储key:6,因为19和6不是同一个链表上的,也就是说key:19抢了key:6的位置:
对于这种情况,我们需要key:19让出位置,通过lastfree申请一个空节点n5,然后将19的位置换到n5上(注意维护next节点)。然后将key:6放在n6节点上。
这部分操作就是流程图上Resetpos部分
6. 执行完tb[14] = 'e’之后,tb中的hash表状态是这样:
这里和步骤3类似。
下面再来分析一下Rehash的部分:
Rehash并不一定代表hash表的扩容,而是根据table里面的key的个数和类型,重新更合适的分配array的大小和hash表的大小,可能会扩容、可能不变、也有可能缩小。
假如此时table里面的array和hash表状态如下:
此时再插入一个key:10,因为lastfree已经无法获取空节点了,所以触发了rehash。
- 首先通过numusearray计算数组部分val不为nil的所有整形数目,和nums[]。对于nums[i] = j,其意义表示key在2^i-1 到2^i之间其整形key的个数有j个。
- 然后再通过numusehash计算hash部分Val不为nil的所有整形数目,和nums[]。
- 通过整形key的个数确定array的大小,computesizes,这里确定array的大小有个规则就是要满足2的幂size,并且整形key数目num > arraySize / 2,还要保证放入整形key的数目高于arraySize / 2。
- 最后根据array大小和总key个数,确定hash表的大小。(ps:hash表的大小也只能是2的幂,如果不是则向上对齐)
通过上面的规则可以计算得到array部分的大小为4,hash表大小为7 - 3 = 4。(7是指Key的总数,3是指能放入数组的Key的个数)
无论是array的rehash还是hash表的rehash都是先开辟新的内存,然后将原来的元素重新插入。
插入key:10后的状态为:
值得注意的是:table元素的删除是通过table[key] = nil来实现的,然而通过我们上面对luaH_set介绍我们可以知道,仅仅是把把key对应的val设置为nil而已,并没有真正的从table中删除这个key,只有当插入新的key或者rehash的时候才可能会被覆盖或者清除。
5.lua数据结构关键点,重要节点,疑难杂症场景
5.1 lua在取table其长度值
lua在取table其长度值,实际上在找一个整形key,满足:
- table[key] != nil
- table[key+1] == nil
引用
Lua源码分析 - 数据结构篇 - Table实现(06)_自娱自乐的代码人-CSDN博客
Lua源码分析 - 数据结构篇 - 字符串池实现(05)_自娱自乐的代码人-CSDN博客
https://blog.csdn.net/initphp/category_9293184.html
Lua 源码分析之String_kelvin_feng的专栏-CSDN博客_lua 字符串源码(这篇逻辑清晰)