前言
今天,我们主要来研究一下在Java8中HashMap的数据结构及一些重要方法的具体实现。
研究HashMap的源代码之前,我们首先来研究一下常用的三种数据结构:数组、链表和红黑树。
数组作为一种基本的数据结构,以线性的方式组织数据,按数据的插入顺序来排列数据。在内存中,数组的物理组织形式是一段连续的内存空间。在数据操作上,由于数组的物理特点,可以在O(1)的时间复杂度内完成数据的查找,在插入和删除上需要更长的时间(O(n)的时间复杂度)。
链表在逻辑特性上基本与数组保持一致,同样以线性的方式组织数据,按数据的插入顺序来排列数据。在内存中,链表的物理组织形式是离散的磁盘空间,通过指针进行不同链表节点的链接,因此在数据的插入和删除上具有非常好的性能,可以达到O(1)的时间复杂度,但是在查找上面性能较差,需要O(n)的时间复杂度。
红黑树是一种经过了优化的平衡二叉树,可以在数据的插入上获得更好的性能。首先红黑树在数据的查找上可以达到O(nlog(n))的时间复杂度,同时数据的插入和删除,可以实现局部调整,从而可以承担更少的性能代价(相较于平衡二叉树)。
HashMap的设计就是为了在查找上获得最优的性能(希望基本获取O(1)的时间复杂度),因此HashMap主要使用了数组作为核心的数据存储的结构,同时使用链地址法解决K-V映射时产生的碰撞,在Java8中同时加入了红黑树来优化碰撞时数据匹配的性能。HashMap的组织形式大致如下图所示:

HashMap的映射方法
Java8中结合元素自身的hashcode以及再hash的方式来获取元素的哈希值,在通过取模的方式映射到数组中,其中取模运算是通过优化后的位运算来实现的。首先来看一下元素的hashcode的获取方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
由此可知,HashMap是使用元素自身的hashcode的前16位作为哈希值的。结下来看一看如何使用该哈希值做映射,为了说明方便我们使用Java7中的indexFor函数来做说明,在Java8中虽然没有显示的这个函数了,但该函数的具体实现被嵌入到了具体的各个方法中了。
static int indexFor(int h, int length) {
return h & (length-1);
}
如上,需要解释两个问题,1)如何使用位运算&来实现取模运算2)在HashMap中,数组table的长度为啥必须为2的n次方。
首先来解释第一个问题1)如何使用位运算&来实现取模运算。先来看一个简单的例子,当数组的长度为16的时候,实现取模运算和位运算&时候的结果如下:
key&(table.length-1) 二进制 | index | key%(table.length-1) | index |
4&15 100&1111 | 100 | 4%15 | 4 |
13&15 1101&1111 | 1101 | 13%15 | 13 |
19&15 10011&1111 | 11 | 19%15 | 4 |
由上表可知,可以通过位运算&的方式,实现hashcode的映射,将不同的hashcode映射到不同的数组下标中,并且如表所知,可以通过hashcode的末几位决定映射后的下标位置。同时可以知道,实际上hashcode的很多位是用不上的,因此在HashMap的hash函数中,才使用了移位运算,只取了hashcode的前16位来做映射。
同时另一方面,虽然通过对2^n-1做&运算不会和%运算获得同样的结果,但是能够将hashcode做一个均匀的散列,其效果完全等同于取模运算,并且会有更优的运算性能。
因此第二个问题2)HashMap中,数组table的长度为啥必须为2的n次方的第一个层面就回答完了,为了在&运算中,使元素在数组中映射的更均匀,达到%运算的效果,所以将数组的大小必须设定位2的n次幂。同时该大小的设定还有第二个层次的原因,具体我们将在resize函数的解析中,做进一步的剖析,请继续研读下去。