HashMap resize 方法执行逻辑及部分问题的分析
本文主要通过一下几个问题进行探讨:
- 如何进行扩容的,扩容大小为什么总是
2的幂次大小 ? - 为什么链表转红黑树的阈值是
8? - 什么情况下会触发扩容 ?
HashMap为什么要设计为单项链表,而不设计为双向链表?
1. 如何进行扩容的 ?
HashMap 无论是扩容还是初始化,在 Java8 版本中,都是用的同一个方法 resize(),那么接下来进入代码:
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; // 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);
}
threshold = newThr;
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) {
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;
}
老规矩,拆分细一点开始分析
1.1 根据表容量来计算是否需要扩容
// 因为 resize 方法主要是用来扩容的,所以这里需要先记录旧的 table
Node<K,V>[] oldTab = table;
// 旧 table 的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 第一次初始化的时候,该变量为0,在后续的代码中会重新计算后赋值,成员变量中赋值的规则是 n = n * 2
// 例如初始化时为12,第二次24,第三次48 ...
int oldThr = threshold;
int newCap, newThr = 0;
// 这里如果旧table的容量大于0,那么说明了此表目前可能需要扩容,进入if条件后,重新计算table的扩容阈值
if (oldCap > 0) {
// 旧table长度都大于了 1<<<30 位
if (oldCap >= MAXIMUM_CAPACITY) {
// 说明下一次扩容最大能到 int 最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 先计算出新的表容量,第一次初始化的时候 oldCap=16,左移以为就是32
// 并且旧table长度大于 16,则说明需要执行扩容操作,直接计算出下次扩容的大小,也就是 12 << 1 = 24
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
// 如果旧扩容阈值大于0,则表示下次扩容的就是本次计算的扩容大小阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 这里就是最开始啥也没定义的时候的默认值,table 的默认容量和下次扩容的阈值计算
else {
// 16
newCap = DEFAULT_INITIAL_CAPACITY;
// 12 = 0.75 * 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
}
1.2 第一次创建 HashMap 实例,计算出下一次扩容阈值
// 第一次进入的时候,1.1小结的逻辑是只会执行初始化操作,不会执行 if 判断内的逻辑,所以这里一定会进来,所以这里的 newThr 一定等于0,且本if判断只会进来一次
if (newThr == 0) {
// newCap: 第一次的时候0,接下来每一次都会是2的幂次
// loadFactor: 该值在构造器中被初始化,默认是 0.75,
// ft : 第一次等于 12,以后每一次进来都会在原来基础上左移1位,成2倍增大
float ft = (float)newCap * loadFactor;
// 重新计算出下一次扩容阈值,12
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
1.3 在 1.1 和 1.2 中计算出来下一次扩容的 table 容量,根据容量来生成下一次扩容的 table
此标题可能描述的不太完美,换句话说就是先计算出了扩容的 table 的容量,那么初始化 table (table是数组,HashMap 的根数组),通常情况下 new Integer[size] 必须要知道 size 的大小才能 new 出数组,在此种情况下不可能直接 new 一个无边数组的。
// 针对成员变量执行一次赋值,newThr在 1.2 小结中被计算出来的,那么这个值就将成为下次扩容的一个阈值,初始化时为 12
threshold = newThr;
// 开始创建扩容的数组,此时结合到 1.1 处的代码,如果是初始化table,那么一定会走最后的 else 判断,为 newCap 进行赋值,默认为 16
// 所以默认情况下 HashMap 数组的大小为 1<<4=16
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将table进行赋值
table = newTab;
1.4 创建好扩容的数组,将扩容前的数据转移到扩容后的数组上
// oldTab != null
// oldTab 是在 1.1 小结中被赋值,那么他主要存储的是扩容前的table
// 情况1:如果是第一次进入 HashMap 的 put 方法,那么说明 oldTab 根本不存在,所以这个条件一定不成立
// 情况2:如果是真正扩容的时候进入的,那么 oldTab 一定不为 null,条件成立,只有当 oldTab 不为 null 的时候才需要进行数据的迁移,而 if 条件内就是对数据的迁移
if (oldTab != null) {
..................
}
1.5 数据从旧 table 中转移到新 table 中
// table 是数组,需要去遍历,那么遍历的目标就是 oldTab
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 这里很明显,如果数组上某个节点 == null,我们就不需要去处理这个节点,如下图所示:
if ((e = oldTab[j]) != null) {
.... 老规矩,拆分 ....
}
}
在这里我们不妨大胆的猜想一下,从一个
table 转移到另一个 table 会经历些什么逻辑 ?
- 每次遍历,得到一个
Node对象 - 重新计算
hash值 (还记得第一篇文章中怎么计算hash的吗,知道这里为啥要重新计算hash吗?) - 根据计算出来的
hash重新调用putVal方法放入到扩容后的table中
1.5.1 将数组上某个节点移动到新数组上
// 移除掉旧table上的j位置的值
oldTab[j] = null;
// 如果 j 位置的下一个节点为 null,这就说明了 j 位置上不存在hash冲突,也就是没有链表
if (e.next == null)
// 既然没有链表,那这里就直接把当前的节点放到新数组上,重新计算下标
newTab[e.hash & (newCap - 1)] = e;
// 如果当前的节点是一个树节点,那么调用红黑树的插入方法
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
1.5.2 j 下标位置存在 hash 冲突,有下属链表
如图所示,假设
j = 4,那么这个位置上存在了 hash 冲突,形成了链表的情况,就需要再次对整个链表进行遍历后重新赋值到新 table 中
// 结合图进行查看会变得更简单
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// e 就表示图中 k:1 的节点
// e.next = k:2 的节点
next = e.next;
// k:1.hash 和旧 table 容量做&运算,如果等于0,则说明他的下标不会发生变化
// 既然下标没发生变化,那我就把第一个没有发生变化的节点作为当前 table[j] 的内容 查看标记相同处 (√)
if ((e.hash & oldCap) == 0) {
// 如果
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 如果计算的结果不在原来的位置,那么肯定就在 j + 原始容量的位置,例如 j=4; cap = 16; 所以在新数组中就一定在 20的位置
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// (√)
// 循环完毕后检查 loTail 如果有值,直接将当前的 head 放入到新数组原始位置中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 如果 hiTail 中有值,直接将对应的内容放入到新位置中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
这里我觉得设计的特别的巧妙,想要阅读这里的代码,还必须要结合图才能彻底的看明白其中的奥秘,首先容我先介绍以下这里的原理;
在一个链表中,所有的
node 节点都在同一个桶(数组上某一个节点被称为一个桶)中,所以他们的下标是相同的,每次计算后,如果满足(node.hash & oldCap) == 0,那么说明这个节点还是在原来的位置,直接将相同的 node 全部加入到 loHead 链表中,否则直接放入到 hiHead 中,最后统一将两个组装好的链表放入到新的数组中。
2. 扩容大小为什么总是2的幂次大小 ?
在方法
putVal 中,每次计算数组下标的算法是 (n - 1) & hash, n (table.length),假设 n = 16, n - 1 = 15,转换为二进制则用 1111 表示,最后和 hash 进行与运算得到数组下标。
实际上,HashMap 每次扩容多少倍都是可以的,但是回过头看看计算数组下标落点的时候,用的是与运算,而不是对数组长度做取模运算(逻辑运算和取模运算实际上性能差别还是很大的),作者为了提升性能选择了逻辑运算,而能够满足逻辑与运算的数组长度,恰好是 n^2-1。
3. 为什么链表转红黑树的阈值是 8 ?
在源码中,有这么一段注释如下:
/**
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
*/
以上内容翻译过来大致内容如下:
常规情况下,树节点的大小是普通节点的两倍,想要使用红黑树那就要保证容器中有足够的节点,当树的节点达到某个足够小的阈值时,会变成普通的链表容器,如果每次经过hash计算后的结果在数组中分布良好的情况下,那么很少有可能会使用到红黑树,在理想情况下,每次计算的下标位置遵循
“泊松分布”。根据以下给出的计算结果,当某一个链表达到深度为 8 的时候,那么也就是有百万分之六的概率,这个概率已经非常小了,所以这里采用8作为切换为红黑树的阈值。
4. 什么情况下会触发扩容 ?
在
putVal 方法中有一层判断,如果当前的节点深度为8的时候,会调用转换的方法,但是具体需不需要转换,还需要进入 treeifyBin() 中查看
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 在这里,如果 tab.length 小于 64,会触发一次扩容,如果不满足此情况才会触发链表转换为树的操作
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 在这里真正触发了树节点的转换
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
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)
hd.treeify(tab);
}
}
5. HashMap 为什么要设计为单向链表,而不设计为双向链表?
HashMap 组成由 数组 + 链表 + 红黑树,我们可以把数组作为入口,每次再 get 值得时候,入口先从数组寻找下标所在节点,然后依次遍历得到结果,且在遍历链表的是都都是向下遍历,所以完全没必要采用双向链表。
6. 总结
在 Java 8 HashMap 中,组成结构为 数组 + 链表 + 红黑树,且在同一个桶中不存在链表和红黑树同时存在的可能。
链表转换为红黑树的前提条件 : (1) 链表深度要大于或等于8,(2) 数组的长度必须为大于或等于64个。
红黑树转链表前提条件:树节点的深度小于6的时候会触发转换,转换方法 java.util.HashMap.TreeNode#untreeify
每次扩容大小是根据前一次 table 的容量的两倍 newCap = oldCap << 1
HashMap 采用单向链表。
对 HashMap get 方法的解析:blog.csdn.net/qq_38800175… 参考:www.zhihu.com/question/42…