Redis系列之一文读懂redis的哈希表

2,189 阅读4分钟

最近有小伙伴去面试,有被问到说你知道redis的hash数据类型底层是用哈希表实现,那你知道redis的哈希表跟jdk中的hashmap有什么区别吗。当时小伙伴一脸懵逼,后来回来找我交流了一波,决定好好捋一捋redis的哈希表结构,话不多说,先来看看redis的哈希表的源码。

redis 哈希表的构造

typedef struct dictEntry {
    void *key;    		// 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;     		        // 值
    struct dictEntry *next;     // 指向下个哈希表节点,形成链表
} dictEntry;

typedef struct dictht {
    dictEntry **table;	        // 数组指针,每个元素都是指向dictEntry的指针
    unsigned long size;		// 这个dictht分配的元素个数 2^n
    unsigned long sizemask;     // 用来求hash的掩码,sizemask = size - 1 即 2^n-1
    unsigned long used;		// 已经使用的元素个数
} dictht;

typedef struct dict {
    dictType *type;		// 方法的指针
    void *privdata;		// 可以传给这个dict的私有数据
    dictht ht[2];	        // 包含两个dictht的数组
    int rehashidx; 		// 是否在进行rehash的标志位
    int iterators; 		// 迭代器
} dict;

粗看起来,比java的hashmap简单很多啊,组织结构跟jdk1.7的hashmap挺类似的,都是由数组跟链表组成,实际存放数据的对象为一个dictEntry对象对应到HashMap就是Entry对象,对比jdk1.7的hashmap多了一些冗余字段比如sizemask、used等,最大区别就是存放dictEntry的数组对象有两个,那么问题来了,为什么redis的哈希表要用两个dictht数组来存放dictEntry链表呢

我们首先来回顾一下hashmap的扩容过程,简单点来说就是需要复制一个新的数组,然后把旧数组内的元素进行rehash,然后把元素重新插入到新的数组中。因为resize操作是一次性,集中式完成的,当hashmap的数组大小为初始容量16时,整个resize过程还是可控的,而经过多次扩容后,数组的元素变多,进行resize的时候操作元素也增多,resize所需要的时间就不可控了。而redis作为高性能缓存,从结构设计上就需要避免这种操作时间不可控的场景存在,而采取的策略就是喜闻乐见的用空间换时间了,所以能看到dict中包含了两个dictht的数组。那么redis具体是怎么做的呢?

渐进式rehash

image-20210508232121361.png

触发条件

当以下条件任意一个被满足时,程序就会自动开始对哈希表进行扩容操作:

  1. 服务器目前没有执行BGSAVE或者BGREWRITEAOF命令时,且哈希表的负载因子大于等于1(tips: JDK的hashmap扩容的因子0.75);
  2. 服务器目前在执行BGSAVE或BGREWRITEAOF命令时,且哈希表的负载因子大于等于5。(tips: 为了尽量避免子进程存在期间对哈希表进行扩容操作,避免不必要的内存写入,最大限度的节约内存)

步骤:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个hash表
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
  3. 在rehash进行期间,每次对字典执行添加,删除,查找或者更新操作时,程序除了执行特定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增1
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成

由于多了一个dictht维护,当哈希表的负载因子小于0.1时,还会对哈希表进行缩容的操作,操作的过程跟渐进式rehash是一致的。

总结一下跟jdk的hashmap的区别

  1. 数据结构上,采用了两个数组保存数据,发生hash冲突时,只采用了链地址法解决hash冲突,并没有跟jdk1.8一样当链表超过8时优化成红黑树,因此插入元素时跟jdk1.7的hashmap一样采用的是头插法。
  2. 在发生扩容时,跟jdk的hashmap一次性、集中式进行扩容不一样,采取的是渐进式的rehash,每次操作只会操作当前的元素,在当前数组中移除或者存放到新的数组中,直到老数组的元素彻底变成空表。
  3. 当负载因子小于0.1时,会自动进行缩容。jdk的hashmap出于性能考虑,不提供缩容的操作。
  4. redis使用MurmurHash来计算哈希表的键的hash值,而jdk的hashmap使用key.hashcode()的高十六位跟低十六位做与运算获得键的hash值。