散列
在散列表中,我们所做的也就是为每一个key找到一种类似于上述26进制的函数,使得key可以映射到一个数字中,这样就可以利用数组基于下标随机访问的高效性,迅速在散列表中找到所关联的键值对。
所以散列函数的本质,就是 将一个更大且可能不连续空间(比如所有的单词),映射到一个空间有限的数组里,从而借用数组基于下标O(1)快速随机访问数组元素的能力 。
但设计一个合理的散列函数是一个非常有挑战的事情。比如,26进制的散列函数就有一个巨大的缺陷,就是它所需要的数组空间太大了,在刚刚的示例代码中,仅表示长度为3位的、只有a-z构成的字符串,就需要开一个接近20000(26^3)大小的计数数组。假设我们有一个单词是有10个字母,那所需要的26^10的计数数组,其下标甚至不能用一个长整型表示出来。
这种时候我们不得不做的事情可能是,对26进制的哈希值再进行一次对大质数取mod的运算,只有这样才能用比较有限的计数数组空间去表示整个哈希表。
然而,取了mod之后,我们很快就会发现,现在可能出现一种情况,把两个不同的单词用26进制表示并取模之后,得到的值很可能是一样的。这个问题被称之为 哈希碰撞 ,当然也是一个需要处理的问题。
比如如果我们设置的数组大小只有16,那么AA和Q这两个字符串在26进制的哈希函数作用下就是,所对应的哈希表的数组下标就都是0。
JDK的实现
散列函数到底需要怎么设计
- 整个散列表是建立在数组上的,显然首先要保证散列函数 输出的数值是一个非负整数,因为这个整数将是散列表底层数组的下标。
- 其次,底层数组的空间不可能是无限的。我们应该要让散列函数 在使用有限数组空间的前提下,导致的哈希冲突尽量少。
- 最后,我们当然也希望散列函数本身的 计算不过于复杂。计算哈希虽然是一个常数的开销,但是反复执行一个复杂的散列函数显然也会拖慢整个程序。
在JDK(以JDK14为例)中Map的实现非常多,我们讲解的HashMap主要实现在 java.util
下的 HashMap
中,这是一个最简单的不考虑并发的、基于散列的Map实现。
找到用于计算哈希值的hash方法:
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以发现非常简短,就是对key.hashCode()进行了一次特别的位运算。你可能会对这里的,key.hashcode 和 h>>>16,有一些疑惑,我们来看一看。
hashcode
而这里的hashCode,其实是Java一个非常不错的设计。在Java中每个对象生成时都会产生一个对应的hashcode。 当然数据类型不同,hashcode的计算方式是不一样的,但一定会保证的是两个一样的对象,对应的hashcode也是一样的 ;
所以在比较两个对象是否相等时,我们可以先比较hashcode是否一致,如果不一致,就不需要继续调用equals,从统计意义上来说就大大降低了比较对象相等的代价。当然equals和hashcode的方法也是支持用户重写的。
既然今天要解决的问题是如何统计文本单词数量,我们就一起来看看JDK中对String类型的hashcode是怎么计算的吧,我们进入 java.lang
包查看String类型的实现:
java
public int hashCode() {
// The hash or hashIsZero fields are subject to a benign data race,
// making it crucial to ensure that any observable result of the
// calculation in this method stays correct under any possible read of
// these fields. Necessary restrictions to allow this to be correct
// without explicit memory fences or similar concurrency primitives is
// that we can ever only write to one of these two fields for a given
// String instance, and that the computation is idempotent and derived
// from immutable state
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
Latin和UTF16是两种字符串的编码格式,实现思路其实差不多,我们就来看 StringUTF16
中hashcode的实现:
java
public static int hashCode(byte[] value) {
int h = 0;
int length = value.length >> 1;
for (int i = 0; i < length; i++) {
h = 31 * h + getChar(value, i);
}
return h;
}
就是对字符串逐位按照下面的方式进行计算,和展开成26进制的想法本质上是相似的。
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
不过一个非常有趣的问题是为什么选择了31?
答案并不是那么直观。首先在各种哈希计算中,我们比较倾向使用奇素数进行乘法运算,而不是用偶数。因为用偶数,尤其是2的幂次,进行乘法,相当于直接对原来的数据进行移位运算;这样溢出的时候,部分位的信息就完全丢失了,可能增加哈希冲突的概率。
而为什么选择了31这个奇怪的数,这是因为计算机在进行移位运算要比普通乘法运算快得多,而31*i可以直接转化为 (i << 5)- i
,这是一个性能比较好的乘法计算方式,现代的编译器都可以推理并自动完成相关的优化。StackOverflow上有一个相关的 讨论 非常不错,也可以参考《effective Java》中的相关章节。
h>>>16
好,我们现在来看 ^ h >>> 16
又是一个什么样的作用呢?它的意思是就是将h右移16位并进行异或操作,不熟悉相关概念的同学可以参考 百度百科。为什么要这么做呢?
哦,这里要先跟你解释一下,刚刚那个hash值计算出来这么大,怎么把它连续地映射到一个小一点的连续数组空间呢?想必你已经猜到了,就是前面说的取模,我们需要将hash值对数组的大小进行一次取模。
那数组大小是多少呢?在 HashMap 中,用于存储所有的{Key,Value}对的真实数组 table ,有一个初始化的容量,但随着插入的元素越来越多,数组的resize机制会被触发,而扩容时,容量永远是2的幂次,这也是为了保证取模运算的高效。马上讲具体实现的时候会展开讲解。
总而言之,我们需要对2的幂次大小的数组进行一次取模计算。但前面也说了 对二的幂次取模相当于直接截取数字比较低的若干位,这在数组元素较少的时候,相当于只使用了数字比较低位的信息 ,而放弃了高位的信息,可能会增加冲突的概率。
所以,JDK的代码引入了 ^ h >>> 16
这样的位运算,其实就是把高16位的信息叠加到了低16位,这样我们在取模的时候就可以用到高位的信息了。
当然,无论我们选择多优秀的hash函数,只要是把一个更大的空间映射到一个更小的连续数组空间上,那哈希冲突一定是无可避免的。那如何处理冲突呢?
JDK中采用的是开链法。
哈希表内置数组中的每个槽位,存储的是一个链表,链表节点的值存放的就是需要存储的键值对。如果碰到哈希冲突,也就是两个不同的key映射到了数组中的同一个槽位,我们就将该元素直接放到槽位对应链表的尾部。
JDK代码实现
现在,在JDK中具体实现的代码就很好理解啦,table 就是经过散列之后映射到的内部连续数组:
java
transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//在tab尚未初始化、或者对应槽位链表未初始化时,进行相应的初始化操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 查找 key 对应的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历所有节点 依次查找
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
通过hash函数的计算,我们可以基于这个数组的下标快速访问到key对应的元素,元素存储的是Node类型。
估计你会注意到第21行进行了一个treeifyBin的操作,就是说当哈希冲突产生的链表足够长时,我们就会把它转化成有序的红黑树,以提高对同样hash值的不同key的查找速度。
这是因为在HashMap中Node具体的实现可以是链表或者红黑树。用红黑树的整体思想和开链是一样的,这只是在冲突比较多、链表比较长的情况下的一个优化,具体结构和JDK中另一种典型的Map实现TreeMap一致,我们会在下一讲详细介绍。
好,我们回头看整体的逻辑。
开始的5-8行主要是为了在tab尚未初始化、或者对应槽位链表未初始化时进行相应的初始化操作。 从16行开始,就进入了和待插入key的hash值所对应的链表逐一对比的阶段 ,目标是找到一个合适的槽位,找到当前链表中的key和待插入的key相同的节点,或者直到遍历到链表的尾部;而如果节点类型是红黑树的话,底层就直接调用了红黑树的查找方法。
这里还有一个比较重要的操作就是第40行的resize函数,帮助动态调整数组所占用的空间,也就是底层的连续数组table的大小。
java
if (++size > threshold)
resize();
随着插入的数据越来越多,如果保持table大小不变,一定会遇到更多的哈希冲突,这会让哈希表性能大大下降。所以我们有必要在插入数据越来越多的时候进行哈希表的扩容,也就是resize操作。
这里的threshold就是我们触发扩容机制的阈值,每次插入数据时,如果发现表内的元素多于threshold之后,就会进行resize操作:
java
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 翻倍扩容
}
else if (oldThr > 0) // 初始化的时候 capacity 设置为初始化阈值
newCap = oldThr;
else { // 没有初始化 采用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor; // 用容量乘负载因子表示扩容阈值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
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 {
// 新扩容部分,标识为hi,原来的部分标识为lo
// JDK 1.8 之后引入用于解决多线程死循环问题 可参考:https://stackoverflow.com/questions/35534906/java-hashmap-getobject-infinite-loop
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 整体操作就是将j所对应的链表拆成两个部分
// 分别放到 j 和 j + oldCap 的槽位
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
看起来 resize 的代码比较复杂,但核心在做的事情很简单,就是要将哈希桶也就是内置的table数组,搬到一个更大的数组中去。主要有两块逻辑我们需要关注一下。
第一部分在第6-26行,主要的工作就是计算当前扩容的数组大小和触发下一次扩容的阈值threshold。
可以看到命中扩容条件的分支都会进入13行的逻辑,也就是每次扩容我们都会扩容一倍的容量。这和c++中STL动态数组的扩容逻辑是相似的,都是为了平衡扩容带来的时间复杂度和占用空间大小的权衡;当然这也是因为我们仍然需要保持数组大小为2的幂次,以提高取模运算的速度。其他行主要是为了处理一些默认参数和初始化的逻辑。
在第22行中,我们还会看到一个很重要的变量loadfactor,也就是负载因子。这是创建HashMap时的一个可选参数,用来帮助我们计算下一次触发扩容的阈值。假设 length 是table的长度,threshold = length * Load factor。在内置数组大小一定的时候,负载因子越高,触发resize的阈值也就越高;
负载因子默认值0.75,是基于经验对空间和时间效率的平衡,如果没有特殊的需求可以不用修改。loadfactor越高,随着插入的元素越来越多,可能导致冲突的概率也会变高;当然也会让我们有机会使用更小的内存,避免更多次的扩容操作。