面试官:说说Redis的Hash底层 我:......

存储 存储软件 Redis
在Redis中Hash类型的应用非常广泛,其中key到value的映射就通过字典结构来维护的。记笔记,此处要考。

 

本文转载自微信公众号「学习Java的小姐姐」,作者学习Java的小姐姐0618 。转载本文请联系学习Java的小姐姐公众号。

[[332012]]

前言

hello,各位小可爱们,又见面了。今天这篇文章来自去年面试阅文的面试题,结果被虐了。这一part不说了,下次专门开一篇,写下我面试被虐的名场面,尴尬的不行,全程尬聊。哈哈哈哈,话不多说,开始把。😂

 

在Redis中Hash类型的应用非常广泛,其中key到value的映射就通过字典结构来维护的。记笔记,此处要考。

 

API使用

API的使用比较简单,所以以下就粗略的写了。

插入数据hset

使用hset命令往myhash中插入两个key,value的键值对,分别是(name,zhangsan)和(age,20),返回值当前的myhash的长度。

 

获取数据hget

使用hget命令获取myhash中key为name的value值。

 

获取所有数据hgetall

使用hgetall命令获取myhash中所有的key和value值。

 

获取所有key

使用hkeys命令获取myhash中所有的key值。

 

获取长度

使用hlen命令获取myhash的长度。

 

获取所有value

使用hvals命令获取myhash中所有的value值。

 

具体逻辑图

正文要开始了哈。hash的底层主要是采用字典dict的结构,整体呈现层层封装。

 

首先dict有四个部分组成,分别是dictType(类型,不咋重要),dictht(核心),rehashidx(渐进式hash的标志),iterators(迭代器),这里面最重要的就是dictht和rehashidx。

接下来是dictht,其有两个数组构成,一个是真正的数据存储位置,还有一个用于hash过程,包括的变量分别是真正的数据table和一些常见变量。

最后数据节点,和上篇说的双向链表一样,每个节点都有next指针,方便指向下一个节点,这样目的是为了解决hash碰撞。具体的可以看下图。

这边看不懂没关系,后面会针对每个模块详细说明。(千万不要看到这里就跳过啦)

 

双向链表的定义

字典结构体dict

我们先看字典结构体dict,其包括四个部分,重点是dictht[2](真正的数据)和rehashidx(渐进式hash的标志)。具体图如下。

 

具体代码如下:

  1. //字典结构体 
  2. typedef struct dict { 
  3. dictType *type;//类型,包括一些自定义函数,这些函数使得key和value能够存储 
  4. void *privdata;//私有数据 
  5. dictht ht[2];//两张hash表 
  6. long rehashidx; //渐进式hash标记,如果为-1,说明没在进行hash 
  7. unsigned long iterators; //正在迭代的迭代器数量 
  8. } dict; 

数组结构体dictht

dictht主要包括四个部分,1是真正的数据dictEntry类型的数组,里面存放的是数据节点;2是数组长度size;3是进行hash运算的参数sizemask,这个不咋重要,只要记住等于size-1;4是数据节点数量used,当前有多少个数据节点。

 

具体代码如下:

  1. //hash结构体 
  2. typedef struct dictht { 
  3. dictEntry **table;//真正数据的数组 
  4. unsigned long size;//数组的大小 
  5. unsigned long sizemask;//用户将hash映射到table的位置索引,他的值总是等于size-1 
  6. unsigned long used;//已用节点数量 
  7. } dictht; 

数据节点dictEntry

dictEntry为真正的数据节点,包括key,value和next节点。

  1. //每个节点的结构体 
  2. typedef struct dictEntry { 
  3.    void *key; //key 
  4.    union { 
  5.           void *val; 
  6.           uint64_t u64; 
  7.           int64_t s64; 
  8.           double d; 
  9.    } v;//value 
  10.   struct dictEntry *next; //下一个数据节点的地址 
  11. } dictEntry; 

扩容过程和渐进式Hash图解

我们先来第一个部分,dictht[2]为什么会要2个数组存放,真正的数据只要一个数组就够了?

 

这其实和Java的HashMap相似,都是数据加链表的结构,随着数据量的增加,hash碰撞发生的就越频繁,每个数组后面的链表就越长,整个链表显得非常累赘。如果业务需要大量查询操作,因为是链表,只能从头部开始查询,等一个数组的链表全部查询完才能开始下一个数组,这样查询时间将无限拉长。

这无疑是要进行扩容,所以第一个数组存放真正的数据,第二个数组用于扩容用。第一个数组中的节点经过hash运算映射到第二个数组上,然后依次进行。那么过程中还能对外提供服务吗?答案是可以的,因为他可以随时停止,这就到了下一个变量rehashidx。(一点都不生硬的转场,哈哈哈)

 

rehashidx其实是一个标志量,如果为-1说明当前没有扩容,如果不为-1则表示当前扩容到哪个下标位置,方便下次进行从该下标位置继续扩容。

 

这样说是不是太抽象了,还是一脸懵逼,贴心的送上扩容过程全解,一定要点赞评论多夸夸我哦。

 

步骤1

首先是未扩容前,rehashidx为-1,表示未扩容,第一个数组的dictEntry长度为4,一共有5个节点,所以used为5。

 

步骤2

当发生扩容了,rahashidx为第一个数组的第一个下标位置,即0。扩容之后的大小为大于used*2的2的n次方的最小值,即能包含这些节点*2的2的倍数的最小值。因为当前为5个数据节点,所以used*2=10,扩容后的数组大小为大于10的2的次方的最小值,为16。从第一个数组0下标位置开始,查找第一个元素,找到key为name,value为张三的节点,将其hash过,找到在第二个数组的下标为1的位置,将节点移过去,其实是指针的移动。这边就简单说了。

 

步骤3

key为name,value为张三的节点移动结束后,继续移动第一个数组dictht[0]的下标为0的后续节点,移动步骤和上面相同。

 

步骤4

继续移动第一个数组dictht[0]的下标为0的后续节点都移动完了,开始移动下标为1的节点,发现其没有数据,所以移动下标为2的节点,同时修改rehashidx为2,移动步骤和上面相同。

 

整个过程的重点在于rehashidx,其为第一个数组正在移动的下标位置,如果当前内存不够,或者操作系统繁忙,扩容的过程可以随时停止。

停止之后如果对该对象进行操作,那是什么样子的呢?

  • 如果是新增,则直接新增后第二个数组,因为如果新增到第一个数组,以后还是要移过来,没必要浪费时间
  • 如果是删除,更新,查询,则先查找第一个数组,如果没找到,则再查询第二个数组。

字典的实现(源码分析)

创建并初始化字典

首先分配内存,接着调用初始化方法_dictInit,主要是赋值操作,重点看下rehashidx赋值为-1(这验证了刚才的图解,-1表示未进行hash扩容),最后返回是否创建成功。

  1. /* 创建并初始化字典 */ 
  2. dict *dictCreate(dictType *type,void *privDataPtr) 
  3.      dict *d = zmalloc(sizeof(*d)); 
  4.      _dictInit(d,type,privDataPtr); 
  5.      return d; 
  6.  
  7. /* Initialize the hash table */ 
  8. int _dictInit(dict *d, dictType *type,void *privDataPtr) 
  9.      _dictReset(&d->ht[0]); 
  10.      _dictReset(&d->ht[1]); 
  11.      d->type = type; 
  12.      d->privdata = privDataPtr; 
  13.      d->rehashidx = -1;//赋值为-1,表示未进行hash 
  14.      d->iterators = 0; 
  15. return DICT_OK; 

扩容

dict里面有一个静态方法_dictExpandIfNeed,判断是否需要扩容。

首先判断通过dictIsRehashing方法,判断是否处于hash状态,其调用的是宏常量#define dictIsRehashing(d) ((d)->rehashidx != -1),即判断rehashidx是否为-1,如果为-1,即不处于hash状态,if条件为false,可以进行扩容,如果不为-1,即处于hash状态,if条件为true,不可以进行扩容,直接返回常量DICT_OK。

接着判断第一个数组的size是否为0,如果为0,则扩容为默认大小4,如果不为0,则执行下面的代码。

再接着判断是否需要扩容,if中有三个条件,具体的分析如下。

最后就是调用dictExpand扩容方法了,参数为数据节点的双倍大小ht[0].used*2。此处验证了上面扩容过程的数组大小16。

扩容方法比较简单点,获取扩容后的大小,将第二个设置新的大小。

这样讲感觉有点空,看下流程图。

扩容流程图

 

具体代码:

  1. static int _dictExpandIfNeeded(dict *d) 
  2.       //判断是否处于扩容状态中,通过调用宏常量#define                                                  
  3.       dictIsRehashing(d) ((d)->rehashidx != -1) 
  4.       //来判断是否可以扩容 
  5.       if (dictIsRehashing(d)) return DICT_OK; 
  6.  
  7.       //判断第一个数组size是否为0,如果为0,则调用扩容方法,大小为宏常量 
  8.       //#define DICT_HT_INITIAL_SIZE 4 
  9.       if (d->ht[0].size == 0)  
  10.              return dictExpand(d, DICT_HT_INITIAL_SIZE); 
  11.  
  12.       //下面先列出if条件中所使用到的参数 
  13.       // static int dict_can_resize = 1;数值为1表示可以扩容 
  14.       //static unsigned int dict_force_resize_ratio = 5; 
  15.       //我们来分析if条件,如果第一个数组的所有节点数量大于等于第一个数组的大小(表      示节点数据已经有些多) 
  16.       //并且可用扩容(数值为1)或者所有节点数量除以数组大小大于5 
  17.       //这个条件表示扩容那个的条件,第一个就是节点必要大于等于数组长度, 
  18.       //第二点就再可以扩容和数据太多,超过5两个中选其一 
  19.       if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize || 
  20.       d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) 
  21.       { 
  22.             //调用扩容方法 
  23.             return dictExpand(d, d->ht[0].used*2); 
  24.       } 
  25. return DICT_OK; 
  26.  
  27. int dictExpand(dict *d, unsigned long size
  28.       dictht n; 
  29.       //获取扩容后真正的大小,找到比size大的最小值,且是2的倍数 
  30.       unsigned long realsize = _dictNextPower(size); 
  31.  
  32.       //一些判断条件 
  33.       if (dictIsRehashing(d) || d->ht[0].used > size
  34.             return DICT_ERR; 
  35.  
  36.       if (realsize == d->ht[0].sizereturn DICT_ERR; 
  37.  
  38.       n.size = realsize; 
  39.       n.sizemask = realsize-1; 
  40.       n.table = zcalloc(realsize*sizeof(dictEntry*)); 
  41.       n.used = 0; 
  42.  
  43.       //第一个hash为null,说明在初始化 
  44.       if (d->ht[0].table == NULL) { 
  45.             d->ht[0] = n; 
  46.       return DICT_OK; 
  47.       } 
  48.  
  49.       //正在hash,给第二个hash的长度设置新的, 
  50.       d->ht[1] = n; 
  51.       d->rehashidx = 0;//设置当前正在hash 
  52. return DICT_OK; 
  53.  
  54. /* 找到比size大的最小值,且是2的倍数 */ 
  55. static unsigned long _dictNextPower(unsigned long size
  56.       unsigned long i = DICT_HT_INITIAL_SIZE; 
  57.  
  58.       if (size >= LONG_MAX) return LONG_MAX; 
  59.       while(1) { 
  60.             if (i >= size
  61.                   return i; 
  62.       i *= 2; 
  63.       } 

渐进式hash

渐进式hash过程已经通过上面图解说明,以下主要看下代码是如何实现的,以及过程是不是对的。

扩容之后就是执行dictRehash方法,参数包括待移动的哈希表d和步骤数字n。

首先判断标志量rehashidx是否等于-1,如果等于-1,则表示hash完成,如果不等于-1,则执行下面的代码。

接着进行循环,遍历第一个数组上的每个下标,每次移动下标位置,都需要更新rehashidx值,每次加1。

再接着进行第二个循环,遍历下标的链表每个节点,完成数据的迁移,主要是指针的移动和一些参数的修改。

最后,返回int数值,如果为0表示整个数据全部hash完成,如果返回1则表示部分hash结束,并没有全部完成,下次可以通过rehashidx值继续hash。

具体代码如下:

  1.      //重新hash这个哈希表 
  2. // Redis的哈希表结构共有两个table数组,t0和t1,平常只使用一个t0,当需要重hash时则重hash到另一个table数组中 
  3. //参数列表 
  4. // 1. d: 待移动的哈希表,结构中存有目前已经重hash到哪个桶了 
  5. // 2. n: N步进行rehash 
  6. // 返回值 返回0说明整个表都重hash完成了,返回1代表未完成 
  7. int dictRehash(dict *d, int n) { 
  8.       int empty_visits = n*10; 
  9.       //如果当前rehashidx=-1,则返回0,表示hash完成 
  10.       if (!dictIsRehashing(d)) return 0; 
  11.       //分n步,而且ht[0]还有没有移动的节点 
  12.       while(n-- && d->ht[0].used != 0) { 
  13.             dictEntry *de, *nextde; 
  14.             assert(d->ht[0].size > (unsigned long)d->rehashidx); 
  15.             //第一个循环用来更新 rehashidx 的值,因为有些桶为空,所以 rehashidx并非每次都比原来前进一个位置,而是有可能前进几个位置,但最多不超过 10。 
  16.             //将rehashidx移动到ht[0]有节点的下标,也就是table[d->rehashidx]非空 
  17.       while(d->ht[0].table[d->rehashidx] == NULL) { 
  18.             d->rehashidx++; 
  19.             if (--empty_visits == 0) return 1; 
  20.       } 
  21.      //第二个循环用来将ht[0]表中每次找到的非空桶中的链表(或者就是单个节点)拷贝到ht[1]中 
  22.       de = d->ht[0].table[d->rehashidx];  
  23.  
  24.      /* 利用循环将数据节点移过去 */ 
  25.      while(de) { 
  26.           unsigned int h; 
  27.  
  28.           nextde = de->next
  29.           h = dictHashKey(d, de->key) & d->ht[1].sizemask; 
  30.           de->next = d->ht[1].table[h]; 
  31.           d->ht[1].table[h] = de; 
  32.           d->ht[0].used--; 
  33.           d->ht[1].used++; 
  34.           de = nextde; 
  35.           } 
  36.      d->ht[0].table[d->rehashidx] = NULL
  37.      d->rehashidx++; 
  38.     } 
  39.  
  40.      if (d->ht[0].used == 0) { 
  41.           zfree(d->ht[0].table); 
  42.           d->ht[0] = d->ht[1]; 
  43.           _dictReset(&d->ht[1]); 
  44.      d->rehashidx = -1; 
  45.      return 0; 
  46.  
  47. return 1; 

总结

 

该篇主要讲了Redis的Hash数据类型的底层实现字典结构Dict,先从Hash的一些API使用,引出字典结构Dict,剖析了其三个主要组成部分,字典结构体Dict,数组结构体Dictht,数据节点结构体DictEntry,进而通过多幅过程图解释了扩容过程和rehash过程,最后结合源码对字典进行描述,如创建过程,扩容过程,渐进式hash过程,中间穿插流程图讲解。

 

责任编辑:武晓燕 来源: 学习Java的小姐姐
相关推荐

2024-08-29 16:30:27

2024-02-29 16:49:20

volatileJava并发编程

2024-03-14 14:56:22

反射Java数据库连接

2024-09-04 17:35:09

2024-03-06 15:38:06

Spring微服务架构扩展组件

2023-12-27 18:16:39

MVCC隔离级别幻读

2021-05-28 11:18:50

MySQLbin logredo log

2024-05-30 08:04:20

Netty核心组件架构

2024-03-05 10:33:39

AOPSpring编程

2024-08-22 10:39:50

@Async注解代理

2022-06-15 15:14:17

Java公平锁非公平锁

2024-03-11 18:18:58

项目Spring线程池

2021-08-09 07:47:40

Git面试版本

2024-09-20 08:36:43

零拷贝数据传输DMA

2024-03-22 06:56:24

零拷贝技术数据传输数据拷贝

2024-02-20 08:13:35

类加载引用Class

2024-07-31 08:28:37

DMAIOMMap

2021-11-25 10:18:42

RESTfulJava互联网

2024-08-12 17:36:54

2021-08-11 08:53:23

Git命令面试
点赞
收藏

51CTO技术栈公众号