从HashMap,Redis 字典看【Hash】。。。

前言

今天摸鱼看了下HashMap源码,想起大神同学面试遇到过面试官问Redis 字典HashMap的哈希过程有何不同。。。老实说,也看过Redis设计与实现(真心推荐),但是准确地描述不出来,故写下此文。

:由于本文偏重于hash过程,源码部分就看大神们浅显易懂的叙述吧~

散列函数(散列算法)

  • 散列函数又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法,而这个“指纹”就是散列值

  • 散列函数的应用领域很广,例如保护数据、确保传递真实的信息、散列表等。本文主要讨论的是散列表上的应用。

  • 当然我们希望散列函数能保证每个key对应一个“指纹”,也就是散列值,所谓的完美散列,但源于对性能、应用场景等考虑,可以接受不太多的散列碰撞

  • **散列碰撞:**散列函数的输入和输出不是唯一对应关系,例如散列函数的输入A、B得到的散列值都是C。

  • 常见的散列函数:

直接定址法 数字分析法 平方取中法 折叠法 除留余数法 随机数法

  • 想必大家都知道HashMap、Redis 字典类的场景,都会选择基于出留余数法进行优化,来作为散列函数。这里就不再赘述各个方法的概念,请大家到这里看看

散列冲突(散列碰撞)的解决方法

  • hash?->散列算法的选择->散列冲突怎么解决想必这是大多数同行们的思维定式了。那么,我们看下散列冲突的解决算法主要有什么?

开放定址法

  • 一旦发生了冲突,就去寻找下一个空的散列地址,最简单的算法公司如下。 f(key) = (f(key) + d) mod  m(d= 1,2,...,m-1) 举个栗子,设置m为12,依次插入26、37,(26+1)% 12 = (37+1)%12,出现了散列冲突,因此再次(37+2)%12 = 4,散列值就不一样了,冲突就解决了。

再散列法

  • 同时准备多个散列函数,当第一个散列函数发生冲突时可以用备用的散列函数计算。

链地址法

  • 先用除留余数法得到散列值,如果散列值冲突了通过链表把冲突的节点挨个插入,形成如下图的结构。 下面例子为,f(x) = key mod 12的场景,可以看到48 % 12 = 12 % 12 = 0,形成了一个链表。

公共溢出区法

  • 使用额外的公共存储空间存储散列值冲突的元素。

HashMap

HashMap的散列算法

小插曲LOAD_FACTOR(负载因子)

  • 大家都知道HashMap的默认LOAD_FACTOR0.75,它的作用是什么呢?下面跟着源码追寻踪迹把~

  • new一个HashMap,源码注释告诉我们capacity为16、load_factor为0.75。

/**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
复制代码
  • 随着put元素,必定要进行扩容,看resize()函数,指贴出标志性的部分。
else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
复制代码
  • 可以看到,阈值是通过capacity乘以load_factor得出的,即16 * 0.75 = 12HashMap中的元素多于阈值就要扩容,可以理解为阈值相对于容量小,就降低了散列冲突的几率,因为放入16个元素的散列冲突的几率,在相同的散列函数下,大概率比12个元素的散列冲突几率小

散列函数公式及解析

  • HashMap的散列算法近似于除留余数法,但没有用MOD运算,而是用位运算,假设key为输入值,散列函数为f(key)具体公式如下:
f(key) = hash(key) & (table.length - 1) 
hash(key) = (h = key.hashCode()) ^ (h >>> 16)
复制代码
  • hash(key) & (table.length - 1) 是对table.length取余的优化版,其实作用差不多,就是基于除留余数法。由于HashMap的特性每次扩容table.length都会是2^n,因此位运算明显效率更高。
  • >>>是无符号右移位运算符,我们知道hashCode()取值范围很广,本身冲突的可能性很小,但是与上table.length - 1 这个几率就变大了,因为table.length是一个较小的值。这就是为什么会使用>>> 16的原因,hashCode()的高位和低位都对f(key)有了一定影响力,使得分布更加均匀,散列冲突的几率就小了。

HashMap的散列冲突解决

  • HashMap的散列冲突解决方法明显是链地址法,其实从结构就可以看出来。
  • 可以从HashMapresize()方法也可以看出来,下面请看部分源码。。。上的注释。
if (oldTab != null) {
			// 遍历旧数组上的节点
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    // 当前链表只有一个节点(没有散列冲突的情况)
                    if (e.next == null)
	                    // 通过散列算法计算存放位置并放入
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
	                    // 红黑树去了。。。
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
	                    // 低位链表、高位链表
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
	                        // 遍历发生散列冲突的链表
                            next = e.next;
                            // hash值小于旧数组容量 放入低位链表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // hash值大于等于旧数组大小 放入高位链表
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 低位链表放在原来index下
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 高位链表放在原来index + 旧数组大小
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
复制代码

Redis 字典

数据结构简介

HashMap差不多的地方

首先,简单对Redis字典的数据结构进行简要说明,都是我读大学时产生阴影的C语言。

typedef struct dictht {
	// 哈希表数组
	dictEntry **table;

	// 哈希表大小
	unsigned long size;

	// 哈希表大小掩码,用于计算索引值
	// 总是等于size - 1
	unsigned long sizemask;
	
	// 哈希表已使用节点数
	unsigned long used;
	
} dictht
复制代码
  • 有个哈希表数组,是不是有点眼熟,和Node<K,V>[]异曲同工之妙,接下来看看dictEntry这个类。
typedef struct dictEntry {
	// 键
	void *key

	// 值
	union {
		void *val;
		unit64_tu64;
		int64_ts64;
	} v

	// next指针
	struct dictEntry *next;
}
复制代码
  • 又有点眼熟了,键值对!key属性保存着键值对中的键,而v属性则保存着值,其中键值对的值可以是一个指针、unit64_tint64_t整数。next是指向另一个哈希表节点的指针,这个指针可以将多个哈希表相同的键值对连接成一个链表。

  • 可以看到,目前为止,HashMap基本没啥区别,除了多了一些额外属性(哈希表大小、已使用节点数、掩码等)。

区别的地方

  • 刚才看了字典的底层是由哈希表结构实现的,那么字典的真面目是怎样的呢?
typedef struct dict {
	// 类型特定函数
	dictType *type;
	
	// 私有数据
	void *privdata;

	// 哈希表(上文讲的)
	dictht ht[2];

	// rehash索引
	// 当rehash不在进行时,值为-1
	int trehashidx;
}
复制代码
  • 至此,Redis字典的数据结构介绍完了,下图为在普通状态下的字典。

Redis 字典散列算法

  • 计算哈希值的流程为基于字典的计算哈希值的函数计算哈希值: hash = dict -> type->hashFunction(key)
  • 使用哈希表的sizemask属性和哈希值,计算出索引值,根据情况不同ht[0]ht[1]。其实和HashMaphash(key) & (table.length - 1) 一样,因为注释上说了sizemask总是等于size - 1

index = hash & dict->ht[x].sizemask

  • Redis使用的哈希值计算算法为MurmurHash2,笔者没能力说明,给出连接。

Redis 字典散列冲突解决

  • Redis的哈希表也使用链地址法,每个节点都有一个next指针,多个节点形成单向链表,与HashMap不同的是由于没有表尾指针,使用头插法将新节点添加到链表的表头位置。

Redis与HashMap区别

看到散列算法、散列冲突解决方式,没有太大的区别,那么差别到底在哪儿呢?那就是再哈希

Rehash

  • 刚才讲到,字典数据结构里有两个哈希表(ht[2]),秘密就在这里。

  • Rehash的目的在于为了让哈希表的负载因子维持在合理的范围内,哈希表在键值对太多或者太少时,需要进行扩展或收缩

  • 步骤如下:

    1. 为字典的ht[1]哈希表分配空间,如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used *22^n;如果执行的是收缩操作,那么ht[1]的大小第一个大于等于ht[0].used2^n
    2. 将保存在ht[0]中的所有键值对rehashht[1]上面,即重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
    3. ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空哈希表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
  • :渐进式Rehash本文没讲,画图笔者实在没动力,数据结构较复杂,请谅解。

总结

本文从散列算法、散列碰撞解决出发,简要分析了HashMapRedis 字典hash,观点不一定都对,请各位大神批评指正!

参考文献

猜你喜欢

转载自juejin.im/post/5d67c96c6fb9a06b160f4017
今日推荐