前言
今天摸鱼看了下HashMap
源码,想起大神同学面试遇到过面试官问Redis 字典
和HashMap
的哈希过程有何不同。。。老实说,也看过Redis设计与实现(真心推荐),但是准确地描述不出来,故写下此文。
注:由于本文偏重于hash过程,源码部分就看大神们浅显易懂的叙述吧~
散列函数(散列算法)
-
散列函数又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法,而这个“指纹”就是散列值。
-
散列函数的应用领域很广,例如保护数据、确保传递真实的信息、散列表等。本文主要讨论的是散列表上的应用。
-
当然我们希望散列函数能保证每个key对应一个“指纹”,也就是散列值,所谓的完美散列,但源于对性能、应用场景等考虑,可以接受不太多的散列碰撞。
-
**散列碰撞:**散列函数的输入和输出不是唯一对应关系,例如散列函数的输入A、B得到的散列值都是C。
-
常见的散列函数:
直接定址法 数字分析法 平方取中法 折叠法 除留余数法 随机数法
- 想必大家都知道HashMap、Redis 字典类的场景,都会选择基于出留余数法进行优化,来作为散列函数。这里就不再赘述各个方法的概念,请大家到这里看看。
散列冲突(散列碰撞)的解决方法
hash?->散列算法的选择->散列冲突怎么解决
想必这是大多数同行们的思维定式了。那么,我们看下散列冲突的解决算法主要有什么?
开放定址法
- 一旦发生了冲突,就去寻找下一个空的散列地址,最简单的算法公司如下。 举个栗子,设置为12,依次插入26、37,(26+1)% 12 = (37+1)%12,出现了散列冲突,因此再次(37+2)%12 = 4,散列值就不一样了,冲突就解决了。
再散列法
- 同时准备多个散列函数,当第一个散列函数发生冲突时可以用备用的散列函数计算。
链地址法
- 先用除留余数法得到散列值,如果散列值冲突了通过链表把冲突的节点挨个插入,形成如下图的结构。 下面例子为,的场景,可以看到48 % 12 = 12 % 12 = 0,形成了一个链表。
公共溢出区法
- 使用额外的公共存储空间存储散列值冲突的元素。
HashMap
HashMap的散列算法
小插曲LOAD_FACTOR
(负载因子)
-
大家都知道
HashMap
的默认LOAD_FACTOR
为0.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 = 12
。HashMap
中的元素多于阈值就要扩容,可以理解为阈值相对于容量小,就降低了散列冲突的几率,因为放入16个元素的散列冲突的几率,在相同的散列函数下,大概率比12个元素的散列冲突几率小。
散列函数公式及解析
- HashMap的散列算法近似于除留余数法,但没有用
MOD
运算,而是用位运算,假设为输入值,散列函数为具体公式如下:
f(key) = hash(key) & (table.length - 1)
hash(key) = (h = key.hashCode()) ^ (h >>> 16)
复制代码
hash(key) & (table.length - 1)
是对table.length
取余的优化版,其实作用差不多,就是基于除留余数法。由于HashMap
的特性每次扩容table.length
都会是,因此位运算明显效率更高。>>>
是无符号右移位运算符,我们知道hashCode()
取值范围很广,本身冲突的可能性很小,但是与上table.length - 1
这个几率就变大了,因为table.length
是一个较小的值。这就是为什么会使用>>> 16
的原因,hashCode()
的高位和低位都对f(key)
有了一定影响力,使得分布更加均匀,散列冲突的几率就小了。
HashMap的散列冲突解决
HashMap
的散列冲突解决方法明显是链地址法,其实从结构就可以看出来。- 可以从
HashMap
的resize()
方法也可以看出来,下面请看部分源码。。。上的注释。
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_t
、int64_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]
。其实和HashMap
的hash(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
的目的在于为了让哈希表的负载因子维持在合理的范围内,哈希表在键值对太多或者太少时,需要进行扩展或收缩。 -
步骤如下:
- 为字典的
ht[1]
哈希表分配空间,如果执行的是扩展操作,那么ht[1]
的大小为第一个大于等于的;如果执行的是收缩操作,那么ht[1]
的大小第一个大于等于ht[0].used
的。 - 将保存在
ht[0]
中的所有键值对rehash
到ht[1]
上面,即重新计算键的哈希值和索引值,然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后(ht[0]
变为空哈希表),释放ht[0]
,将ht[1]
设置为ht[0]
,并在ht[1]
新创建一个空白哈希表,为下一次rehash
做准备。
- 为字典的
-
注:渐进式Rehash本文没讲,画图笔者实在没动力,数据结构较复杂,请谅解。
总结
本文从散列算法、散列碰撞解决出发,简要分析了HashMap
,Redis 字典
的hash
,观点不一定都对,请各位大神批评指正!