文章目录

  • dict
  • 解决哈希冲突
  • 为什么出现哈希碰撞
  • 解决方法
  • rehash
  • 为什么要rehash
  • 什么时候需要rehash
  • redis的rehash
  • 渐进rehash
  • 使用两个哈希表
  • 定时rehash
  • 优化
  • 函数
  • 创建
  • 总结
  • 关于作者


dict

  今天是哈希表的中篇,花了很大的篇幅来描述哈希表,足以证明这个结构的重要性了。redis就是基于哈希的一个设计,而且如今的分布式都和哈希有着密切的关系。redis的数据结构部分结束后,计划增加哈希表的应用,布隆过滤器和一致性哈希,这两个是哈希结构比较典型的两个场景,一个是关于大数据的,一个关于分布式的。当然也会有其它数据结构的实战操作,比如链表,链表和哈希表的结合等等。下面进入正题,今天会涉及以下部分:

  • 解决哈希碰撞
  • rehash
  • 源码

解决哈希冲突

  什么是哈希碰撞?让我们回顾一下哈希函数的第三个特征,不同的输入可能对应相同的输出,即哈希碰撞。

为什么出现哈希碰撞

  学习一个东西必须从问题出发,那么究竟是什么原因呢?同学们肯定还记得哈希函数的第一个特征-输入域是无穷大的,输出域是有限的。函数是一种映射,而且很容易看出是多对一的映射关系,这种映射必然会导致哈希碰撞。

解决方法

  • 拉链法
  • 开放定址法
  • 再哈希法
  • 公共溢出区法

  出了问题能怎么办,just do it.如果通过哈希函数计算的值映射到数组后,数组索引处已经有元素了,那么把每个数组元素的类型定义成链表或者是节点类型就好了。
  或许会有疑惑,到底使用链表还是节点类型?使用头插法还是尾插法呢?首先第一个问题redis采用了节点类型,因为不需要辅助变量去获得链表的长度信息,首元节点和尾节点,这些对于哈希表是没什么价值的。其次第二个问题redis使用了头插法,头插法的优势很明显,直接插入就可以,而尾插法需要遍历一下单链表。
  说实话,三儿基本上只用过拉链法,简单易懂好实现。之前看golang的map的实现时。好像是结合了拉链法和公共溢出区两种方法,emmmm,毕竟当时没有把整个过程浏览一遍,只是大概的看了下map底层的数据结构,不看就没有发言权。所以就不猜测了,如果同学们对别的方法感兴趣可以找一本数据结构的书。

rehash

  如果哈希表不断的添加而没有rehash的过程那么就太扯了。所谓的rehash就是随着不断的添加,我们需要给数组扩容,然后将原来所有的节点重新通过hash函数映射到新的数组上。

为什么要rehash

  链表的长度不能无限的增长,如果链表长度太长,那么查找效率就会大大降低。

什么时候需要rehash

  是否需要进行rehash是通过负载因子(节点个数/数组容量)决定的,java中的默认为0.75,java采用0.75是根据数学统计的,这时可以保证基本每条链表的长度都小于8,拨云见日,其实负载因子就是在控制链表长度,使得链表不要太长,以保证搜索的效率。
  redis是直接计算每条链的平均长度的,当长度超过预定的阈值后,就要进行一次rehash。redis的阈值设置的是5。
  多说一句,在经典的哈希表结构中,如果有一条链的长度超过阈值,那么就会rehash,这是因为哈希函数的第四条特征,既然碰撞是等概率的,那么如果一条链长度达到了阈值,那么可以认为所有链的长度都达到了阈值。下面的代码if语句的最后一个判断条件就是我们上述描述的redis rehash的条件。

static int _dictExpandIfNeeded(dict *d)
{
    ...
    //dict_force_resize_ratio == 5
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))  //
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

  如果对于redis的哈希结构,一条链的长度可能为阈值*数组容量,但是经典哈希不会。但是对于经典哈希也有弊端,如果恰巧添加的所有键值对哈希都映射到同一个位置上,那么可能会不停的扩容。不过这都是非常极端的情况。所以没有什么好坏,只有根据场景的取舍。哈希表这个结构中设计到了非常多的数学知识,比如经典哈希的数组长度都要求质数,而且阈值设定的是7,都是一些概率问题,对于三儿这个数学渣渣,只能安慰自己数学验证过就是这么做,按照步骤来就行。

redis的rehash

  • 渐进rehash
  • 使用两个哈希表
  • 定时rehash
渐进rehash

  虽然控制平均的链长为5,但是如果数组的容量很大,有数百万,数亿的键值对,一次性rehash可能会卡顿一段时间吧,这肯定是不行的啊。所以redis采用了渐进式,每次搬移一条链。当需要rehash时,搬移一条链,接下来的rehash操作会在接下来的任何关于这个字典的增删改查中进行,至于怎么判断是否在rehash的过程中,是通过rehashidx的值,-1代表不需要rehash。

#define dictIsRehashing(d) ((d)->rehashidx != -1)
使用两个哈希表

  既然是渐进rehash,所以就涉及两个哈希表,这就是为什么redis的dict中使用的是一个哈希表数组。这个数组的作用正是为rehash服务的。在进行rehash过程前,只用0号哈希表,一旦需要rehash,那么所有的操作会首先在1号哈希表中进行,如果没有完成的话,再对0号哈希表进行同样的操作。比如增加键值对会直接插入1号哈希表,再比如查询键值对,现在1号中查找,找到即返回结果,否则去0号哈希表中查找,返回最终查找结果。如果0号中已经没有节点了,那么此时交换0号和1号哈希表,并且更新rehashidx。下面的代码中的if语句块描述了这个过程。

int dictRehash(dict *d, int n) {
    //...
    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}
定时rehash

  如果在rehash后很久没有对这个字典操作,那么就不进行rehash了吗,redis怎么可能这么草率,redis会有定时任务去解决这个问题。

优化

  拉链法中的单链表可以使用红黑树,这样相对于链表可以减少rehash的次数。既然减少了rehash次数,那么树中的节点必然要比链表的多,所以为了查找效率使用了红黑树。三儿当时为了自己实现一个哈希表,去学习了红黑树,看完了添加的情况就看不下去了,如果同学们有兴趣拿下红黑树,推荐算法的橙皮书,先看2-3树,2-3树的实现就是红黑树,原理很简单,实现难到爆炸。

函数

  进入源码分析部分,任何一个数据结构的算法都是增删改查,不过我们今天只介绍创建函数。

创建

  创建函数基本上就是分配空间,初始化为零值或者需要的值。这并没有什么好学习的,但是dict的创建函数有一些学习的点。那就是尽量每个函数都只做一件事,这件事要尽可能的小。这样就会有较好的复用性,下面的创建函数就复用了一部分重置的函数。虽然这个点非常小,但是作为励志成为优秀的程序猿怎么可以忍受复制粘贴,我们要尽可能的代码复用。有兴趣的可以看一下代码整洁之道或者代码大全,三儿目前对怎么写一份好的代码还基本上没有学习多少。

dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

  上面需要解释的一点是type和privdata成员的作用,它们是为了处理不同类型的键值对。type用来选择特点类型的一组处理键值对的函数,而privdata是这些键值对处理函数的参数。下面的宏体现了type的多态作用,而privdata可以根据while中的函数指针的调用所在处看到是作为数据使用的。

#define dictHashKey(d, key) (d)->type->hashFunction(key)

unsigned long dictScan(dict *d,
                       unsigned long v,
                       dictScanFunction *fn,
                       dictScanBucketFunction* bucketfn,
                       void *privdata)
{
    //...
    if (!dictIsRehashing(d)) {
    
        //...
        while (de) {
            next = de->next;
            fn(privdata, de);
            de = next;
        }

        //...
    } else {
        //...
    }

    return v;
}

总结

  上篇重点讲了哈希函数,今天重点部分在哈希碰撞和rehash,这就把所有关于哈希表的原理都讲解到了,三儿也提到了哈希表中很多东西都是根据数学特性来的,所以我们不必纠结某些过程,比如我们不需要研究哈希函数怎么设计,为什么java的负载因子是0.75,为什么redis定义的阈值为5等等。我们只需要从宏观的方面上把握哈希表的各个知识点,比如说映射的过程,rehash的过程。今天源码部分的讲解只开了个头,因为剩下的操作都是基于查找的,所以把接下来的增删改查放在下一篇中,并不是三儿偷懒哦。

关于作者

大四学生一枚,分析数据结构,面试题,golang,C语言等知识。