Lua5.3源码阅读记录(3) ---- 字符串

本文深入探讨Lua5.3的字符串实现,包括TString数据结构,短字符串的全局存储结构stringtable,字符串创建、保留字符串的处理,以及版本变动背后的原因。详细介绍了字符串如何在内存中存储,以及如何通过优化hash值提升查找效率,避免Hash Dos攻击。

数据结构 TString

存储一个字符串,从数据结构的角度来看 需要存储两个数据,一个是 字符串的内存地址 ,一个是字符串的长度 ,先看一下 在lobject.h中的存储字符串类型的数据结构 TString  

CommonHeader : 需要进行GC的数据类型的通用头部结构 

extra : 针对短字符串 ,标识当前字符串是不是lua的保留字 针对长字符串 ,标识字符串是否已经计算过hash值

shrlen: 短字符串长度

hash: 字符串的散列值, 用于字符串比较的优化

常规比较字符串是根据字符串长度进行逐位比较 时间复杂度与字符串长度线性相关 ,因为lua中字符串是被内化的数据类型 ,字符串的查找、比较操作较多 ,所以lua记录了一个散列值 ,使用散列值进行比较(性能优化)

u -> lnglen : 长字符串长度

u-> hnext :指向下一个短字符串

从上面的TString结构可以看出 ,lua源码中将字符串分为短字符串 和 长字符串 两种。有些人看了这个结构体或许会有一些疑问 ,这个结构体里怎么没有存储字符串的成员,那它怎么存字符串啊 ?? 其实英语比较好的同学已经看出来了, 这个结构上面的注释写的很清楚 (Header for string value; string bytes follow the end of this structure )大概意思就是TString结构体只是字符串类型的头,真正的字符串紧跟在这个结构体后面 ),那新的问题又出来了 ,如何从一个TString获取到它对应的字符串 ??正常情况下,想要获取一个字符串 ,需要指向字符串内存数据的指针 和 字符串的长度,字符串的长度已经在TString里了,指向字符串内存数据的指针要怎么获取呢, 这要是放在某些语言里你还真获取不到 ,但是C就不一样了,既然它说真正的字符串紧跟在这个结构体后面,假设有TString* ts , ((char*)ts+sizeof(TString))就是指向字符串的指针了 。理论上确实如此,但是在lua源码里却有一些出入,下面是根据TString *ts取字符串的源码:

上图的宏定义是lua中通过一个TString*类型的ts ,来获取对应字符串的源码 ,在没有断言的情况下,该宏定义可以简化为 #define getstr(ts) ((cha*)ts + sizeof(UTString) ) .怎么又蹦出来一个UString ? 我们来看一下UTString的定义

UTString的定义如上图所示 , 其实定义联合体UTString是单纯为了加快lua中字符串的存取效率,至于为何能加块效率 ,需要理解我另一篇博客《数据对齐》,里面有这样一句话 :cpu不是一个字节一个字节的计算位置 ,以32位来看cpu是以4个字节 4个字节来寻找 ,读写总是从CPU数据访问粒度的倍数地址开始的 ,0,4,8...... 等等 ,地址对齐可以提高cpu的效率。按照我的理解 定义UTString是因为TString其实是一块连续内存区域的头部,后面紧跟实体数据。为了保证访问实体数据时候字节对齐,必须让实体数的起始地址是平台最大数据长度的整数倍。好比malloc返回的地址在32位平台上是8的整数倍,在64位平台上是16的整数倍一样,毕竟存取字符串操作是一个很频繁的操作 ,效率不得不重视(也不知道理解的对不对)

 

stringtable (短字符串的全局存储结构)

在 Lua中短字符串是被内化的 ,什么是内化 ? 简单来说 , 每个存放Lua字符串的变量 ,实际上存放的只是字符串数据的引用,lua中有一个全局的地方存放着当前系统中所有字符串,每当创建一个新的字符串,首先去查找是否已经存在同样的字符串。有的话,直接将引用指向已经存在的字符串数据 ,否则在系统内创建一个新的字符串数据,复制引用 。 这样做的优点是: 节省字符串的占用空间 简化字符串的比较(只需要比较引用是否一样),缺点是:每次创建新字符串都要遍历全局的存储结构,来判断是否已经存在相同字符串,字符串的查找比较相当频繁,所以TString中存储了hash值来优化字符串的查找、比较。 lua中的短字符串全局存储结构是一个哈希桶,数据结构如下:

hash: 指向哈希桶的指针数组   
nuse:当前哈希桶内的字符串数量 
size :当前哈希桶的字符串容量

有些同学可能对双重指针不是很熟悉 ,比较好的方法是 分成前后两部分看 : TString * 和 * hash (*hash)说明hash是一个指针变量, (TString*) 说明hash指向的是TString* 类型的数据 , 也就是说 hash指向的是一个TString 结构指针变量,如下图所示,很详细的说明了stringtable的结构 :

与之相关最常用的操作是重新调整哈希桶 (LuaS_resize)

上图的注释其实已经很清楚了,需要补充的是 ,第76行的luaM_reallocvector 是一个好几层的宏定义 ,最终调用了lmem.c里面的通用内存分配函数 luaM_realloc_ 。其中有一层宏定义如下图

该宏定义的作用是重新分配内存 从内存地址b开始 重新分配n*e的 字节内存 ,((sizeof(n) >= sizeof(size_t) && cast(size_t, (n)) + 1 > MAX_SIZET/(e) 用于参数检测 ,由于luaM_realloc_的原型参数是 (lua_State *L, void *block, size_t osize, size_t nsize) 必须保证 n*e on*e的数值在size_t的取值范围之内 ,否则报出错误“memory allocation error: block too big”

再看一下,通用内存分配函数luaM_realloc_ ,由于其中涉及了一些GC方面的东西 ,需要后面再说。说明一下 ,这里使用global_State里的frealloc指向的函数来进行内存申请,而frealloc是与global_State一起初始化的 ,函数原型如下,从下面的源代码中可以看出参数中的ud ,osize暂时并没有用到

 

 

在lua源码中触发luaS_resize的地方有三个 :

1.lstring.c 中的 luaS_init: 初始化哈希桶 ,大小是宏定义 MINSTRTABSIZE ,在5.3中等于128,用于存储lua的保留字以及其他库文件使用到的保留字

2. lstring.c中的 internshrstr:如果哈希桶中的字符串数量nuse 超过当前容量size, 并且 当前size 小于或等于 MAX_INT/2 则将stringtable扩容到原本的2倍

3.lgc.c 中的checksize :进行检查,如果当前哈希桶实际存储字符串的数量nuse小于容量size的四分之一,则将哈希桶的容量缩减为原来的二分之一

 

创建字符串 

很明显,lua根据创建的字符串长度是否大于 LUAI_MAXSHORTLEN 来区分 是长字符串或短字符串,两者的逻辑是不一样的 。LUAI_MAXSHORTLEN是定义在limits.h里的一个宏定义,lua5.3暂时将其定为40,我们可以改动这个宏,但是由于lua中的保留字和 元方法名一定需要被内化,所以LUAI_MAXSHORTLEN 一定要大于 最长的保留字"__newindex" 和 元方法名 "function",也就是要大于等于10.首先来看一下短字符串是如何创建的 .

上面是我当时注释都是我一边看一边写的 ,虽然写的不是很清晰,但结合着源码很容易就能看懂(将就看一下,实在不想看,看下面的总结也行 )总结一下步骤

1.172 ~173行 根据字符串和全局的一个随机种子计算出hash值 ,再根据hash值计算出对应哈希桶的链表

2.175~185行 遍历第一步的链表,查找是否已经存在一样的字符串 ,存在的话直接返回引用

3 .186~190行 查看哈希桶的容量是否够用,不够的话进行扩容

4 . 191~194行 创建TString结构体 ,将字符串复制到该结构体后面 ,将字符串的长度赋值给成员shrlen。

5. 195 -~197行 插入到链表头处 。

这里需要注意的是luaS_hash的计算方法,这个我们放在最后再讲 ,至于 createstrobj 里则通过一系列的调用最终是通过我们上面说到的通用内存分配函数luaM_realloc_进行

内存分配的,这里就不一一分析代码了 ,放上带注释代码看一下

再往上回溯 ,对于长字符串的处理相对比较简单 ,除了没有内化之外 ,长字符串的hash值是惰性计算的。如下图,创建了TString对象,将全局随机种子seed存储在hash成员变量中 ,再下面一张图是长字符串惰性计算的代码,利用了原本存在hash变量里的 seed值 ,另外可以看到 extra在长字符串中用于标识是否已经计算过hash值

 

保留字符串

首先保留字符串一定是短字符串 ,前面提到过 TString中的extra对于短字符串用来标识是不是保留字符串,创建TString结构体时统一赋值为0 ,对于保留字符串在初始化时会赋值为大于0的数值

LuaX_tokens 里存储着lua中的关键字符 

 

版本变动以及原因 

在lua5.2.0之前 ,字符串不分长度 一律内化后存放在全局的哈希桶中 。lua5.2.0之前的hash值计算如下

5.3版本的hash值计算 

 之所以要修改的原因是5.2版本的hash值计算方式对于长字符串来说很容易被Hash Dos攻击,那什么是Hash Dos呢,简单来说 Hash Dos就是利用hash算法中的"非随机性"来构造出众多value不一样但是 key值一样的数据 ,这样对于哈希桶这种数据结构来说就会导致所有的字符串都会存储在一个链表中从而使哈希桶退化成一个单向链表 以至于数十倍的降低字符串处理的效率 。

了解Hash Dos之后 ,我们再来看看lua5.2之前的hash值计算源码,其中对于长字符串,hash值是跳跃进行计算的,并且跳跃值为 5 。举个例子 ,对于一个34位长度的字符串,hash步长step为2,那么意味着每两位字符只有一位参与hash值的计算,只要保证参与的hash值计算的字符相同,很容易就能构造出大量 hash值相同的字符串 。例如 "0000000000000000000000000000000000" 、 "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0" 、"b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0" 这三个字符串的hash值都是 2009774107。为了解决这个问题 后面的lua版本 ,将长字符串独立出来。长字符串不再通过哈希内化进入全局哈希桶。同时,使用了一个全局随机种子用于哈希值的计算,使外界无法轻易构造出拥有相同哈 希值的不同字符串。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值