这应该是 Java Collections Framework 源码分析的最后一部分了,而分析对象也是目前为止最为复杂的数据结构:HashMap。在日常开发中 HashMap 的使用率非常高,应该和 ArrayList 不分上下,而且 HashMap 的相关问题是 Java 面试中必然出现的,因此能够从源码层面理解 HashMap 的相关知识点就变得尤为重要。希望接下来的几篇文章能够帮助你从数个核心知识点上真正掌握 HashMap 这个数据结构。
基础结构
大家可能已经知道 Java8 之后,HashMap 内部存放元素的数据结构发生了一些变化,不再单纯是以前数组+链表的方式,而是在满足某些条件后会变为红黑树的形式。所以让我们先从基础结构开始分析。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node[] table;
可以看到 HashMap 内部使用一个数组来存放数据元素,而数组元素的类型是 Node。需要留意一下的是注释中提到数组的长度一定是 2 的幂。接着看一下 Node 的定义:
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
....
很简单的数据包装对象,封装了 key 与 value 和相应的 hash 值,并且有指向下一个节点,类似单链表的结构。
put 操作
我们可以从 HashMap 最常用的 put 函数入手,先大致看一下 HashMap 是如何新增元素的。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
实际的实现是 putVal
函数,继续往下看:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
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 {
....
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
第一个 if
用来检查 table
是否被初始化,如果没有的话会调用 resize()
函数得到一个新的数组,resize()
函数我们稍后分析,先往下走。
第二个 if
的作用也很明显,通过 hash 值在数组中寻找是否有对应的 Node
对象,如果没有则创建一个新的 Node
。newNode()
函数很简单,只是调用 Node
的构造函数,值得看一下的是 i
的计算逻辑。在理解 i
的计算逻辑就要先看一下 hash
的算法。
Hash 算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到 HashMap
中用到的哈希算法非常简单,调用 key
对象的 hashCode
函数,然后与自身逻辑位移(高位补 0) 16 位之后的值进行 XOR(异或)操作。之后还会有地方会谈及这个哈希算法,现在先回到之前 i
计算的那部分。
确定 table 中的位置
tab[i = (n - 1) & hash])
n
是 table
数组的长度,还记得之前提到过 table
数组长度是 2 的幂吗?因此 n
的二进制形式就是 1 开头,后面都是 0 (2 -> 10, 4 -> 100, 8 -> 1000),而 n - 1
之后的二进制形式就都是 1 (3 -> 11, 7 -> 111)。了解了 n - 1
的数字特征后, n - 1
与 hash
进行 &
(AND 操作) 的结果就容易了:如果 hash
大于 n - 1
,那么会对 hash
截取超过 n -1
的部分,仅保留低位,反之直接返回 hash
值。
哈希冲突
现在可以继续分析 putVal
源码中的 else
部分:
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode)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;
}
}
回忆一下进入 else
分支的条件,即 tab
数组对应 i
的 Node
已经存在。此时我们称之为发生了「哈希冲突」,即不同的 key
产生了相同的哈希值。
第一个 if
分支的逻辑很容易理解,如果数组对应位置上 Node
对象的 hash 值与 key 值都与传入参数一致,那么就是个简单的覆盖。
接下来的 else...if
分支则是判断当前 Node
是否为 TreeNode
的示例,在这个分支中执行的是红黑树的插入操作,这部分的分析会放在后面。
最后的 else
分支中会使用 for
与 Node
上的 next
引用循环链表,便利相同 hash 值的 Node
。其中值得注意的是:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
当计数超过 TREEIFY_THRESHOLD - 1
时会调用 treeifyBin
函数,将原来数组 + 单链表的数据结构转化为红黑树,这个阈值的初始为 8。即当由冲突引起的单链表长度超过 8 之后会执行红黑树的转化。
小结
本篇为大家分析了 HashMap
内部的数据结构,以及 put()
函数的答题流程,包括哈希算法,冲突的解决等。不难发现位操作往往是一些底层算法的基石,因此希望大家在平时还是花些时间掌握位操作的相关知识,最好能够活学活用。之后的文章会为大家分析数组扩容,以及红黑树的部分,希望你不要错过。