lua5.3字符串实现

发布时间 2023-05-20 19:30:52作者: 墨色山水

lua字符串对象分为长字符串和短字符串,长度超过40的是长字符串对象,小于40的是短字符串对象,短字符串对象会被缓存到全局变量G的strt 哈希表中(数组+链表实现)。

 

字符串对外提供的创建接口

luaS_new(),luaS_newlstr(), luaS_createlngstrobj() 三个,前面两个接口会对字符串进行缓存,第三个接口是直接创建一个长字符串,不会对其进行缓存。

 

luaS_new函数实现

每次调用 luaS_new() 接口获取字符串对象时,不管是长字符串,还是短字符串,都会先去全局变量G的一个二维表中查询一下,如果查询到该字符串对象,就直接返回,如果查询不到,就会创建一份出来,返回给用户,与此同时也缓存到二维表中,加快下次访问速度。可以得出结论,这个二维表存在的目的,就是做一层缓存,加快访问字符串速度,而且只会存储最近访问的106个字符串。如图:

 

查询g->strcache[53][2]二维表过程

先根据字符串地址,对行号取模,获取对应的行号n,然后再去for循环查找对应的列,是否存在该字符串,如果存在,直接返回,如果不存在,则直接调用luaS_newlstr() 函数创建新的字符串对象,然后把二维表的第n行第0列字符串移动到下一列,腾出的位置 [n][0] 插入新创建的字符串对象。下次再获取时,就可以直接从二维表中查找获取到字符串对象。

/*
* 创建字符串:如果在 global_state 的 strcache 中已经存在,就复用;否则就新建。
*/
TString *luaS_new (lua_State *L, const char *str) {
  unsigned int i = point2uint(str) % STRCACHE_N;  /* hash */
  int j;
  TString **p = G(L)->strcache[i];
  for (j = 0; j < STRCACHE_M; j++) {
    if (strcmp(str, getstr(p[j])) == 0)  /* hit? */
      return p[j];  	/* 在 cache 中找到相同的字符串。that is it */
  }
  /* normal route */
  for (j = STRCACHE_M - 1; j > 0; j--)
    p[j] = p[j - 1];  	/* 移动元素。move out last element */

  /* 新元素插入到最前端。 new element is first in the list */
  p[0] = luaS_newlstr(L, str, strlen(str));
  return p[0];
}

luaS_newlstr() 实现

通过这个接口获取字符串时,会先判断字符串是否是长字符串,还是短字符串(依据是字符串长度是否超过40)

长字符串的创建方式:先向系统中申请一块内存,大小为字符串长度+sizeof(结构体TString)。然后初始化 TString 结构体,并标记类型 tt 为长字符串后返回,不进行缓存。

短字符串创建方式:先去全局变量g的 strt 哈希表中查找是否存在,如果存在该字符串对象,直接返回,如果不存在,则从系统中申请一块内存,大小同样是字符串长度+sizeof(结构体TString)。然后初始化结构体 TString,并计算字符串的hash值,记录到 TString->hash 字段中,标记类型 tt 为短字符串。然后插入到哈希表 g->strt 中。

TString *luaS_newlstr (lua_State *L, const char *str, size_t l) {
  if (l <= LUAI_MAXSHORTLEN)  /* LUAI_MAXSHORTLEN 值为40 short string? */
    return internshrstr(L, str, l); // 短字符串
  else {   // 长字符串
    TString *ts;
    if (l >= (MAX_SIZE - sizeof(TString))/sizeof(char))
      luaM_toobig(L);
    ts = luaS_createlngstrobj(L, l);
    memcpy(getstr(ts), str, l * sizeof(char));
    return ts;
  }
}

 g->strt 哈希表介绍

只要是短字符串都会在内部缓存起来,那里存储用到的数据结构就是 g->strt 哈希表(数组+链表,链地址法实现)。查询过程也比较简单,计算出短字符串哈希值,然后模数组长度,看看在哪个槽位,然后循环遍历这个槽位下的链表,对比链表中的每个字符串,如果要创建的字符串对象在链表中存在,则直接返回,不存在,则先创建一个新的字符串结构体,然后插入到哈希表对应的槽位中。

g->strt 哈希表扩容

触发时机:

  1. 在查询哈希表中,不存在短字符串对象时,就会先去判断,缓存的短字符串对象个数是否超过了哈希表的长度,且哈希表长度没有超过 MAX_INT/2 大小时,就触发一次扩容,扩容大小为原先的2倍。
  2. 在创建初始化虚拟机时,会预先扩容,哈希表数组大小为128。
  3. 在gc时,会检查字符串对象个数是否小于哈希表大小的1/4,如果满足,就会对哈希表缩容,大小为原先的一半。
//internshrstr() 函数
if (g->strt.nuse >= g->strt.size && g->strt.size <= MAX_INT/2) {
    luaS_resize(L, g->strt.size * 2);
    ...
}

实现:

重置的实现也比较简单,先根据需要的大小,对数组部分进行原地扩容或者缩容。然后对数组的每一个槽位,不为NULL的链表取出来,然后把槽位置NULL,这一步很关键,因为如果不把槽位置空,对取出的字符串对象链表重新rehash,就有可能导致死循环,比如,某个槽位存的是链表a->b->c->NULL,如果不对槽位置NULL,就rehash,那么有可能a会rehash到之前的槽位,然后指向槽位的第一个元素a,这样就变成了自己指向自己,最终在槽位中形成的链表就是 c->b->a->a,那么下次查询字符串对象a时,就会出现死循环了。

如果g->strt不是原地扩容,而是创建一份新的内存来对旧的所有字符串对象来rehash,那么就不需要对旧的槽位置NULL了。当然原地扩容,这种扩容方式性能更好。

void luaS_resize (lua_State *L, int newsize) {
   ...
  // 由于字符串所在位置是根据表长来计算的,但表长变成 newSize 时,需要将整个哈希槽位的源字符串重新排列,计算位置。
  for (i = 0; i < tb->size; i++) {  /* rehash */ 
    TString *p = tb->hash[i];
      
    tb->hash[i] = NULL; // 这个得先将槽位值NULL,才能对p链表重新rehash
      
    while (p) {  /* for each node in the list */
      TString *hnext = p->u.hnext;  /* save next 暂存 next 节点*/
      unsigned int h = lmod(p->hash, newsize);  /* new position 重新计算应该放到哪个哈希槽位中 */
      p->u.hnext = tb->hash[h];  	/* chain it. 节点 p 的 next 指向另一个槽位的第一个节点 */
      tb->hash[h] = p;   			// 然后把 p节点指向另外一个槽位的开始位置
      p = hnext;
    }
  }
  ...
}

TString结构体

typedef struct TString {
  CommonHeader;   	// 需要进行GC的数据类型的通用头部结构 
  lu_byte extra;  	/* reserved words for short strings; "has hash" for longs */
  lu_byte shrlen;  	/* length for short strings */
  unsigned int hash;
  union {
    size_t lnglen;  /* 长字符串长度 length for long strings  */
    struct TString *hnext;  /* 指向下一个短字符串 linked list for hash table  */
  } u;
} TString;

TString结构体包含了短字符串需要到的字段,也包含了长字符串需要到的字段。

如果当前TString 表示的是一个短字符串,长度用 shrlen 表示,因为短字符串长度不会超过40,所以用一个无符号char表示长度足够了,hnext 字段就是在 g->strt 哈希表中使用到,用来构成链表,而 lnglen 字段则不会使用到。

如果当前TString 表示的是一个长字符串,那么 lnglen 表示长字符串长度,而shrlen,hnext字段不会被使用到。

 

hash字段

对于hash字段,我们知道,一个字符串对应一个hash值。而且再算多次hash值也是不会变的。而短字符串和长字符计算hash值的时机会不一样。

短字符串会在创建时计算一次,然后赋值给hash,因为短字符串往往会做为一个表中的key,所以预先计算hash值。

长字符串创建时,hash字段只是赋值为g->seed,不会进行计算,因为长字符串计算哈希值,可能会相对短字符串消耗更多的时间,而且也不一定会做为表中的key,所以没必要预先计算,得需要的时候再计算一次,就好了,然后缓存到 hash 字段中。

 

extra字段

针对短字符串 ,标识当前字符串是不是lua的保留字。

针对长字符串 ,标识字符串是否已经计算过hash值,1计算过,0没有。

 

小结

lua字符串,在底层用TString结构体表示,而且调用创建接口luaS_new 时,会先判断是否缓存是否有字符串对象,有就返回,没有就继续调用 luaS_newlstr() 接口,这个接口又会根据字符串长短来做一些划分,比如短字符,需要进行缓存,需要计算hash值,长字符串则直接从内存中申请,不缓存,hash值计算也是在需要做为表中key的时候,才会去计算一次。所以说,长字符串有可能同时存在多份,而短字符串只要存在一份。短字符在内部用哈希表缓存,用链地址法解决健冲突,扩容或者缩容时,就地对哈希表进行扩容缩容,而不是新创建一块,把旧的rehash过去。