lua之table

目录

lua中数据的统一表示

lua中table类型的表示

数组部分

哈希表部分

table的操作

查询

如果查询的键是一个字符串

如果查询的键是一个数字

插入

常规插入

空间不够了怎么插入

总结


lua中的table类型是一种奇特的存在,它的底层实现大有文章,但是在使用上却提供给程序员极大的便利。本文就来剖析它的底层实现。

lua中数据的统一表示

table既然是一种数据类型,那么有必要先梳理一下lua中的数据的表示方法。

所有类型的数据都是以结构体TValue来表示:


typedef struct lua_TValue {
  Value value; //表示具体的数据,它是一个联合体
  int tt //表示该数据的类型,我们常用的:
         //0表示nil类型,1表示boolean类型,3表示number类型,4表示string类型,5表示table类型
} TValue;

//Value是联合体,意思就明确了:一个类型的数据只能是其中一种
typedef union {
  GCObject *gc; //该数据是需要lua内部进行内存分配和垃圾回收,gc指向的是一个联合体:typedef union GCObject GCObject
                 // 我们要说的table类型的数据就在gc里
  void *p; //数据所在内存区域由lua外部使用者来进行分配和释放
  lua_Number n; //该数据是number类型,实际是double:typedef double lua_Number
  int b; //该数据是boolean类型,整数1表示true,整数0表示false
} Value;

//GCObject是联合体,意思也明确了:需要垃圾回收的的一个类型数据只能是其中一种
union GCObject {
  GCheader gch;
  union TString ts; //string类型
  union Udata u;
  union Closure cl;
  struct Table h; //table类型
  struct Proto p;
  struct UpVal uv;
  struct lua_State th; 
};

 以上就解读了TValue的构成,下面要讨论今天的主角table类型。

lua中table类型的表示

typedef struct Table {
  CommonHeader;
  lu_byte flags;  
  lu_byte lsizenode; //2的lsizenode次方表示为此table的哈希表部分准备的节点数组的大小,实际是unsigned char:typedef unsigned char lu_byte
  struct Table *metatable;
  TValue *array; //指向此table的数组部分
  Node *node; //指向此table的哈希表部分
  Node *lastfree; //当需要往哈希表里插入k-v键值对的时候,应该从节点数组的哪个位置往前去找空闲节点用来存放此k-v键值对
  GCObject *gclist;
  int sizearray; //此table的数组部分的大小
} Table;

 根据对该结构体的解读,我们发现,lua中的table类型里有两部分:数组部分和哈希表部分,这是table的核心。

数组部分

数组部分由table类型结构体的成员array指向,在创建table的时候,会调用realloc()来分配指定大小的内存,然后将内存起始地址赋值给array。

数组中每个元素都是用TValue结构体表示。

数组大小谁指定?我们在lua里脚本里指定。如果我们用{}来构造table,那么大小就是0;如在构造table的时候{}里有一些非键值对方式指定的元素,有多少个这样的元素,数组部分大小就有多少。记住,这是首次构造一个table的时候,其数组部分的容量分配是要多少就多少,但是当后续需要对数组部分需要扩容或缩容的时候是按照”大于等于要求容量的最小的2的次幂“原则来的,后面会有介绍。

哈希表部分

哈希表部分由table类型结构体的成员node指向,在创建table的时候,会调用realloc()来分配内存,然后将内存起始地址赋值给node。

哈希表中每个元素都是用Node结构体(后面我们称呼每个Node结构体为一个节点)表示:

typedef struct Node {
  TValue i_val; //键值对的v
  TKey i_key; //键值对的k
} Node;

typedef union TKey {
  struct {
    Value value; //键值对的k
    int tt; //键值对的k的类型
    struct Node *next; //指向和该key产生哈希碰撞的下一个键值对,可知哈希碰撞的所有元素是用单向链表串接起来的
  } nk; //可以看做是用一个TValue结构体来存放键值对的k,外加一个指针来指向下一个键值对
  TValue tvk;
} TKey; // 此处为什么是联合体类型?我很疑惑,本来只需要nk成员就足够的呀

那么哈希表部分是如果分配内存的?它像数组部分一样,一次性分配一块连续内存,它看起来就是个节点数组。

那么一次性分配多少个节点?一句话:大于等于要求的节点数量的最小的2的次幂。如果要求0个那就分配0个;如果要求1个那就分配1个;如果要求2个那就分配2个;如果要求3个那就分配4个;如果要求7个那就分配8个;如果要求8个那就分配8个;如果要求14个那就分配16个。以此类推。哈希表无论是第一个分配内存还是后续调整内存,都是按照”大于等于要求的节点数量的最小的2的次幂“原则来的。

table的操作

table有较多操作,我们只针对最常用的查询和插入来进剖析。

查询

如果查询的键是一个字符串

正常情况下想到哈希表中获取以某字符串为键的值是很费时间的:首先要对字符串做挨个字符做循环的哈希计算,然后对哈希表长度取余,然后锁定相应位置的链表,遍历链表,拿链表中每个键和目标字符串进行长度比较,如果长度相等再逐个字符进行比较,最终确定哪个节点的键是该字符串。

正常方式要进行大量的计算和比较,非常耗时。而lua底层却有很高效的方式。

首先要解释的是,lua中每个字符串全局只存放一份,每个字符串用一个TString联合体表示,所有同样的字符串不会重复分配内存,而是用指针指向对应的TString:

typedef union TString {
  L_Umaxalign dummy;  
  struct {
    CommonHeader;
    lu_byte reserved;
    unsigned int hash; //字符串的哈希值
    size_t len; //字符串的长度
  } tsv;
} TString;

TString联合体里只有字符串长度和哈希值,那么真正的字符串在哪里?紧接在联合体后面!每次在新创建一个字符串的时候,分配的内存大小是sizeof(TString)+(len+1)*sizeof(char),这块内存前面存TString,接着是字符串本身,最后是字符'\0'。

讲完字符串就可以把查询讲明白了。lua在查询以一个字符串为键的值的时候,查询参数不是该字符串,而是表示该字符串的TString联合体的地址。代码逻辑中先把联合体中的哈希值和(哈希表长度-1)进行与运算,找到以该字符串为键的节点应该属于哪个位置,然后把该位置的节点链表遍历,看看哪个节点的键的ts成员的地址等于查询参数就行了,如果等于,就找到它了,然后返回该节点的i_val成员(Tvalue结构体)的地址供调用者读取值。

如果查询的键是一个数字

先看该数字是不是整数,如果是,再看该整数在不在数组部分的序号范围内,是则返回该数组对应序号的元素(Tvalue结构体)的地址。

如果不是整数或不在数组部分的序号范围内,那么就要到哈希表部分中去找了。如何计算以数字为键的节点在哈希表的位置?拿数字的高四位和低四位相加得到一个数,然后拿这个数对(哈希表容量-1)|1)取余得到位置,然后把该位置的节点链表遍历,看看哪个节点的键和该数字相等。(此处有疑问,字符串拿哈希值做与运算得到位置合情合理,但数字做取余运算后我发现,数字为键的的节点永远不会落到哈希表最后一个位置上,这咋回事?)

插入

常规插入

插入的第一步就是先查询,如果能查询得到,那么得到了对应的TValue的地址,至于插入什么值?调用者会操作该TValue。那么如果查询不到呢?哈哈,这又有一大篇文章要讲了。

查询不到代表无论数组部分还是哈希部分都没有以目标为键的键值对。

那么先计算该键对应的哈希表的位置是哪一个。如果该位置节点未被占用,则把该key赋值给该节点的i_key,并返回i_val的地址。这是最简单的。

如果该位置节点被占用呢?那么寻找一个空闲节点。如何寻找空闲节点?我们知道table的lastfree成员,在初始的时候,它指向哈希表的内存区域的最后一个节点的后面,空闲节点都在它前面(不代表在它前面都是空闲节点)。如果将lastfree往前推一步,如果它指向的是一个空闲节点,那么就用它,每次用就把它往前推,推一次就检查是不是一个空闲节点,直到它与table的node成员相逢,即指向了哈希表内存区域的起始,此时代表没有空闲节点了。

得到空闲节点后要做一个拨乱反正的事情。假如该键计算后得知应处于位置A,结果因为A被占用而找到一个位于B位置的空闲节点,那么是直接赋值空闲节点的i_key然后挂到A后面吗?当然不是,因为当前占据A位置的节点可能并不是应该在那个位置,它有可能是C位置的链表的一环,只是由于当时B位置正好空闲被征用了而已。此时如果把空闲节点挂在A后面就是站错队了。

该这样来解决:

看看A位置被占用是不是属于被篡位导致的,方法是对A位置节点的i_key进行计算看它是否应该在此。

如果的确应该在此,那么就将空闲节点的i_key成员赋值并插入到A后面,这样就算找到了队伍。

如果是篡位,那么计算篡位者应该在哪个位置,找到那个位置,那个位置上的节点就带头大哥,顺着大哥链表一路捋下去,找到篡位者的前面一环,将篡位者赋值给空闲节点,挂载到前面一环的后面,然后篡位者把A位置的节点让位出来给我们的新键用。

空间不够了怎么插入

以上讲了有空闲节点的情况,它可能包括拨乱反正的复杂操作。

还有更复杂的,这也来到了table之核心的核心:如果哈希表没有空闲节点,那么就要对table进行内存的重新分配了,同时也会趁此对所有元素的存储位置做重新布局。

首先,遍历数组部分和哈希表部分,得到非空键的数量、非空整数键的数量、非空整数键的数量分布。这几个概念需要解释下:

此处谈到键,不只是哈希表中的键,也包括数组中存在非空值的位置的序号,这序号叫做整数键罢了。

非空整数键的分布是咋回事?即每一个2的次幂的之间的非空整数键的数量,区间分别为[0,1), [1,2),[2,4),[4,8),[8,16),[16,32),以此类推。

竟然有整数键不在数组内,在哈希表里?当然了。如果我常规插入的时候,插入的键是整数1000000,那么它不在数组序号范围内(即当时数组内存区域不够大,尚容不下第1000000个位置),那么以该整数为键的值只能插入到哈希表中咯。

统计完各种数量后呢?

先重新计算数组部分需要多大容量。计算方法是:从前往后将每个区间的非空整数键累加,累加值大于总位置个数的一半,则数组部分的容量就是总位置的数量。如:累加到[0,1)区间,累积了1个非空整数键,则数组部分的容量提高到1;累加到[1,2),累积了2个非空整数键,则数组部分的容量提高到2;累加到[2,4),累积了大于2个非空整数键,则数组部分的容量提高到4;累加到[4,8),累积了大于4个非空整数键,则数组部分的容量提高到8;累加到[8,16),累积了大于8个非空整数键,则数组部分的容量提高到8,and so on。如果中途没有达到某区间的累积非空键的阈值,则继续往后累加,直到最后一个有非空整数键的区间。我要举一个意难平的情况:600到1000共401个整数键都非空,但是其他整数键空的,那么不好意思,数组部分容量为0,因为如果要把数组容量设置为1024,那么[0,1023)内至少要有513个非空键,而事实上只有401个,太遗憾了。

计算完数组部分需要多大容量后,再看看在容量范围内目前共有多少个非空整数键,用table所有非空键的数量减去数组容量范围内目前的已有非空整数键的数量得到了哈希表部分调整后要放多少个键。

到此,我们确定了数组部分需要分配多大内存,以及哈希表部分需要容纳多少个键。

最后我们就进入实际的内存重新分配和元素位置的重新布局了,以下是步骤:

  1. 如果要求的数组容量大于已有容量,那么就直接调用realloc进行扩容,该函数的属性决定了已有容量中的元素是不变的。
  2. 将旧的哈希表起始地址保存一下,然后按照要求重新申请哈希表内存(按照”大于等于要求容量的最小的2的次幂“原则)。
  3. 如果数组需要缩容,则要把多出的数组元素散列到新哈希表中存放,然后调用realloc对数组缩容。
  4. 将旧哈希表里的键值对都进行重新set,它们每一个可能会set到数组里,可能会set到新哈希表里。
  5. 将旧的哈希表占据的内存释放掉。

以上就是插入键值对发现哈希表无空闲节点的操作。一波操作猛于虎后,我们就可以回头走常规插入流程了,这个时候再也不用担心无空闲节点了!

总结

数组部分每个元素是TValue结构体,占据16个字节。哈希表部分每个节点是Node结构体,占据40个字节。

数组存储一个整数为键的键值对只需要哈希表不到一半的内存,所以数组存储以整数为键的键值很有空间优势。

由于哈希表存在哈希碰撞导致有的时候会遍历链表,所以数组部分存储以整数为键的键值对也有时间优势。

总而言之,一切都是为了整数键!

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值