Redis底层核心数据结构

文章目录

  • Redis底层核心数据结构
    • DB
    • Key
    • Value
      • String
      • List
      • Hash
      • Set
      • Zset
      • geo
      • hyperloglog
      • bitmap
    • redis6.0版本的新特性

Redis底层核心数据结构

Redis是key-value数据结构,k-v 在java中会想到map,redis中是叫dict。

redis还是数据库,它是使用数组和链表来存储海量的数据的。

hash(key) --> 自然数 % array.size --> 这样就得到了数组的下标了,然后把key-value保存在数组的这个位置

如果产生了hash冲突,是使用链表来解决的,使用的头插法



DB

Redis中默认有16个数据库,底层是redisDb对象,代码如下

typedef struct redisDb {
    dict *dict;       // 上面提到的 key-value  就是存储在dict中的           
    dict *expires;    // 存储各个key的过期时间      
    dict *blocking_keys;     // 存储的是阻塞队列相关的内容     
    dict *ready_keys;        // 维护key和client客户端连接之间的对应关系
    dict *watched_keys;      // 关于事务处理是存放在watched_keys
    int id;           // 这个就是数据库的id  0~15              
    long long avg_ttl;           
    unsigned long expires_cursor;  
    list *defrag_later;          
} redisDb;

重点是要讲解dict,我们可以理解为这就是一个hashtable

typedef struct dict {
    dictType *type;		// 指定hash算法,并且产生hash冲突后去进行equals比较   是否进行覆盖或者头插法插入元素
    void *privdata;
    dictht ht[2];	// 这就是一个hashtable结构,ht[0]是老数组  ht[1]是新数组,指向下面的dictht对象
    long rehashidx; 
    unsigned long iterators;  
} dict;

dictht的代码如下所示。这就是一个hashtable的数据结构,每个dict字典都有两个dictht,目的就是实现一个渐进式的rehash,其实就是数组的扩容,把老数组的内容拷贝到扩容后新数组中去。Redis的扩容是newSize=oldSize*2,扩容完成后并不是一次性把所有的key-value移动到新数组中去,而是一次移动一部分数据,然后去处理用户请求,过一会了又移动一部分,扩容是在master线程中执行的,扩容触发的条件是size : used = 1。当把ht[0]中的数据都移动到ht[1]之后,会把ht[0]指向ht[1],ht[1]=null

在移动数据过程中如果客户端进行了更新操作,Redis会操作两个dictht,先去老数组ht[0]中找,如果没有找到就直接去新数组ht[1]中操作,如果老数组找到了就是在老数组中去操作,同时会把这个hash桶中的数据全都移动到新数组中去。

typedef struct dictht {
    dictEntry **table;
    unsigned long size;		// hash桶个数、数组的长度
    unsigned long sizemask;	// size-1  计算key在数组中的下标时 hash(key)%2^n == hash(key) & (2^n-1),位运行要快,sizemask存在的意义
    unsigned long used;			// 已经存在了多少个元素,不是使用hash桶的个数
} dictht;

我们现在知道了dictht的作用与结构,那么也就知道Key-value其实存储在这其中的,具体就是存储在dictEntry指针中的,如下所示

dictEntry代码如下所示,其实就是存储了key、value、next三个元素

typedef struct dictEntry {
    void *key;		// 这个指针就是指向的一个SDS的对象
    union {
        void *val;   // void *表示一个空指针,可以指向任意的数据类型,实际上指向下面的redisObject对象
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;		// 产生hash冲突后,链表就是靠next实现的
} dictEntry;

val这个指针指向的对象类型可能是String、hash、list、set、zset…,当然Redis中也不是简单的直接用这些类型对象,而是用了redisObject 对象来封装

typedef struct redisObject {
	// type当key不存在时根据客户端执行的命令确定value是什么类型,比如set命令->string。
    // 当key存在时会约束客户端命令的操作,比如name是一个string类型,但是我却执行lpush name [value...]那么这里直接就抛错了
    unsigned type:4;  
    unsigned encoding:4;	// 某个key对应的value,这个value在redis内存中到底是什么类型的编码
    unsigned lru:LRU_BITS;  // 设置了内存淘汰策略时才会用到
    int refcount;			// 类似于java的垃圾标记算法——引用计数法,用这个来管理内存回收
    void *ptr;				// 指向value内存中真实存储的位置
} robj;

RedisDB主体数据结构如下图所示

Redis底层核心数据结构_第1张图片



Key

Redis中所有的key都是String类型的,底层是使用的SDS类型,没有使用c语言的字符数组去实现字符串。因为c语言是以\0作为字符串结束标识的,redis需要支持各种语言,当数据以stream流的形式传输到Redis-server后可能某个字符串中就包含这个\0字符。

SDS simple dynamic string

关键特点是:

  • 二进制安全的数据结构

    它有一个属性指定了当前字符串的长度,然后根据这个长度去读取字符串,而不是根据\0作为结束标识

  • 提供了内存预分配机制,避免频繁的内存分配

    如果我们使用append等命令修改一个字符串时,会去判断当前剩余空间是否足够,如果不足够这则按照(length + addlen) * 2去重新分配内存 创建spring对象,当达到了1024*1024后 也就是1M后就会按照每次增加1M去扩容

  • 兼容c语言的函数库

    会自动的在字符串的结尾添加\0 去兼容c语言的函数库

SDS
	free:6
	len:10
	char buf[] = "hushang123"

在redis3.2以前,len它是整形占4个字节 32位,也就是说它len的值最大为2^32 -1 ,但是我们一般一个字符串不会很长一般都是100个字符以内。在redis3.2之后的版本中SDS就有了几种,如果字符串的长度在2^5 -1之内就使用sdshdr5类型,如果字符串的长度在2^8 -1之内就使用sdshdr8类型…

会根据不同的业务数据长度创建不同的SDS业务类型

Redis底层核心数据结构_第2张图片

如上图所示,在redis3.2之后的版本中,SDS还多了一些其他的属性

  • flags:如上图右上角所示,char占一个字节 8位,其中前面3位表示类型,后面5位表示长度
  • alloc :表示当前sds字符串总长度,alloc - len = free



Value

我们接下来再来看value部分的底层结构,redis中的value它能支持的常见数据类型有String、hash、list、set、zset…

String

下面我新插入了三个值,首先查看类型都是string,但是查看value的encoding编码却有三种情况

127.0.0.1:6379> set name hushang
OK
127.0.0.1:6379> set age 23
OK
127.0.0.1:6379> set script aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OK


# 类型都是string
127.0.0.1:6379> type name
string
127.0.0.1:6379> type age
string
127.0.0.1:6379> type script
string



127.0.0.1:6379> object encoding name
"embstr"
127.0.0.1:6379> object encoding age
"int"
127.0.0.1:6379> object encoding script
"raw"
  • raw其实就是原始类型SDS,上面讲解Key的结构时已经做了介绍,这里就不在重新写一遍了,

  • int这种就是整形,它的特点是内存长度是固定了,最多占8字节 64bit,不会有动态扩容机制。如下所示redisObject对象中的ptr是最终指向内存中的value值,也占8字节,那么能不能直接把int值存放在ptr中嘞,这样也就省了又开辟一块内存空间,也省略一次内存寻址。其实redis底层也就是这么来做的。redis会首先判断当前value的长度是否超过数值能表示的最大长度,然后调一个函数判断是否能把当前value变为一个数值型,如果都满足则*ptr直接存储这个值

    typedef struct redisObject {
        unsigned type:4;  
        unsigned encoding:4;	
        unsigned lru:LRU_BITS;  
        int refcount;			
        void *ptr;				// 指向value内存中真实存储的位置
    } robj;
    
  • embstr

    cpu去内存读取数据不是要多少数据就读多少数据,有一个缓存行cache line的概念,每次读取数据最少读取一个cache line,一般linux系统一个cache line的大小是64byte字节。

    保存value的对象是如上所示的readsObject对象,它占4bit + 4bit + 24bit + 4byte + 8byte = 16byte,但是缓存行一次读64字节,那么也就意味着后面48字节根本用不上。那么这一块就可以优化,我们可以在通过readsObject对象里面的*ptr再去读取一次数据,把后面的48字节利用上。

    *ptr指向的是一个SDS对象,从上一节介绍SDS我们可以知道SDS分为sdshdr5、sdshdr8、sdshdr16…,48个字节是在32~64之间,所以这里会使用sdshdr8

    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* used */
        uint8_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    

    而一个sdshdr8中len占8bit、alloc占8bit、flags占1byte、之前提到了sds为了满足c语言的函数库会在buf[]后面加一个\0,所以buf[]至少也占1byte,现在readsObject + sdshdr8 = 16byte + 4byte = 20字节,目前还剩余44字节未使用。我们保存的value是存在buf[]中的,那么我们的value字符串如果 <= 44字节那么就是能一次cpu缓存行读取就能把具体值给读取出来,不需要进行多次内存寻址。

    所以value<= 44字节 就可以是embstr

    127.0.0.1:6379> set name aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    OK
    127.0.0.1:6379> strlen name
    (integer) 44
    127.0.0.1:6379> object encoding name
    "embstr"
    
    
    # 超过44字节的value就变成了raw类型了
    127.0.0.1:6379> set name aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
    OK
    127.0.0.1:6379> strlen name
    (integer) 45
    127.0.0.1:6379> object encoding name
    "raw"
    



List

redis中list结构的一个特点是它存储的数据类型是不固定的,可以是string,也可以是数值…,如果是string可能需要好几个字节,如果是数值可能一个字节就能存储了,并且还可以从两端存两端取。

# 往list的两端存元素,
127.0.0.1:6379> lpush hs_list aa bb cc
(integer) 3
127.0.0.1:6379> rpush hs_list dd ee 123
(integer) 6
127.0.0.1:6379> lpop hs_list
"cc"

Redis它并没有直接使用链表结构来实现list,因为如果采用链表那么每个元素都需要pre和next两个指针,而在64位操作系统中,两个指针就占了16byte,反而数据value却占一点点内存,这就出现了胖指针。并且使用链表结构,各个元素内存是不连续的,会出现很多内存碎片化

Redis采用了quicklist双端链表ziplist作为list的底层实现

我们首先来看一下ziplist底层的编码结构

Redis底层核心数据结构_第3张图片

  • zlbytes,标识当前ziplist中存了多少数据,内存容量

  • zltail,标记尾结点的内存地址

  • zllen,当前ziplist中有多少个元素

  • entry,list中的具体元素

  • zlend,占1字节,恒等于255,表示ziplist结尾部分。

每个Entry包含三部分,因为list需要满足两端遍历,所以每个entry对象包含了前一个元素的信息与自己本身的信息

  • preawlen:包含前一个元素的信息

    首先会判断preawlen这个字节代表的数据是否小于254,这个254就是一个标识,因为255已经被ziplist结尾标识给占用了。目的是从后往前遍历时大概知道前面一个元素占用多大内存空间,如果是小于254则占用一个字节标识,如果大于254则用5字节

  • len:标识当前元素长度,它有很多的含义,具体如上图所示

  • data:当前元素的具体内容值

List并没有把所有的元素都存储在上图绿色方框位置,还使用了双端链表quicklist,具体结构如下图所示

Redis底层核心数据结构_第4张图片

list共分为了多个ziplist,每个ziplist充当双端链表quicklist中的一个元素节点。当要插入或删除元素时只需要修改一个ziplist即可,当一个ziplist存储的元素过多时,它还会进行分裂,创建一个新的ziplist出来

可以通过redis.conf设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率

#  单个ziplist节点最大能存储  8kb  ,超过则进行分裂,将数据存储在新的ziplist节点中
list-max-ziplist-size  -2

# 头节点和尾结点这两个ziplist可能会频繁访问,而中间节点的ziplist基本很少访问,那么中间的节点是否需要进行压缩,通过下面的配置
#  0 代表所有节点,都不进行压缩,1, 代表从头节点往后走一个,尾节点往前走一个不用压缩,其他的全部压缩,2,3,4 ... 以此类推
list-compress-depth  1



Hash

hash的底层数据结构其实就是一个dict字典,其实从上面介绍RedisDB的时候已经结束了关于dict相关的很多知识。

Hash当数据量比较少或单元元素比较小时,底层用ziplist存储

我们知道dict,也就是一个hashtable是无序的,接下来再看下面的案例

# 刚开始存放的元素,获取出来竟然是有序的,按照我们放入的顺序输出
127.0.0.1:6379> hset user:1 name hushang age 23 k1 v1 k2 v2 k3 v3
(integer) 5
127.0.0.1:6379> hgetall user:1
 1) "name"
 2) "hushang"
 3) "age"
 4) "23"
 5) "k1"
 6) "v1"
 7) "k2"
 8) "v2"
 9) "k3"
10) "v3"


# 接下来在存一个比较大的值,再查询发现就是乱序输出了
127.0.0.1:6379> hset user:1 k4 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(integer) 1
127.0.0.1:6379> hgetall user:1
 1) "name"
 2) "hushang"
 3) "k3"
 4) "v3"
 5) "k4"
 6) "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
 7) "k1"
 8) "v1"
 9) "k2"
10) "v2"
11) "age"
12) "23"
127.0.0.1:6379> object encoding user:1
"hashtable"

这是因为Hash当数据量比较少或单元元素比较小时,底层用ziplist存储,否则才用字典(dict)来存储

Redis底层核心数据结构_第5张图片

Hash数据大小和元素数量阈值可以通过如下redis.conf参数设置

hash-max-ziplist-entries  512    #  ziplist 元素个数超过 512 ,将改为hashtable编码 
hash-max-ziplist-value    64      #  单个元素大小超过 64 byte时,将改为hashtable编码

面试题:String和hash数据结构
string:key-value
hash:key-dict

  • 拿User对象那举例,如果使用String,user对象的每个属性都是一个key-value,那么就会存在比较多的key,之前讲DB的时候就说了Redis底层是使用的dict 类似于hashtable的数结构,如果存在这么多的key,也就很容易触发rehash进行数组扩容。而使用hash结构的话就只会创建一个key。
  • 关于过期时间,Hash这种只能给最外层的key设置过期时间,不能给field设置过期时间



Set

set为无序的,自动去重的集合,Set数据结构底层实现为一个value为null的字典dict,只用了dict中的key;

set当所有数据可以用整形表示时,set集合将被编码为intset数据结构,当下面两个条件任意一个无法满足时Set集合将用hashtable存储数据:

  • 元素个数大于set-max-intset-entries

    redis.conf配置文件中默认值是512,表示intset 能存储的最大元素个数,超过则用hashtable编码

  • 元素无法用整形表示

# 先全部存储数值型,可以发现最后打印输出的内容竟然是排好序的。
# 目的是使用intset会内存消耗更友好,排序的目的是能更快找元素,比如新增时判断这个值是否已经存在了
127.0.0.1:6379> sadd hs-set 3 5 1 6 8 2 2 2 2 
(integer) 6
127.0.0.1:6379> smembers hs-set
1) "1"
2) "2"
3) "3"
4) "5"
5) "6"
6) "8"
127.0.0.1:6379> type hs-set
set
127.0.0.1:6379> object encoding hs-set
"intset"


# 此时我在加一个不是数值型的元素,就会使用hashtable了
127.0.0.1:6379> sadd hs-set a
(integer) 1
127.0.0.1:6379> smembers hs-set
1) "2"
2) "8"
3) "3"
4) "a"
5) "5"
6) "6"
7) "1"
127.0.0.1:6379> type hs-set
set
127.0.0.1:6379> object encoding hs-set
"hashtable"

dict的底层代码在RedisDB部分时已经详细分析过了,接下来我们分析一下intset的代码

typedef struct intset {
    uint32_t  encoding;		// 编码类型
    uint32_t  length;		//  元素个数
    int8_t      contents[];		// 具体元素存储
} intset; 
 
// intset有三种数据类型,分别占用不同的bit位数
// define INTSET_ENC_INT16 (sizeof(int16_t))
// define INTSET_ENC_INT32 (sizeof(int32_t))
// define INTSET_ENC_INT64 (sizeof(int64_t))

下图就是一个数组的结构,根据不同大小的数值选择不同的intset类型,这样也就知道了每个元素占多数bit了,那么也就能从数组中拿到元素了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ozKiNrsD-1679905101674)(picture/Redis/image-20230327114328777.png)]



Zset

Zset为有序、自动去重的集合数据类型、zset底层数据结构为 字典dict + 跳表skiplist。当数据量较少时使用ziplist编码结构去存储

zset-max-ziplist-entries  128   # 元素个数超过128 ,将用skiplist编码
zset-max-ziplist-value     64   #  单个元素大小超过 64 byte, 将用 skiplist编码

Redis底层核心数据结构_第6张图片

# 插入三个元素,分值分别是100 120 200
127.0.0.1:6379> zadd hs-zset 100 a 120 b 200 c
(integer) 3

# 查询某个范围的值  -1表示查询所有
127.0.0.1:6379> zrange hs-zset 0 -1
1) "a"
2) "b"
3) "c"
127.0.0.1:6379> zrange hs-zset 0 1
1) "a"
2) "b"

Zset它是有序的数据结构,而底层如果要使用一块连续的内存空间维护有序的数据,如果新增数据那么就会造成频繁的数据移动,所以Redis底层是采用的链表的结构来维护的多个数据,但是使用链表随机查找又比较慢,Redis再又加上了跳表的数据结构来提升查询效率。同时Zset底层除了跳表之外还有一个字典dict,它的作用是能够以O(1)的时间复杂度快速找到某个元素与对应的分值。


接下来我们详细看一下跳表的实现,

// 创建zset 数据结构: 字典 + 跳表
robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    // dict用来查询数据到分数的对应关系, 如 zscore 就可以直接根据 元素拿到分值 
    zs->dict = dictCreate(&zsetDictType,NULL);
    
    // skiplist用来根据分数查询数据(可能是范围查找)
    zs->zsl = zslCreate();
    // 设置对象类型 
    o = createObject(OBJ_ZSET,zs);
    // 设置编码类型 
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

// zset数据结构,是字典dict和跳表skiplist来实现的
// 跳表能够实现范围查找或根据分数查询数据 字典能够快速查询到某个数据、分数
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;


// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 指向头节点和尾结点,表头不存储元素,拥有最高的层级
    unsigned long length;			// 元素个数
    int level;					// 层数
} zskiplist;


// 跳表节点
typedef struct zskiplistNode {
    sds ele;							// 具体元素值
    double score;						// 分数
    struct zskiplistNode *backward;		// 前一个节点
    struct zskiplistLevel {
        struct zskiplistNode *forward;	// 同一层级中的下一个节点指针
        unsigned long span;
    } level[];
} zskiplistNode;

跳表skiplist的结构图

Redis底层核心数据结构_第7张图片


其实就是一种类似于下图所示的结构

Redis底层核心数据结构_第8张图片

因为我们可以使用zrange命令按照分值升序输出,也可以使用zrevrange按照分值降序输出,所以跳表skiplistNode才有前一个节点执行与下一个节点指针

skiplist中的level记录的是最高的层高数,因为索引遍历时是从最高的那一层开始往下找的,初始层高是1。

当新增一个元素时,最底层的数据层肯定是会插入的,那么问题是这个值需要不要在各个索引层中也新增嘞,这也是通过一个随机函数决定的,越高的层数新增的概率越低



geo

底层实现是靠geo算法+Zset数据结构来实现的。

地图是二维的,但Zset排序的数据是一维的,他们是怎么联系在一起使用的嘞?


经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负

所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。

如果以本初子午线、赤道为界,地球可以分成4个部分。如果纬度范围[-90°, 0°)用二进制0代表,(0°, 90°]用二进制1代表,经度范围[-180°, 0°)用二进制0代表,(0°, 180°]用二进制1代表,那么地球可以分成如下(左图 )4个部分

Redis底层核心数据结构_第9张图片


我们在每一个小格子中还可以进一步细分,每个小格子又可以分为四个小格子,但是高2位不变,低2位又可以分为00 01 10 11四种,再继续拆分最终就会得到一串二进制的经纬度。上面的图片右边部分用线连接的就是Z阶曲线


通过GeoHash算法,可以将经纬度的二维坐标变成一个可排序、可比较的的字符串编码。 在编码中的每个字符代表一个区域,并且前面的字符是后面字符的父区域。其算法的过程如下:

根据GeoHash 来计算纬度的二进制编码地球纬度区间是[-90,90], 如某纬度是39.92324,可以通过下面算法来进行维度编码:

  1. 区间[-90,90]进行二分为[-90,0),[0,90],称为左右区间,可以确定39.92324属于右区间[0,90],给标记为1
  2. 接着将区间[0,90]进行二分为 [0,45),[45,90],可以确定39.92324属于左区间 [0,45),给标记为0
  3. 递归上述过程39.92324总是属于某个区间[a,b]。随着每次迭代区间[a,b]总在缩小,并越来越逼近39.928167
  4. 如果给定的纬度(39.92324)属于左区间,则记录0,如果属于右区间则记录1,这样随着算法的进行会产生一个序列1011 1000 1100 0111 1001,序列的长度跟给定的区间划分次数有关。

经度的划分如下图右边位置


纬度产生的编码为1011 1000 1100 0111 1001,经度产生的编码为1101 0010 1100 0100 0100。偶数位放经度,奇数位放纬度,把2串编码组合生成新串:11100 11101 00100 01111 00000 01101 01011 00001。


现在得到了一串二进制,然后通过geohash算法对二进制进行编码生成一段字符串,具体生成的方法是通过base32进行编码

使用0-9、b-z(去掉a, i, l, o)这32个字母进行base32编码,将二进制中每5位作为一个数字(2^5 - 1 =31),首先将11100 11101 00100 01111 00000 01101 01011 00001转成十进制 28,29,4,15,0,13,11,1,十进制对应的编码就是wx4g0ec1。同理,将编码转换成经纬度的解码算法与之相反

Redis底层核心数据结构_第10张图片



它与Zset的关联,因为这一串编码之后的字符串是有序的,那么我们也就可以使用Zset来存储。

GEO_STEP_MAX 的值是26,表示Redis中会底层会拆分26次,经纬度都需要拆分,所以最后需要26*2=52bit来存储一整个二进制串。然后在根据52bit位的值生成Zset需要的score,再存储在Zset中

Redis底层核心数据结构_第11张图片



hyperloglog

求基数,结果是一个近似值

HyperLogLog基于概率论中伯努利试验并结合了极大似然估算方法,并做了分桶优化。具体实现原理参考下一篇文章——Redis HyperLogLog底层实现



bitmap

亿级用户日活统计,一天内用户登录了就记一次

bitmap底层就是基于String来实现的。

bitmap我们可以使用日期作为key,而value底层就是一串二进制的数组 比如0 1 1 1 0 0 0 0 1 0 1 1 1 0 0...,如果用户登录了我们就可以根据用户id定位到具体的某一个bit位上,然后将值改为1。一个bit为表示一个用户,这样就会非常节省空间

如果数据量比较大可以使用bitmap,如果数据量不大就没必要使用bitmap。bitmap还可以进行位运算,& |



redis6.0版本的新特性

  • 多线程模型
  • 客户端缓存
  • 权限校验

你可能感兴趣的:(报班总结笔记,redis,数据结构,java)