接上一篇博文,来吧剩下的部分写完。
整体来讲,HashMap的实现内部有两个关键点,第一是当表内元素和hash桶数组的比例达到某个阈值时会触发扩容机制,不然表中的元素会愈来愈挤影响性能;
第二是保存hash冲突的链表若是过长,就重构为红黑树提高性能。java
<!-- more -->
关于第二点,对于HashMap来讲,达到O(1)的查询性能只是平均时间复杂度,这须要key的hash值对应的位置分布的足够均匀。算法
来设想一种极端状况,假设某个黑客故意构造一组特定的数据,这些数据的hash值正好同样。当插入hash表中时,它们的位置也同样。
那么,这些数据会所有被组织到该位置的链表中,hash表退化为链表,这时的查询的时间复杂度为O(N),也是hash表查询时间复杂度的最坏状况。数组
不过HashMap在链表过长时会将其重构为红黑树,这样,其最坏的时间复杂度就会下降为O(logN),这样使得hash表的适应场景更广。函数
扩容分两个步骤:性能
如下是第一个步骤的代码:优化
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) { // 已经初始化过的状况 // 对边界状况的处理:若是hash桶数组的大小已经达到了最大值MAXINUM_CAPACITY 这里是2的30次方 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 扩容两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold // 这种状况对照构造函数看 newCap = oldThr; else { // zero initial threshold signifies using defaults // 这种状况对照构造函数看 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); } // 到此,newCap是新的hash桶数组大小,newThr是新的扩容阈值 threshold = newThr; // 分配一个新的hash桶数组,而后把旧的数据迁移过来 /* ... */ }
逻辑是这样的,首先有三种状况,代码写的看起来很复杂:this
hash桶数组已经初始化过。code
虽然逻辑很明确,可是代码写的看起来却很复杂。
其缘由是HashMap内部记录的字段能表达的状态太多,每种状况都须要考虑周全。内存
第一阶段执行完毕后,HashMap内部的部分状态字段被更新。
最重要的是,newCap这个变量记录了扩容以后的大小。ci
final Node<K,V>[] resize() { /* ... */ // 分配一个新的hash桶数组,而后把旧的数据迁移过来 @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 { // preserve order // 链表的状况 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) { // 若是注意到hash桶数组扩容是从2^N 到 2^(N +1) 这一事实,从二进制的角度分析取余运算,就不难发现优化思路。 // 总之,这个迭代的代码是把这条链表拆分红两条,然而不一样的处理逻辑。 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; }
从思路上,总结以下:
遍历旧的hash桶数组,在其中保存有节点时,分不一样状况处理:
先来看下红黑树的split函数:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { /* ... */ 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); } } /* ... */ }
红黑树的各类操做代码我是无意看,各类旋转太复杂了。这里面主要有一个关键点,在于rehash的时候,会将红黑树节点也rehash。
一样,和链表的rehash同样,也是将红黑树拆分红两条子树。至于为何是拆分为两条后面会说。
可是,若是拆分出来的子树过小了,就会从新将其重构回链表。
顺便说一句,因为删除操做的逻辑没有什么新东西以前就没有分析。我也没有在其中找到删除节点时,若是红黑树过小会将其重构回链表的操做。
对于链表的rehash操做,乍一看,这个逻辑还有些看不懂,从代码上来看是这样的逻辑,对于hash桶数组中第j个位置上的一个链表,进行遍历,根据条件分红两条:
(e.hash & oldCap) == 0
知足上述条件的串成一条链表loHead
,不知足上述条件的串成一条链表hiHead
。以后:
if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
实际上,因为HashMap的hash桶数组的大小必定为2的幂这一性质,取余操做可以被优化。前面也说过这一点,这里以大小为8,也即0001000为例子:
严谨的数学表达我实在懒得写了,总之经过分析不可贵到这个结论:
有了以上结论,对照上面的代码,也就不难理解这段rehash代码的思路了:
(e.hash & oldCap) == 0
这句话是判断hash值的对应位是否为0,并分红两条不一样的链表。
最后,我比较疑惑的一点是,花了这么大力气去优化,为何能获得性能或内存上的提高?
咱们分析下优化先后的时间复杂度:
看起来两种方案都须要遍历全部的链表节点,难道仅仅是减少一点时间复杂度的常数吗?
以前说过当链表长度过大时会将其重构为红黑树,下面来看具体的代码。
// 8. 把链表转换成二叉树 final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 若是hash桶数组的大小过小还得扩容。 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // 所须要的hash参数是为了定位是hash桶数组中的那个链表,可为啥不直接传index... else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; // 遍历单链表,而后把给它们一个个的分配TreeNode节点 // 看下面这代码,这个TreeNode,记得拥有next和prev字段,看下面的代码是把它们串成双链表 do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) // 调用TreeNod.treeify()函数将这个已经组成双链表的TreeNode节点重构成红黑树 hd.treeify(tab); } }
以前提到过TreeNode拥有next和prev字段,所以它不只可以用来组织红黑树,还可以组织双向链表。这里看到了,这里首先将单链表的元素复制到TreeNode节点构成的双向链表中,而后经过TreeNode的treeify方法将其组织成红黑树。至于这个方法。。。各类旋转,红黑树的操做算法自己是很复杂的,就略过不看了。