HashMap
下面来看另一个熟人--HashMap。不同于上面两个List的实现类,HashMap是一个采用哈希表实现的键值对集合,继承自AbstractMap,实现了Map接口并使用拉链法解决Hash冲突,其内部储存的元素并不是在连续内存地址的,并且是无序的。此处我们只关心其扩容操作的逻辑和实现,先说一下,由于要重新创建数组,rehash,重新分配元素位置等,HashMap扩容的开销要比List大很多。下面介绍几个和扩容相关的成员变量://哈希表中的数组,JDK 1.8之前存放各个链表的表头。1.8中由于引入了红黑树,则也有可能存的是树的根
transient Node[] table; //默认初始容量:16,必须是2的整数次方。这样规定是因为在通过key来确定元素在table中的index时
//所用的算法为:index = (n - 1) & hash,其中n即为table容量。保证n是2的整数次方就能保证n-1的低位均为1,
//这样便能保留hash(key)得到的hash值的所有低位,从而保证得到的index在n范围内分布均匀,因为hash算法的结果就是均匀的
static final int DEFAULT_INITIAL_CAPACITY = 1 <
//默认加载因子为: 0.75,这是在时间、空间两方面均衡考虑下的结果。
//这个值过大会导致发生冲突的几率增加,容易形成长链表,降低查找效率;太小则会导致频繁的扩容,降低整体性能。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//阈值,下次需要扩容时的值,等于 容量*加载因子
int threshold;
//最大容量: 2^30次方
static final int MAXIMUM_CAPACITY = 1 <
//树化阈值。JDK 1.8后HashMap对冲突处理做了优化,引入了红黑树。
//当桶中元素个数大于TREEIFY_THRESHOLD时,就需要用红黑树代替链表,以提高操作效率。此值必须大于2,并建议大于8
static final int TREEIFY_THRESHOLD = 8;
//非树化阈值。在进行扩容操作时,桶中的元素可能会减少,这很好理解,因为在JDK1.7中,
//每一个元素的位置需要通过key.hash和新的数组长度取模来重新计算,而1.8中则会直接将其分为两部分。
//并且在1.8中,对于已经是树形的桶,会做一个split操作(具体实现下面会说),在此过程中,
//若剩下的树元素个数少于UNTREEIFY_THRESHOLD,则需要将其非树化,重新变回链表结构。
//此值应小于TREEIFY_THRESHOLD,且规定最大值为6
static final int UNTREEIFY_THRESHOLD = 6;
好了,相关变量介绍完了,接下来开始分析HashMap的扩容函数resize,一个长得很讨厌的方法:final Node[] resize() {
Node[] oldTab = table; //记录当前数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录当前扩容阈值
int oldThr = threshold; int newCap, newThr = 0; //下面一长串if-else是为了确定newCap和newThr,即新的容量和扩容阈值
if (oldCap > 0) { //oldCap不为0,已被初始化过
if (oldCap >= MAXIMUM_CAPACITY) { //当前已经是最大容量,不允许再扩容,返回当前哈希表
threshold = Integer.MAX_VALUE; return oldTab;
} else if ((newCap = oldCap <
oldCap >= DEFAULT_INITIAL_CAPACITY) //先将oldCap翻倍,如果得到的值小于最大容量,并且oldCap不小于默认初始值,则将扩容阈值也翻倍,结束
newThr = oldThr <
} else if (oldThr > 0)
// initial capacity was placed in threshold
// 若构造函数中有传入initialCapacity,则会暂存在oldThr=threshold变量中。
// 然后在第一次put操作导致的resize方法中被赋给newCap,这样做的目的应该是避免污染oldCap从而影响上面那个if的判断
// 从这里也可以看出HashMap对于所需内存的申请是被延迟到第一次put操作时进行的,而非在构造函数中。
newCap = oldThr; else {
// zero initial threshold signifies using defaults
// Map没有被初始化,用默认值初始化
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
} if (newThr == 0) { //若经过计算后,新阈值为0,则赋值为新容量和扩容因子的乘积(需考虑边界条件)
float ft = (float)newCap * loadFactor;
newThr = (newCap
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/******-----分割线,至此新的容量和扩容因子已确定------*************/
@SuppressWarnings({"rawtypes","unchecked"}) //创建一个大小为newCap的Node数组,并赋值给table变量
Node[] newTab = (Node[])new Node[newCap];
table = newTab; if (oldTab != null) { //遍历扩容前的数组
for (int j = 0; j
Node e; if ((e = oldTab[j]) != null) { //将原数组中第j个元素赋给e,并将原数组第j位置置空
oldTab[j] = null; if (e.next == null) //该元素没有后续节点,即该位置未发生过hash冲突。则直接将该元素的hash值与新数组的长度取模得到新位置并放入
newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) //JDK1.8中,如果该元素是一个树节点,说明该位置存放的是一颗红黑树,则需要对该树进行分解操作
//具体实现后面会讨论,这里split的结果就是分为两棵树(这里必要时要进行非树化操作)并分别放在新数组的高段和低段
((TreeNode)e).split(this, newTab, j, oldCap); else { // preserve order
//剩下这种情况就是该位置存放的是一个链表,需要说明的是在JDK1.7和1.8中这里有着不同的实现,下面分别讨论
/******--- JDK 1.7版本 starts ---*******/
//遍历链表
while(null != e) { //将原链表中e的下一个元素暂存到next变量中
HashMapEntry next = e.next; //算出在新数组的index=i,indexFor其实就是e.hash & (newCapacity - 1)
int i = indexFor(e.hash, newCapacity); //改变e.next的指向,将新数组该位置上原先的内容(一个链表,元素或是null)挂在e的身后,使e成为这个链表的表头
e.next = newTable[i]; //将这个e位表头的新链表放回到index为i的位置中
newTable[i] = e; //将之前暂存的原链表中的下一个元素赋给e,继续遍历原链表
e = next;
} /******--- JDK 1.7版本 ends ---*******/
/******--- JDK 1.8版本 starts ---*******/
//在1.8的实现中,新数组被分成了高低两个段,而原链表也会被分成两个子链表,分别放入新数组的高段和低段中
//loHead和loTail用于生成将被放入新数组低段的子链表
Node loHead = null, loTail = null; //hiHead和hiTail则用于生成将被放入新数组高段的子链表
Node hiHead = null, hiTail = null; //跟1.7中一样,next用于暂存原链表中e的下一个元素
Node next; //开始遍历原链表
do {
next = e.next; //用if中的方法确定e是该去新数组的高段还是低段
if ((e.hash & oldCap) == 0) { //将e加到将被放入低段的子链表的尾部
if (loTail == null)
loHead = e; else
loTail.next = e;
loTail = e;
} else { //将e加到将被放入高段的子链表的尾部
if (hiTail == null)
hiHead = e; else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //将loHead指向的子链表放入新数组中index=j的位置
loTail.next = null;
newTab[j] = loHead;
} if (hiTail != null) { 将hiHead指向的子链表放入新数组中index=j+oldCap的位置
hiTail.next = null;
newTab[j + oldCap] = hiHead;
} /******--- JDK 1.8版本 ends ---*******/
}
}
}
} return newTab;
}
可以看到JDK1.8对resize方法进行了彻底的改造,引入红黑树结合之前的链表势必会提高在发生hash冲突时的操作效率(红黑树能保证在最坏情况下插入,删除,查找的时间复杂度都为O(logN))。
此外最大的改动便是在扩容的时候对链表或树的处理,在1.7时代,链表中的每一个元素都会被重新计算在新数组中的index,具体方法仍旧是e.hash对新数组长度做取模操作;而在1.8时代,这个链表或树会被分为两部分,我们暂且称其为A和B,若元素的hash值按位与扩容前数组的长度得到的结果为0(其实就是判断hash的某一位是1还是0,由于hash值均匀分布的特性,这个分裂基本可以认为是均匀的),则将其接入A,反之接入B。最后保持A的位置不变,即在新数组中仍位于原先的index=j处,而B则去到j+oldCap处。
其实对于这个改动带来的好处我理解的不是特别透彻,因为整个过程并没有减少计算的次数。目前看到的好处是可以避免扩容重定向过程中发生哈希冲突(因为是扩容一倍,所以一个萝卜一个坑,不会有冲突),并且不会将链表中的元素倒置(考虑极端情况,就一条链表,1.7的方法每次都会将元素插到表头)。这里还是得求教大家,欢迎讨论~
回到resize方法,上面还留了一个尾巴,就是当桶中是树形结构时的split方法,下面就来看源码:/**
* Splits nodes in a tree bin into lower and upper tree bins,
* or untreeifies if now too small. Called only from resize;
* see above discussion about split bits and indices.
*
* @param map the map
* @param tab the table for recording bin heads
* @param index the index of the table being split
* @param bit the bit of hash to split on
*/
final void split(HashMap map, Node[] tab, int index, int bit) {
TreeNode节点继承自LinkedHashMapEntry,在链表节点的基础上扩充了树节点的功能,譬如left,right,parent
TreeNode b = this; // Relink into lo and hi lists, preserving order
// 将树分为两部分这里的做法和链表结构时是相似的
TreeNode loHead = null, loTail = null;
TreeNode hiHead = null, hiTail = null; int lc = 0, hc = 0; //遍历整棵树,这里说明一下,由于这棵树是由链表treeify生成的,其next指针依旧存在并指向之前链表中的后继节点,
//因此遍历时依然可以按照遍历链表的方式来进行
for (TreeNode e = b, next; e != null; e = next) { //暂存next
next = (TreeNode)e.next; //将e从链表中切断
e.next = null; //与对链表的处理相同,若e.hash按位与bit=oldCap结果为0,则接到低段组的尾部
if ((e.hash & bit) == 0) { 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 { //否则的话将低段子树按照原本在旧数组中的index放入新数组中
tab[index] = loHead; //对低段子树进行树化调整。这里有一个优化,如果发现高段子树为空,则说明之前树中的所有元素都被放到了低段子树中,
//也即这已经是一棵完整的,调整好了的红黑树,不需要再进行树化调整
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
} if (hiHead != null) { //与低段子树同样的逻辑,放入新数组的位置为旧数组的index+oldCap
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map); else {
tab[index + bit] = hiHead; //这里同样的,如果低段子树为空,说明高段这棵树已经是一棵完整的红黑树,无需调整
if (loHead != null)
hiHead.treeify(tab);
}
}
}
注:由于篇幅所限,红黑树的treeify树化操作和untreeify非树化操作将在另一篇关于红黑树的文章中单独进行说明,在此大家只需理解treeify做的事情是将一个链表中的TreeNode节点,按照二叉查找树的结构连接其left,right和parent指针,并根据红黑树的规则进行调整,同时也可以对一个非红黑树的树结构进行调整;而untreeify反之,是将一个红黑树的TreeNode节点还原为HashMap.Node节点,并将其首尾相连还原为一个链表结构。
至此,HashMap的扩容逻辑和实现就分析完成了,可以看到1.7中的逻辑和做法还比较简单粗暴,而到了1.8中由于红黑树的引入,整体变得精巧了许多,整体HashMap的操作性能也有了大的提升。但即便如此,HashMap的扩容依旧是一个很贵的操作,这就要求我们在初始化HashMap的时候根据自己的业务场景设置尽可能合适的初始容量,以降低扩容发生的几率。例如我需要一个容纳96个元素的map,那么只要我把capacity初始值设置为128,那么就不会经历16到32到64再到128的三次扩容,这样来说是节省内存和运算成本的。当然如果需要容纳97个元素的话,因为超过了capacity值的3/4,所以就需要设置为256了,否则也会经历一次扩容。