首页 > 代码库 > Redis 一、数据结构与对象--五大数据类型的底层结构实现

Redis 一、数据结构与对象--五大数据类型的底层结构实现

redis上手比较简单,但是它的底层实现原理一直很让人着迷。具体来说的话,它是怎么做到如此高的效率的?阅读Redis设计与实现这本书,可以很好的理解Redis的五种基本类型:String,List,Hash,Set,ZSet是如何在底层实现的。还可以了解Redis的其他机制的原理。我们现在来看看Redis中的基本的数据结构吧。

简单动态字符串

Redis的简单动态字符串,通常被用来存储字符串值,几乎所有的字符串类型都是SDS的。另外,SDS还常被用作缓冲区(buffer):AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区等,都是由SDS实现的。

基本结构

redis里面很多地方都用到了字符串,我们知道redis是一个键值对存储的非关系型数据库,那么所有的key都是用字符串存储的,还有字符串类型,这些都是用字符串存储的。甚至包括序列化操作Dump和Restore,也是将对象序列化为字符串之后好进行数据的传输。那么redis的字符串是怎么实现的呢、

Redis的底层是C++实现的,我们知道C++的字符串是一个以\0结尾的char数组,字符串的长度为数组长度-1,但是redis并没有直接使用C++的char数组,而是自己实现了一个简单的结构,这个结构的名字叫做简单动态字符串(simple dynamic string)。这个结构的定义如下:
struct sdshdr{
    int len;
    int free;
    int buf[];
}

sdshdr就是我们所说的简单动态字符串的结构了,这个结构有三个变量,第一个变量是字符串的长度,是buf数组已使用的字节的数量,这个长度和C++中的长度不同,这个长度就是字符串的长度,而C++中的因为还有一个字符串结尾\0,所以长度比C++中的少1。
free是buf数组中未使用的字节的数量
buf数组用来存储字符串,这个存储的字符串不包含最后的\0结尾。这个\0结尾在初始化redis字符串时,由SDS函数自动添加,不计算在len里面。这也意味着我们可以直接使用这个buf数组   sdshdr s    print(“%s”,s->buf),因为已经将结尾\0添加到buf数组里面去了。buf数组的长度等于len+free+1(1是\0)

和C++字符串的比较

这样的好处是,1)相比C++的字符串,这个获取字符串长度的时间复杂度是常数,也就是O(1),不需要遍历字符串。2)而且,C++的数组是有缓冲区溢出和内存泄露的风险的,而SDS巧妙的解决了这个风险,解决的措施是SDS可以自动的对数组容量进行修改。比如进行字符串拼接操作,这个时候buf数组容量不够的时候,若buf数组小于1MB大小,会对buf数组的容量扩容到原来的两倍,如果大于1MB,那么程序会分配1MB的free空间,这叫做空间预分配,这样可以大大的减少因为多次空间不足导致的频繁分配空间的情况发生。而对于空间回收,Redis的SDS采用的是惰性空间释放,也就是说,当字符串数组buf存储的字符串内容变少时,并不立即回收空间,而是先将空间释放,修改free值(加上释放的空间),与此同时,你也可以调用redis真正释放空间的api来释放掉多于的空间。3)SDS是二进制安全的。由于\0操作是SDS函数自动添加到buf数组中的,所以buf数组中的特殊字符(包括\0)都将被视为其本身的含义,不需要转义符号的出现。4)兼容部分C字符串函数。

链表

列表键的底层实现之一

基本结构

Redis的链表也是自己实现的数据结构,因为C里面没有内置这种数据结构。
Redis的链表由链表和链表节点构成。链表封装了链表节点,提供相关操作API,链表节点则封装了每个节点的数据等相关信息。我们来看下他们的结构
typedef struct listNode{
    struct listNode *prev;
    struct listNode *next;
    void *value;
}listNode;
这是一个链表节点,从中我们可以看到,链表节点持有三个变量,前两个变量是两个分别指向上一个和下一个节点的指针,value变量存储了节点的值(多态存储)。虽然使用ListNode可以实现一个简单的链表,但是我们还是使用的list结构来封装所有的操作,这样操作起来会更方便。
typedef struct list{
    listNode *head;
    listNode *tail;
    unsigned long len;
    void *(*dup) (void *ptr)
    void *(*free)(void *ptr)
    int (*match)(void *ptr,void *key);
}list;
可以看到,这个list结构封装了头结点,尾节点和链表的长度以及若干操作,包括赋值操作,释放操作以及节点对比的操作。而且,Redis是一个双向无环链表,而且是多态的。

字典

Redis数据库就是使用字典来实现的

基本结构

字典,又称为符号表,关联数组,或者映射,是一种用于保存键值对的抽象数据结构。可以说Redis里所有的结构都是用字典来存储的。那么字典是如何来使先的呢?
字典的结构从高层到底层实现分别是:字典(dict),字典哈希表(dictht),哈希表节点(dictEntry)。
我们先来看看字典哈希表和哈希表节点
typedef struct dictht{
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,
    //总是等于size-1,
    //用于计算索引值
    unsigned long sizemask;    

    //该哈希表已有的节点的数量
    unsigned long used;
}dictht
注释已经很好的解释了每个变量的含义,下面我们来看看dictEntry的结构类型
typedef struct dictEntry{
    //键
    void *key;
    //值
    union{
        void *val;
        unit64_tu64;
        int64_ts64;
    } v;
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;

}dictEntry;
可以看到,这个哈希表的结构使用的是拉链法来实现的, 拉链法既可以实现基本的哈希表结构,也能用来避免冲突。
技术分享

我们知道了底层的字典是如何实现的之后,我们再来看看Redis中的字典dict结构是如何来实现的吧。
typedef struct dict{
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2]
    //rehash索引
    //当rehash不在进行时,值为-1
    int trehashidx;
}dict;
type属性和privdata是为了针对不同类型的键值对,为创建多态字典而设置的,其中type属性指向的是一个dictType结构的指针,每个dictType结构都保存了一系列用于操作特定类型键值对的函数,比如字典的增删改查操作,redis会为用途不同的字典设置不同的类型特定函数。而privdata数组则保存了需要传给那些特定函数的可选参数,了解即可。

ht数组有两项,每一项都是一个dictht哈希表,dictht表我们已经在前面介绍过了,因此不再多说。为什么会有两项呢,这是因为方便再散列(rehash),接下来我们会详细的说redis字典再散列的过程。字典会默认使用ht[0],另外一个ht[1]是用来再散列的。下图是一个常规状态下(没有再散列)的字典结构图



技术分享





redis字典的哈希算法:

哈希算法比较简单,采用的是按位与的操作,首先使用dict的type的一个函数hashFunction(key)对key计算一个hash值,然后将这个哈希值和字典的某个哈希表dictht的sizemask按位与计算出索引位置。见下图

技术分享
redis的字典是使用拉链法解决冲突的。

再散列Rehash

redis的字典随着不断的增加(减少)元素,会导致装载因子慢慢的变大(变小)(装载因子=元素个数/ht[0].size,也就是哈希表内实际元素个数和容量的比值,拉链法允许比值大于1),这个时候需要调整数组ht数组的大小,调整的基本方式是:
1)为字典的ht[1]分配空间(一开始空的嘛,对吧),然后分情况讨论:
  • 如果是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方
  • 如果是缩小操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方
2)分配完空间后,那么就是将ht[0]上面的数据移动到ht[1]上面去了,这个移动的过程中还是需要前面的哈希算法计算索引值,将数据移动到新的索引上。
3)接下来,那肯定就是回收空间了,回收ht[0]的空间,然后将ht[1]设置为ht[0],ht[0]设置为ht[1]。
过程还是很简单的, 但是有一种情况,你需要考虑到,那么就是我们知道redis可能存储的数据是非常大的,比如存储了几百万条数据在字典中,这个时候rehash是一件非常消耗时间的操作,如果单纯的按上面的流程进行再散列,那么会导致一个问题,系统可能会长时间不能响应。这违背了我们使用redis 的初衷(本来使用redis缓存为了就是加速响应 的)。
那么怎么办呢?解决办法就是渐进式rehash

渐进式Rehash


渐进式rehash在上面的rehash的基础上,进行了改进,改进过后的步骤将rehash的过程变得不在那么占用cpu时间,详细的步骤如下:
1)为ht[1]分配空间,让字典同时拥有ht[0]和ht[1]
2)在字典中维持一个索引计数器变量rehashidx,将它的值设置为0,表示rehash工作正在进行。
3)在rehash执行期间,每次对字典的增删改查,程序成了执行指定的操作之外,还会将ht[0]的哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx的属性值增一。(比如当前rehashidx为0,这个时候ht[0]的table数组中索引为0的那些dictEntry会不断的向ht[1]发送,进行rehash分布到ht[‘1]的不同位置)
4)这样随着操作不断进行,rehasidx不断的增加1,不断的将ht[0]中的所以为0,1,2,3,上面的元素rehash到ht[1]中,总会在一定的时间之后,会rehash结束,这个时候就将rehashidx设置为-1,表示rehash操作完成。

跳跃表

跳跃表这种数据结构可能接触的不多,但是这是一种查找操作的平均情况可以和平衡二叉树差不多的结构。它基本的实现想法是你在保存一个数据的同时,给这个数据赋予一个分值,这个分值代表了当前这个数据的大小,然后按照这个数据进行排序。当然实际情况要比这复杂的多。Redis只在两个地方用到了跳跃表,一个是实现有序集合,另外一个是集群节点中用作内部数据结构。

关于什么是跳跃表,建议去参考这篇博客的文章。这篇博客的文章和书中的跳跃表最为相似:http://www.cnblogs.com/xuqiang/archive/2011/05/22/2053516.html;

跳跃表的实现

跳表是由节点和表结构两个结构定义,名字分别为zskiplistNode 和 zskiplist,他们的 示例图如下
技术分享
其中最坐标的是zskiplist,它右边的每一个都是一个zskiplistNode。每个元素的含义如下:
header指向跳表的表头节点
tail指向跳表的表尾节点
level表示目前跳跃表内,层数最大的那个节点的层数(表头header不算)
length表示跳表的长度,当前跳表含有多少个跳表节点zskiplistNode(表头节点不算)

层(level)在节点中用L1,L2等表示,分别表示第1层,第2层。每一层都带有两个属性,指针和跨度,指针用于访问下一个节点,跨度表示两个节点之间的距离(距离表示他们之间相隔的节点个数+1)
后退指针(BW),指向前一个节点,当使用tail指针进行尾部遍历的时候,就可以使用BW来进行。
分值(score):各个节点中的1.0,2.0,3.0是节点所保存的分值,在跳跃表中,节点需要按照分值从小到大排列。
成员独享(obj),在节点汇总用o1,o2表示,表示节点存储的成员对象,可以是String型,可以是list型等

整数集合

整数集合时集合键的底层实现之一,当一个集合只包含整数值元素,并且集合元素不多时,就可以使用整数集合来作为集合键的底层实现。整数集合可以保存类型Int16_t,int32_t或者int64_t的整数值,并且因为是集合,所以不会出现重复元素。

typedef struct intset{
    //编码方式
    uint32_t enconding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
}intset;
可以看到,上面的几行代码就是intset的基本结构了。其中不同属性的含义是:
encoding:这是一个无符号32位整数,表示整数集合数组contents存储到的数据类型是Int16_t,int32_t或者int64_t
length:也是一个无符号32位整数,表示contents存储的元素的个数。
contents:一个数组,并且里面的数据按照从大到小的元素排列。并且存储的类型由encoding决定,而不是它本身的类型int8_t

升级操作

当一个整数集合元素存储的都是int16_t或者int32_t的时候,这个时候你如果放入一个位数比较大,超过16位或者32位,就会有引起整数数组的升级操作,升级操作的基本过程如下:
1)先根据新元素的大小,拓展数组contents的空间。
2)将contents数组中的元素转换成新元素相同的类型,并放到正确的位置上(维持有序性)
3)将新元素添加到新的contents数组里,由于这个新元素会引起数组升级,一般是比原来的数要大或者要小,所以要么添加到尾部,要么添加到头部。

升级具有如下好处:提升整数集合的灵活性,避免一个集合内部存储多种数据类型;节约内存。
整数集合不支持降级操作,一旦升级完成,就会一直保持升级后的状态。

压缩列表

压缩列表是列表和哈希的底层实现之一。当一个列表只包含少量的列表项,且列表项要么是小整数值,要么是较短的字符串时,Redis就会使用压缩列表。另外,当一个哈希只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis机会使用压缩列表来做哈希键的底层实现。

压缩里列表的结构

技术分享

压缩列表是一个总是从尾部开始遍历的列表。因为zlbytes和zltail可以计算得到表尾,然后entryX的特殊结构又能使我们按照一定的顺序从表尾遍历我们的压缩列表。

列表节点

技术分享

可以看到,一个列表节点要么保存一个字节数组,要么保存一个整数值,而这些都是我们前面所说过的压缩列表的基本含义中的内容。那么是怎么保存的呢,这个就需要看一下他的结构了。如上图,压缩列表节点内部是由三个部分构成的,第一个节点表示上一个压缩列表节点的长度。说到这里,应该明白是做到从尾部遍历的了吧。根据前面我们所说的zlbytes和zltail计算出尾部的entry,然后遍历这个entry,然后遍历完之后,往前便宜previous_entry_length,就可以找到上一个节点,然后依次遍历下去。我们来看个例子。
技术分享
当然,具体的实现不可能这么简单,下面我们来详细的描述下,压缩列表节点三个属性的含义:
1)previous_entry_length:以字节为单位,表示前一个列表节点的字节长度。如果前一个列表节点的长度小于254字节,那么这个属性只需要占用8位(1字节)的空间,如果前一个节点的长度大于254字节,那么这个属性就会占用5字节,其中第一个字节是固定值254,后面4个字节用于表示前一节点的长度。
2)encoding记录了content数组所保存的数据的类型以及长度。不同的编码表示不同的类型以及长度。比如00、01、10开头的表示字节数组,11开头的表示整数编码,分别表示content存储的内容是字节数组还是整数。如下图技术分享所示

3)content数组记录了保存的内容

连锁更新

前面说过,previous_entry_length属性都记录了前一个节点的长度,如果前一个节点长度小于254,那么previous_entry_length长度为1个字节,否则就是5个字节。假设现在有这么一种情况,在一个压缩列表中,有多个连续的、长度介于250到253之间的列表节点,记做e1,e2.....en。这个时候,加入我们在e1前面插入一个新的节点e0,e0的长度大于254,我们会修改e1 的previous_entry_length为5,然后导致e1的长度大于254,这个时候又要修改e2的previous_entry_length,导致e2的长度大于254....这种影响会一直持续到en。这种状况就叫做连锁更新。除了添加节点,删除节点也会导致连锁更新,比如e1,e2,e3...en那个列表节点,其中e1的长度大于254,e2的长度小于254,然后e3到en的长度介于250-253之间,这个时候,删除e2,将会导致e3到en的连锁更新。
连锁更新会导致n次空间重分配,每次空间分配的最坏复杂度为O(n),也就导致了O(n^2)的复杂度,这是很不好的。
虽然不好,但是这种情况并不常见,所以Redis并没有对这种情况做特殊处理。

对象

前面说了那么多基本的数据结构,终于可以聊一下我们的对象了,也就是Redis的5大对象:字符串(String),列表(List)、哈希(Hash)、集合(Set)、有序集合(ZSet),每种对象都用到了至少一种前面介绍过的数据结构。

对象类型和编码

Redis使用对象来表示数据库中的键和值,每次创建一个键值对时,我们都会至少创建两个对象,一个对象用来保存键值对的键,另一个对象用作键值对的值。
Redis的每个对象都有一个redisObject结构表示,如下所示:
typedef struct redisObject{
    //类型
    unsigned type:4;
    //编码
    unsigned encoding:4;
    //指向底层实现的数据结构指针
    void *ptr;//还有其他的数据,省略...
}robj
1)类型:表示这个对象是5钟类型中的哪一种,我们可以使用Type命令来查看我们所设置键值对的值的类型,他们通常使用类型常量来标记,比如:
技术分享
2)编码和底层实现:encoding表示底层实现的数据结构,也就是ptr指针指向的底层数据结构,也是用常量来表示,如下所示:
技术分享
每种类型都至少可以使用两种不同的编码,也就是说,类型type表示当前是哪一种对象,encoding表示当前对象的底层实现,他们之间的对应关系如下图所示(比如String对象类型可以是Int,EMBSTR或者RAW三种编码实现):
技术分享

字符串对象

字符串对象的编码可以是int,raw或者embstr,这三种的意思我们分别来讲解。
1)int:如果我们设定了一个字符串对象,它保存的是整数值,并且这个整数值可以用long类型来表示,那么我们就可以使用int编码,同时让ptr指针指向一个long。我们使用一个Redis的命令来举个例子:
>SET number 10086
OK
>OBJECT ENCODING number
"int"
2)raw:如果一个字符串对象保存的是一个字符串值,而且这个字符串的长度大于32字节,那么就必须使用一个简单动态字符串来保存这个字符串值,编码设置为raw。
3)embstr:既然有了raw,那么embstr又是干什么的呢?非常简单,embstr是用来保存长度小于32字节的字符串的。embstr和raw的区别是,embstr在创建字符串对象的时候,会分配一次空间,这个空间包含redisObject对象结构和sdshdr结构(保存字符串),而raw则会进行两次分配,分表分配空间给redisObject和sdshdr。所以在字符串较短时,使用embstr会有一些优势:分配次数降低,释放次数降低(因为只需要释放一个空间,而raw需要释放两个空间),字符串连续。
4)另外,浮点数也是以字符串类型来保存的。只不过使用的时候再转化为浮点类型进行计算
5)特殊情况下,int和embstr会转化成raw编码的字符串对象。比如对int编码的字符串对象执行了APPEND操作,使得数字不能再用long雷兴表示或者添加了字符不在是数字等。int不会转为embstr,只会变成raw。同时embstr是只读的,只要对embstr修改,就会编程raw
6)一些常用的字符串命令
技术分享

列表对象

列表对象的编码可以是双端链表(linkedlist)或者压缩列表(ziplist),前面我们都讲过了。
如果列表使用压缩列表来存储元素,那么结构就如下所示:
技术分享
同时,如果使用的是双端链表,那么结构图如下所示,同时对于双端链表中的每一个节点,都会嵌套一个obj,这个obj是String格式的。字符串对象是五种类型中,唯一一种会被其他四种类型的对象嵌套的对象。如下图所示,StringObject是一个redisObject,前面我们已经讲过String类型的redisObject了,type为REDIS_String,encoding为int,embstr或者raw
技术分享


编码转化
当列表对象同时满足下面的两个的两个条件时,列表对象使用ziplist编码:
当列表对象保存的所有字符串的元素长度都小于64字节;列表对象保存的元素数量小于512个。
不满足这两个条件的,需要使用linkedlist。
有一种情况,当一个列表对象的编码是ziplist,随着元素的增多或者修改某一个元素,导致不满足上面的两个条件的时候,就需要编码转换操作将ziplist转化为linkedlist。
具体的转化过程,没有说。
技术分享

哈希对象

哈希对象的编码可以是ziplist或者hashtable。
对于ziplist,哈希对象每次都是将键值对两个对象加入到ziplist的尾部,先将键添加到ziplist的尾部,再将值添加到ziplist的尾部,这样每次都会放入两个值。如下图所示
技术分享

如果是hashtable,由于这是一个字典,因此键值对直接就保存到里面了,所以实现起来理论上要好懂一些。如下图所示:
技术分享

编码转化
和列表一样,ziplist和hashtable不能同时在hash对象中使用,因此,在保存的键值对较为简单的情况下,优先使用ziplist。使用ziplist必须满足下列两个条件:
哈希对象保存的键值对的键和值的长度都小于64字节;哈希对象保存的键值对数量小于512个。
不满足以上情况的,需要使用hashtable。
当一个底层编码是ziplist的hash对象不满足上述两个条件时,就会引起向hashtable编码的转换,这就是编码转换。
技术分享

集合对象

集合对象的编码可以是intset或者hashtable。
对于intset,当一个集合里面的对象都是整数,且元素数量不超过512时,底层的实现就是整数集合;
对于hashtable,底层是字典,这个时候这个字典没有值,只是键。由于键是没有重复的,所以,就能保证集合的无重复的性质。
当然,对于集合,也是存在编码转换的过程的,原理和前面的一致,不再细说。
技术分享

有序集合对象

有序集合的编码可以是ziplist和skiplist
对于ziplist,底层使用压缩列表作为实现,每个集合元素用两个紧挨在一起的压缩列表节点来保存,第一个保存集合元素的成员,迭戈元素保存元素的分值。按照分值大小,分值较小的放到压缩列表的表头方向,分值较大的放到压缩列表的表尾方向。

对于skiplist编码,底层是用zset结构作为底层实现,而这个zset包含一个字典和一个跳跃表,如下所示:
typedef struct zset{
    zskiplist *zsl;
    dict *dict;
}zset
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object保存了元素的成员,而score保存了元素的分值,利用这个跳跃表,可以对有序集合进行范围型操作,比如ZRANK,ZRANGE等命令。除此之外,dickt字典为有序集合创建了一个从成员到分值的映射,字典中的每一个键值对都保存了一个几何元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值,通过这个字典,程序可以用常数复杂度来查找给定成员的分值,比如ZSCORE命令。
同时使用zskiplist和dict原因是为了效率,不同的操作可以使用不同的结构。而元素成员和分值可以通过指针在两个结构之间连接起来,对空间的占用也不会浪费很多,因此效率很高。
编码转化
这里的编码转化和前面原理一致,只不过数量不一样了。当同时满足下列两个条件时,使用ziplist编码,否则使用skiplist编码:
有序集合保存的元素数量小于128个,同时所有元素成员的长度小于64字节。
技术分享

类型检查和命令多态

Redis命令可以分为两种类型

其中一种可以对任何类型的键执行,比如说DEL命令,EXPIRE命令,RENAME名理工,TYPE命令,OBJECT命令等。

另一种命令只能对待特定类型的键执行,比如:

SET、GET、APPEND、STRLEN等命令只能对字符串键执行;

HDEL、HSET、HGET、HLE等命令只能对哈希键执行;

RPUSH、LOPO、LINSERT、LLEN等命令只能对列表建执行;

SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;

ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行;

对于特定类型的命令,Redis会先检查输入键的类型是否正确,然后在确定是否执行给定的命令,这就是类型检查。而类型检查是通过redisObject的type属性来实现的。
由于不同的对象有不同的实现type,而不同的type对应不同的底层实现,比如你对一个键执行LLEN命令,除了要进行前面的类型检查意外,我们还知道列表可以由ziplist和linkedlist两种不同的实现,于是根据不同的encoding,你要选择不同的操作接口去获得长度,这就是命令多态。

内存回收

引用计数:c语言不具备自动内存回收计数,于是Redis在对象系统中构建了一个引用计数计数来实现内存回收。也就是说,redisObject除了我们前面讲的三个变量外,还有另外的若干变量,其中一个就是叫做refcount的变量

当创建一个对象时,引用计数的值会被初始化为1;

当对象被一个新程序使用时,它的引用计数的值会增一;

当对象不再被一个程序使用时,它的引用计数的值会减一;

当对象的引用计数为0时,对象所占用的内存会被释放。

对象共享

类似于java中的String字符串在方法区的情况。也就是说,你在创建一个对象A,为它复制100,然后再创建一个对象B,复制100 的时候,这个时候就可以让B共享同一个字符串100,节约内存的使用。

空转时间

redisObject的最后一个属性为lru属性,这个属性记录了最后一次对象被访问的时间。空转时间就是当前时间减去这个值计算出来的。
>OBJECT IDLETIME key
(integer) 120

以上,是我们的底层数据结构。

Redis 一、数据结构与对象--五大数据类型的底层结构实现