HashMap - 核心原理与知识点记录(中)

91 阅读9分钟

HashMap resize 方法执行逻辑及部分问题的分析

HashMap - 核心原理与知识点记录(上)

HashMap - 核心原理与知识点记录(下)

本文主要通过一下几个问题进行探讨:

  • 如何进行扩容的,扩容大小为什么总是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.11.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) {
    .... 老规矩,拆分 ....
    }
}

当J=1,且node==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…