• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

Lua数据结构和内存占用分析

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

  由于lua是一个跨平台的脚本语言,会根据平台位数(16bit\32bit)、平台类型(linux\windows)、语言标准(C89\C99)、以及编译参数等开启预编译选项,导致基本数据结构的字长和类型会动态变化,以linux_ x86_64 进行编译为基础进行分析介绍,lua版本5.3.4。并根据我们开发过程中一些常见的情景进行分析:

1、基础数据结构

  Lua的基本数据表示方式是type + union的方式,根据不同类型映射到union的不同结构上面统一的表示结构lua_TValue

   typedef union Value {
     GCObject *gc;    /* collectable objects */
     void *p;         /* light userdata */
     int b;           /* booleans */
     lua_CFunction f; /* light C functions */
     lua_Integer i;   /* integer numbers */
     lua_Number n;    /* float numbers */
   } Value;
   
   struct lua_TValue {
     Value value_; 
       int tt_;
   } TValue;

 

  但由于lua_Integerlua_Number在当前平台预处理后,分别定义成long long 和 double类型,所以为方便理解,上述结构经过转义:

   typedef union Value {
     GCObject *gc;    /* collectable objects */
     void *p;         /* light userdata */
     int b;           /* booleans */
     lua_CFunction f; /* light C functions */
     long long i;     /* integer numbers */
     double n;       /* float numbers */
   } Value;
   
   struct lua_TValue {
     Value value_; 
       int tt_;
   } TValue;

  

  在lua5.1版本中,统一使用lua_Number来表示整数和浮点数,而double能够表示的整数大小有限,大概2^52的长度,所以用lua_Number表示一些类型为int64_t的全局唯一id长度不够,类似物品id、角色id。在lua5.3中,上述问题不再存在。

  lua简单数据类型bool、整型、浮点型都是统一用lua_TValue来表示,消耗内存为sizeof(lua_TValue) = 16字节。因此相对C\C++表示整型的char\short\int\int64_t来说,这种数据结构的表示法虽然比较统一,但比较消耗内存。还有一些复杂的数据结构,统一封装在GCObject中。由于5.15.3表示法略有差异,5.1方便理解,如下:

   union GCObject {
     GCheader gch;
     union TString ts;
     union Udata u;
     union Closure cl;
     struct Table h;
     struct Proto p;
     struct UpVal uv;
     struct lua_State th;  /* thread */
   };

 

2、数据结构的内存占用

  在开发过程中,最常用的数据结构是stingtable类型,那么现在主要分析这两种数据结构的内存占用。

2.1 string 类型

  String又细分为短字符串LUA_TSHRSTR和长字符串LUA_TLNGSTR两种,默认长度小于40的为LUA_TSHRSTR,使用全局stringtable进行管理。即所有短字符串都在stringtable中存放,相同字符串只会有一份实际数据拷贝,每份相同的TString对象只是存放一个hash值,用来索引stringtable。而长字符串则跟普通的GCObject没有差别,相同字符串在内存都是单独一份数据拷贝。在Lua5.1中,没有区分长短字符串,所有的字符串统一在stringtable中存在唯一拷贝。猜想这种改变一是因为长字符串出现相同的情况比较少,二是lua5.1的方式长字符串TString计算Hash是抽取部分字符进行运算,这样的计算方式可能被伪造导致不同字符串的hash值一样,但要是所有字符全用来计算hash又比较耗时。

  下面是一个string类型的GCObject的内存表示,根据长短字符串表示方式不一样:

 

 

2.1.1 内存占用分析实践

  下面分别对比四种情景来分析内存占用的不同。因为lua在使用 .. 连接字符串时,底层会调用luaV_concat -> luaS_newStr新建字符串,这里对比下在创建短字符串和长字符串后的内存消耗:

 

Lua5.3.4

情景1

相同短字符串

collectgarbage("stop");      --close auto gc

collectgarbage("collect")     --full gc

local before = collectgarbage("count");

for i = 1, 10000 do

    local string = "100000000000000000000000" .. "0"

end

 

local after = collectgarbage("count");

print("short same mem cost:" .. (after - before) .. "K" )

short same mem cost:0.580078125K

情景2

相同长字符串

 

collectgarbage("stop");     --close auto gc

collectgarbage("collect")    --full gc

before = collectgarbage("count");

for i = 1, 10000 do

    local string = "10000000000000000000000000000000000000000000000000000" .. "0"

end

 

after = collectgarbage("count");

print("long same mem cost:" .. (after - before) .. "K" )

 

 

long same mem cost:771.48K


  通过对比:在创建相同字符串时,因为短字符串在StringTable只有一份拷贝,而长字符串每次都会产生新的数据拷贝,所以消耗内存差异明显。 0.58K  VS  771.48K 

 

Lua5.3.4

情景3

不同短字符串

 

collectgarbage("stop");      --close auto gc

collectgarbage("collect")    --full gc

local before = collectgarbage("count");

for i = 1, 10000 do

    local string = "100000000000000000000000" .. i

end

 

local after = collectgarbage("count");

print("short diff mem cost:" .. (after - before) .. "K" )

 

 

short diff mem cost:1052.62109375K

情景4

不同长字符串

 

collectgarbage("stop");      --close auto gc

collectgarbage("collect")    --full gc

for i = 1, 10000 do

    local string = "10000000000000000000000000000000000000000000000000000" .. i

end

 

after = collectgarbage("count");

print("long diff mem cost:" .. (after - before) .. "K" )

 

 

long diff mem cost:1145.82421875K


通过对比:在创建不同字符串时,不论长短字符串,都会有一份不同字符串的拷贝,所以都需要消耗大量内存。1052K  VS  1145K

 

问题一在对比情景2和情景4时,长字符串都是一份拷贝,为什么内存消耗差异较大?(791K vs 1145K)

答:这是因为使用连字符 .. 时,情景4里面的变量i转为字符串,相当于生成了10000份不同的短字符串拷贝,导致多消耗了300K左右内存,所以在开发中可以适当避免 .. 产生大量字符串的产生。可以考虑使用string.format来格式化字符串。具体效果如下:

Lua5.3.4

 

collectgarbage("stop");      --close auto gc

collectgarbage("collect")    --full gc

for i = 1, 10000 do

    local string = string.format("100000000000000000000000000000000000000000%d", i)

end

 

local after = collectgarbage("count");

print("mem cost:" .. (after - before) .. "K" )

 

 

mem cost:799.7K (结果比较接近情景2)

 

2.1.2 预估字符串消耗

问题二:以情景2为例,最终内存消耗771.48K,这个可以在开发前估算吗?

答:string实际是GCObject对象,所以有些公共的字段。以”Lua”这个字符串为例,TString的内存结构:

  在情景2中,实际字符串长度为54。那么内存占用 = sizeof(TString) + 1 + 实际长度 = 25 + 实际长度 = 25 + 54 = 79。理论内存:10000 * 79/ 1024 = 771.48K, 理论跟实际消耗值几乎一致。

 

2.1.3 与lua5.1.4的对比

  但由于我们之前也用过lua5.1.4,这个版本所有字符串均引用StringTable一份数据拷贝,理论上不论长短字符串,只要相同的数据只会有一份拷贝。(由于lua5.1.4版本在调用collectgarbage("collect")进行一次完整gc后,luaC_fullgc会调用setthreshold打开自动gc,这里容易踩坑,上述代码运行发现内存消耗明显低于lua5.3版本,非常困惑。所以修改代码在collectgarbage("collect")后,重新调用collectgarbage("stop")关闭自动gc)。修改后在lua5.1.4下面执行对比如下:

 

Lua5.3.4内存消耗

Lua5.1.4内存消耗

情景1

相同短字符串

0.580078125K

0.048828125K

情景2

相同长字符串

 

771.48K

0.0966796875K

情景3

不同短字符串

 

1052.62109375K

1052.62109375K

情景4

不同长字符串

 

1145.82421875K

1305.828125K


  最大差别在于相同长字符串创建的内存消耗,Lua5.1.4由于统一用stringtable保存一份拷贝,所以内存消耗可以忽略不记,但Lua5.3.4由于会产生不同副本拷贝,消耗明显。

 

2.1.4 table重复字符串key对内存影响?

对于短字符串,只会在stringtable中存在一份拷贝,对内存没有什么影响。对于长字符串,需要分两种场合看:

for i =1, 1000 do

Tab[i] = {ccccccccccccccccccccccccccccccccccccccc=i}

end

或者

Tab[1] = {ccccccccccccccccccccccccccccccccccccccc=1}

Tab[2] = {ccccccccccccccccccccccccccccccccccccccc=2}

Tab[3] = {ccccccccccccccccccccccccccccccccccccccc=3}

...

Tab[100] = {ccccccccccccccccccccccccccccccccccccccc=100}

对于这两种情况,由于lua程序进行代码文件词法分析时,第一种调用一次luaS_newlstr创建”ccccccccccccccccccccccccccccccccccccccc”, 而第二种会调用100次。正如我们前面的分析,长字符串每次调用都会生成一份新的拷贝,所以第二种情况会有N份的字符串内存消耗。

我们服务器的表资源,转表后都是按第二种形态存在,所以一定要切记key的字符串长度不要超过40,否则产生无谓的内存浪费和大量的GC对象,影响GC效率。

 

2.2 table类型

  在开发过程中,table是最常见的数据结构,每条记录对外都是key-value的方式来读写。他的底层是用array + hashtable的方式管理数据的,但对外是透明的。不论array还是hashtable都是连续的内存分布。在查找时:

  1. 如果key是整型并且 key > 1 and key < max_array_size, 直接取array[key]数据

  2. 其他情况,默认读取hashtableHashtable的管理方有些特别,当不同key hash到同一个node时,用链表来维护这些冲突节点。与stringtable 链表节点动态分配的方法不同, HashTable使用空闲链表来维护冲突节点。

 

2.2.1  Array or HashTable

  首先说一种典型的情况,调用table.insert 或者table[#table + 1] 按序插入列表的,就是存放在array里面。

 

2.2.2 rehash动态扩容原理

  在插入新节点时,如果通过freelist找不到可用节点就触发rehash。在新创建table时,node节点数量为0,当插入第1条数据时触发内存分配2^0=1个内存节点。当插入第2条记录时,因为节点都已经使用,所以又触发rehash,分配2^1=2个内存节点。依次类推,在插入第359时,触发分配一个更大的内存,可以简单理解成重新分配一块oldsize * 2 的内存,把原有的hashTable的数据重新hash插入到新的内存中,再释放掉原有的内存。综上,每次内存的增长都是按2的指数倍来增长。

1Rehash导致的cpu消耗

使用2的指数倍扩容,针对单个表扩容其实并不频繁,比如100W个节点,最多扩容20次,2^20 > 100W。只是在表的前期会相对频繁,12359条记录插入会触发扩容,所以要避免小表的频繁创建插入。

预先填充方式

a = os.clock()

for i = 1, 1000000 do

    local tab = {["1"] = 1, ["2"] = 2, ["3"] = 3, ["4"] = 4, ["5"] = 5}

end

b = os.clock()

print(“timediff:”, b - a)

 

timediff: 0.53

动态扩容方式

a = os.clock()

for i = 1, 1000000 do

    local tab = {}

    tab["1"] = 1

    tab["2"] = 2

    tab["3"] = 3

    tab["4"] = 4

    tab["5"] = 5

end

b = os.clock()

print(“timediff:”, b - a)

 

timediff: 1.68

  分析:可以看到预填充方式可以避免频繁的表扩容,cpu消耗是动态扩容的1/3。使用预填充的方式,在创建tab时,会直接分配一个8个元素的Node节点的内存,存放数据。而采用动态扩容的方式,第一次分配1个元素,然后随着数据的插入,会扩容到2个元素的内存,把原来数据重新hash到新分配的内存,释放老内存。同理,随着数据的插入,继而扩容到4个元素、扩容到8个元素...

 

2rehash导致内存消耗演示

tab = {}

text = "diff mem"

collectgarbage("stop")

before = collectgarbage("count");

for i = 1, 100 do

    tab[10000 + i] = i

 

    local cur = collectgarbage("count")

    print(text, cur - before)

    before = cur ;

end

 

 

因为hashtable数据分配是012481632这些内存大小,所以在插入数据时分别在123591733时内存不够触发扩容,如上红色标记的点。可以看到在红色标记的点上,内存会有大幅扩大,而且基本是上一次大幅扩容的2倍关系。

 

问题一:在上述截图中,为什么在101834这些地方出现了额外的内存消耗?

10节点来说明,这是因为在插入第9条数据后,print打印,系统创建了一个”0.2841796875”字符串,导致节点10在统计消耗的时候计算进去了,具体消耗长度为 25 + 实际长度= 25 + 12 = 37字节,与节点10的消耗吻合。同理节点18的消耗为25 + len(“0.5”) = 28

 

问题二:因为节点101834本身也print打印了字符串,怎么它后面的111935就没有消耗内存?

11为例,理论上节点10打印的”0.0361328125”会消耗内存,但这个字符串在节点6已经创建过了,由于所有短字符串都在stringtable进行管理,只有一份拷贝,所以不再消耗内存。

 

问题三:为什第一个节点内存消耗明显偏高?

这是因为for循环里面local cur = collectgarbage("count")调用,触发了Lua_state->stack指向的内存空间的动态扩容,原理有点类似HashTable内存不够的再分配机制。

综上三个问题,可以把代码调整一下再看:

tab = {}

text = "diff mem"

collectgarbage("stop")

before = collectgarbage("count");

for i = 1, 100 do

    if i == 1 then before = collectgarbage("count"); end

    tab[10000 + i] = i

 

    local cur = collectgarbage("count")

    print(text, cur - before)

    before = collectgarbage("count");

end

 

  可以看到,在1235917等位置触发内存再分配,对应重新分配的内存是2^0=12^1=22^2=481632大小。NewSize-OldSize差值即新增内存,分别为1-0=12-1=14-2=28-4=416-8=832-16=16,而单个节点大小为0.03125K,整体每次新增内存与上面截图完全吻合。

 

2.2.3 估算table的内存消耗

  创建一个最简单的表{}, lua在调用luaH_new初始化时并不会动态分配arrayhashtable指向的内存,只是创建一个最简单的table管理结构,所以消耗的内存为sizeof(Table) = 56

1、估算tablehashtable的内存消耗

每个Node节点消耗sizeof(Node) = 32。所以对于简单数据(整型、浮点、bool)相对比较好估算,sizeof(Table) + sizeof(Node) * NodeNum。 例如上面的代码,100条记录实际分配的Node节点2^7=128,就是56 + 32 * 128 = 4152

collectgarbage("stop");

before = collectgarbage("count");

tab = {}

for i = 1, 128 do

    tab[10000+i] = i

end

after = collectgarbage("count");

print("total mem:", (after - before)*1024)

 

total mem:      4152.0

 

2、估算table中有序数组的内存消耗

对于简单表tab = {}, 一般都是使用tab[#tab + 1] = xxxx 或者 table.insert(tab, xxx) 来插入数据,对于这种典型的场景,table使用的是array的方式存储数据。计算方式,sizeof(Table) + sizeof(TValue) * ArrayNum,其中sizeof(TValue) = 16。同样下面的代码,略作调整,100条记录实际的分配的Array节点也是128, 就是56 + 16 * 128 = 2104.

collectgarbage("stop");

before = collectgarbage("count");

tab = {}

for i = 1, 128 do

    tab[#tab + 1] = i

end

after = collectgarbage("count");

print("total mem:", (after - before)*1024)

 

total mem:      2104.0

 

3、估算table嵌套类型的消耗

上面都是用的简单数据类型进行估算,现在考虑下当valueGCObject对象的情况(字符串、表、闭包)。针对一个Node节点,数据组成:

struct Node {

TValue i_val;

  TKey i_key;

} Node;

因此,对于简单的数据类型,TValue里面的结构字段足以存储,但对于复杂类型,TValue里面只是存了一个对象指针GCObject*,而对象本身的内存大小还得单独核算。先看一个简单:

collectgarbage("stop");

before = collectgarbage("count");

tab = {}

tab[10000] = {i}

after = collectgarbage("count");

print("total mem:", (after - before)*1024)

total mem:      160.0

A、tab表 sizeof(Table) + sizeof(Node) * 1 = 88

B、{i}使用array来存储,实际消耗 sizeof(Table) + sizeof(TValue) * 1 = 72

C、所以整体消耗: 88 + 72 = 160

 

4、估算复杂table的数据内存消耗以及与C语言的对比

以一个简单的排行榜列表来进行多维度计算,预计10000条数据,以玩家uid作为key,每条记录存积分和名次。分多个场景进行评估。

场景一:

collectgarba


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
物理世界和精灵,cocos2dx-lua发布时间:2022-07-22
下一篇:
用Lua控制Nginx静态文件的url权限访问发布时间:2022-07-22
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap