1.三个问题:
- 为什么HashMap的容量一定是 2 n 2^n 2n
- HashMap中的hash函数具体是怎么实现的
- resize函数
2.HashMap如何存储数据的
首先要说的是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<K,V>[] table;//Node类型的数组,存的是链表的头节点或者是红黑树的根,我们称之为hash槽(slot)
Node是HashMap中的内部类,主要有这如下的属性,可以明显看出来是典型的链表,单个Node我们称之为Hash桶(bucket)
final int hash;//hash值
final K key;//key值
V value;//value值
Node<K,V> next;//后继节点
3.HashMap中的Hash函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先是传入类型,因为是泛型,所以将输入作为所以类的父类Object作为类型,而Object类中则规定了所有的类是要实现hashcode方法的,所以key.hashCode()是一定可以返回一个int类型的hash值的。然后我们来逐段来分析这个return语句中的代码;
h = key.hashCode()
这句是将key对应的hashcode(默认是对象地址)赋给了 h
h >>> 16
这句是将h右移16位,同时舍弃了低位的16位,并且对高位补 0 ,这步的意义是将高16位的信息移动到低位来,然后将高16位和低16位做异或操作,这样子可以最大限度的利用key的hashcode中的所有信息,来减少碰撞。
注意到此处,仅仅是求出了key的hash值,并不意味着这是key在Hash槽中的位置,在有了key对应的hash值后,我们才可以计算出其在槽的具体位置,方法可以在putVal方法中找到,如下:
(n - 1) & hash;//其中n为当前table的值,hash为key的hash值
为什么这里取(n-1)呢,还记得前面提出的问题吗,HashMap的长度必定是 2 n 2^n 2n,熟悉二进制的可能已经反应过来了,因为 2 n − 1 2^n-1 2n−1是一长串的 01111111111 ∗ ∗ ∗ ∗ ∗ 111111111 01111111111*****111111111 01111111111∗∗∗∗∗111111111,然后利用这个直接和hash值与操作,意义等于对其取模,然而位运算的速度是要高于%操作的,至此,我们就已经求出对应key在slot中的位置了。同时也明白了为什么HashMap的长度必定是 2 n 2^n 2n的一个原因,即更加的方便利用位运算来完成计算,此处即体现为hash函数的计算过程。
4.HashMap的扩容机制
显然slot的空间是有限的,如果超过了阈值,就会触发HashMap的扩容机制。而就会调用resize方法。
首先我们要知道该方法的调用时机,除了超过阈值需要扩容,另外还会在数组未初始化时,第一次调用put方法时被调用,在putVal方法之中是可以看到的:
if ((tab = table) == null || (n = tab.length) == 0) //table即是slot
n = (tab = resize()).length;
另外的情况则是:
if (++size > threshold)
resize();
再明确了调用时机之后,我们就应该来思考一下resize函数需要做哪些工作了,首先旧的数组肯定是不够大,我们需要开一个新的;然后旧的元素需要重新落入新数组的哪个位置是不是需要重新计算了,所以我们rehash一次了。事实上resize方法也就主要完成了这两件事情,但是其中优化的诸多细节非常值得我们学习。
开新数组并没有什么值得优化的空间,而rehash这个过程极大的决定了效率,这个地方源码设计的细节就非常值得品味了。我们先来设身处地的思考一下,如果换成我们,我们考虑如何去实现这个细节,不难想到的是,从头开始遍历每一个桶,重新计算一次hash值,得出新位置然后插入。然而这里有一个性质就决定了这里是可以做出优化的,不妨先思考一下,旧元素在新表中的位置,到底有哪些可能呢?
此处我们为了后续描述方便,称旧容量oldCap为
2
m
2^m
2m,新容量newCap为
2
m
+
1
2^{m+1}
2m+1。所以答案可能是
2
m
+
1
2^{m+1}
2m+1个,即均匀分布,或者是1,即全在原来位置,还是随机的分布呢?其实这个地方只有两种可能,一种是仍然落在原来的位置,一种是落在原来位置向后oldCap个位置。接下来来分析一下为什么一定时这两种可能,关键仍然是hash函数,我们在回头看一下前面是如何确定位置的:
(n - 1) & hash;//其中n为当前table的值,hash为key的hash值
这里n-1,即oldCap(或者是newCap)-1,转化成二进制,一定是连续的m-1(newCap则是m个)个连续个1,不难发现,两者仅仅是在第m位上是0和1的区别,我们回忆一下与操作的性质,就不难发现,hash值的第m位是可以用来区分,当这位为1时的时候,得出来的新值一定是比原来值大一个 2 m 2^m 2m的(二进制转10进制),而该为为0的时候,则不变;而利用oldCap值,因为oldCap值 2 m 2^m 2m只在m位上为1,去和hash作与操作,就可以将hash值分成两类,一类低位仍然落在原来的位置,一类为高位落在原来位置向后oldCap个位置。
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) // 初始化有对容量进行声明
newCap = oldThr;
else { // 未声明容量时,第一次调用resize,直接用默认规模和因子赋值
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];//开了一个新slot
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {//遍历slot
Node<K,V> e;//桶的起始节点
if ((e = oldTab[j]) != null) {//将旧节点赋给e
oldTab[j] = null;//旧节点置空后等待GC
if (e.next == null)//空直接插入
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//如果是红黑树则执行红黑树种的split方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 保留顺序的拆分为两个链表
Node<K,V> loHead = null, loTail = null;//低位的链表
Node<K,V> hiHead = null, hiTail = null;//高位的链表
Node<K,V> next;//遍历时使用的容器
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//根据key的n+1位的值划分为高低位
if (loTail == null)
loHead = e;//第一个,直接赋值头节点
else
loTail.next = e;//不是第一个,在尾节点后面续上e
loTail = e;//尾更新为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;//高位落入了原位置向后一个oldCap的地方
}
}
}
}
}
return newTab;
}
然后是红黑树实现的内部类的TreeNode节点中的split方法
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;//类似的高低位,但是红黑树也是先转化成了链表
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {//遍历红黑树
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {//bit传进来的是oldCap
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)//低位链表不需要树化,直接存
tab[index] = loHead.untreeify(map);
else {//否则将其树化存入
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)//类似的逻辑
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
至此我们已经大体分析其中resize方法的逻辑与其中的一个关键优化了,可以看出来,hash函数,HashMap规模这是息息相关的,而选取 2 n 2^n 2n作为规模主要是为了迎合计算机的运算特点,而在利用这些二进制的特点上,针对resize方法做出一个很大的优化
5.总结
可以看出来,JDK的HashMap中的诸多设计是一环扣一环的,而不能单纯的切开一个方法区分析HashMap,而且对二进制细节的分析更是让人惊叹,非常值得大家学习