源码阅读(20):Java中主要的Map结构——HashMap容器(下2)

Posted 说好不能打脸

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了源码阅读(20):Java中主要的Map结构——HashMap容器(下2)相关的知识,希望对你有一定的参考价值。

(接上文《源码阅读(19):Java中主要的Map结构——HashMap容器(下1)》)

3.4.6、HashMap扩容操作

3.4.6.1、HashMap扩容操作场景

在上文讲解HashMap容器中的添加操作时,我们就知道在如下几种情况下HashMap会进行扩容操作,扩容操作主要是对HashMap容器中的table数组进行容量扩充——使用一个更大的数组:

  • 当table数组为null或者长度为0的时候,需要进行扩容:

在负责添加新的K-V键值对的putVal()方法中这种条件对应的代码片对如下所示:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
  // ......
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // ......

发生这种操作情况的场景,一般就是HashMap容器刚使用类似“HashMap()”这样的构造函数完成初始化后,第一次进行K-V键值对添加时。

  • 当新的K-V键值对添加后,容器中K-V键值对数量将超过“门槛值”的时候,需要进行扩容:

在putVal()方法中这种条件对应的代码片对如下所示:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
  // ......
  if (++size > threshold)
    resize();
  // ......

threshold变量既是上文中介绍过的“门槛值”,根据以上的代码片段,当HashMap容器的大小已经超过这个“门槛值”时,就进行扩容操作。threshold变量值的来源上文也介绍过——通过tableSizeFor()方法计算得到。这个tableSizeFor()方法可以计算出大于当前方法入参,并和当前tableSizeFor()方法入参“最接近”的2的幂数。扩容“门槛值”是可以变化的,具体策略可参见以下介绍的详细扩容过程。

3.4.6.2、HashMap扩容操作过程

final Node<K,V>[] resize() 
  // 将扩容前的数组应用记为oldTab变量
  Node<K,V>[] oldTab = table;
  // 该变量记录扩容前的数组大小
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 该变量记录扩容操作前的“门槛值”
  int oldThr = threshold;
  // newCap代表扩容后新的数据容量值,请注意区别数组容量和HashMap容器的数据大小值
  // newThr代表扩容后新得到的“门槛值”
  int newCap, newThr = 0;
  
  // =========操作步骤一:根据当前HashMap容器的大小,确认新的数组容量值和新的“门槛值”
  // 如果扩容前的数组容量大于0,则执行一下操作
  if (oldCap > 0) 
    // 如果扩容前的数组容量(数组大小)大于HashMap容器设定的最大数组容量(1073741824 )
    // 则设定下一次扩容门槛值为32为整数最大值,并且不再进行真实的扩容操作。这也意味着HashMap容器中的数组不会再扩容了
    // 这种情况下,扩容操作将返回原来的数组大小
    if (oldCap >= MAXIMUM_CAPACITY) 
      threshold = Integer.MAX_VALUE;
      return oldTab;
    
    // 如果条件成立,说明扩容后新的数组容量将小于HashMap容器设定的最大数组容量
    // 并且扩容前的数组容量大于DEFAULT_INITIAL_CAPACITY(16)
    // 这是扩容操作中最常见的情况,这种情况设定新的数组容量为原容量的2倍、设定新的门槛值为原门槛值的2倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
      newThr = oldThr << 1; // double threshold
  
  // 如果扩容前的门槛值大于0(这个判定条件的优先级低于以oldCap参数为依据的判定条件)
  // 这种情况出现在使用诸如“HashMap(int initialCapacity, float loadFactor)”这样的构造函数完成实例化后的第一次扩容时,
  // 因为这时原有的数组大小容量(oldCap)的值为0,而threshold的值通过tableSizeFor(int)方法的计算后将大于0
  // 这时设定新的数据容量为原始的门槛值
  // initial capacity was placed in threshold
  else if (oldThr > 0) 
    newCap = oldThr;
  // 如果扩容前数组容量等于0;并且扩容前的门槛值等于0
  // 这种情况出现在使用HashMap()构造函数进行实例化,并且进行第一次扩容的情况
  // zero initial threshold signifies using defaults
  else  
    // 新的数组的容量为16
    newCap = DEFAULT_INITIAL_CAPACITY;
    // 新的扩容的门槛值(下一次扩容的门槛值) = 默认负载因子(0.75) * 默认的初始化数组容量(16)
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
   
  // 如果新的扩容门槛值等于0,这种情况承接以上代码中“oldThr > 0”这样的处理条件。
  // 那么新的门槛值 = 新的数组容量 * 当前的负载因子
  if (newThr == 0)  
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
  
  threshold = newThr;
  // 通过以上计算后,得到的扩容后新的数组容量的值一定为2的N次幂数,例如32、64、128......
  
  // =========操作步骤二:在扩容后,对原数组中各K-V键值对对象重新进行平衡
  // 根据新的数组容量值创建一个新的数组
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  // HashMap容器扩容操作的核心点,并不在于重新计算扩容后数组的新的容量值和新的扩容门槛值,
  // 而在于扩容成新的数组后,原有数组上的值如何重新“平衡”,以下就是平衡过程:
  // 首要条件是只有在扩容前的数组不为空的的情况下,才进行原数组中各K-V键值对对象的再平衡操作
  if (oldTab != null) 
    // 依次遍历扩容前数组上的每一个索引位,注意这些索引位可能一个K-V键值对都没有
    // 也可能有多个K-V键值对对象信息,并且以单向链表方式存在
    // 也可能有多个K-V键值对对象信息,并且以红黑树方式存在
    for (int j = 0; j < oldCap; ++j) 
      Node<K,V> e;
      // 如果当前遍历的索引位没有任何K-V键值对信息,则不需要进行重新平衡处理
      if ((e = oldTab[j]) != null) 
        // 设置为null,以便于CG
        oldTab[j] = null;
        // 如果条件成立,则说明基于当前索引位的桶结构上,只有一个K-V键值对结点
        // 这是通过“e.hash & (newCap - 1)”重新计算这个K-V键值对象在新的数组中的存储位置
        // 注意由于采用newCap - 1的计算方式,所以这个值一定不会newCap - 1,这个计算公式的特性在后文中还要进行说明
        if (e.next == null)
          newTab[e.hash & (newCap - 1)] = e;
        // 如果条件成立,说明基于当前索引位的桶结构上是一个红黑树,那么通过TreeNode.split()方法进行K-V键值对对象的平衡
        // 实际操作是对当前索引位上的红黑树进行拆分
        else if (e instanceof TreeNode)
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        // 其它情况,说明基于当前索引位的桶结构上是一个单向链表,且链表上的对象数据一定大于1;
        // 那么就要开始进行链表上每一个节点位置的调整了?为什么要做这样的调整呢?后文会有详细的说明
        else 
          // 以下一大段代码起到的作用,是将原数组当前索引位上(当前桶),以链表结构描述的结构,随机拆分成两个新的链表
          // 其中一个新的链表,作为新的数组相同索引位上的链表结构
          // 另一个新的链表,作为新的数组相同索引位 + oldCap的索引位上的链表结果。以便保证节点的重新分配。
          Node<K,V> loHead = null, loTail = null;
          Node<K,V> hiHead = null, hiTail = null;
          Node<K,V> next;
          do 
            // 将当前e节点的next引用(就是当前节点的下一个节点引用),赋值给next变量
            // 请注意:第一次do循环的情况下,e.next一定有新的对象引用,因为e.next == null的情况已将在上文中判定过
            next = e.next;
            // 这个条件有一定的几率随机成立,根据条件成立情况,原来在j索引位置上的单向链表会构造出两个新的链表
            // 这两个新的链表将分别以loHead、hiHead作为头结点的标识
            // 并且将分别以loTail、hiTail作为尾结点的标识
            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);
          
          // 如果以loTail为尾结点标识的新的单向链表确实存在(至少一个节点),那么新的链表将替换存储到j索引位
          if (loTail != null) 
            loTail.next = null;
            newTab[j] = loHead;
          
          // 如果以hiTail为尾结点标识的新的单向链表确实存在(至少一个节点),那么新的链表将存储到j + oldCap索引位
          if (hiTail != null) 
            hiTail.next = null;
            newTab[j + oldCap] = hiHead;
          
        
      
    
  
  return newTab;

从以上代码的详细注释说明中,我们可以看出整个HashMap容器的扩容过程实际上可以分为两个大的步骤。第一个步骤是根据HashMap容器当前的情况,确认HashMap容器新的容量大小和新的“下一次扩容的门槛值”。经过不同场景条件的计算后,HashMap容器新的容量一定是大于等于16,且为2的幂次方的(16、32、64、128…)数值;新的“下一次扩容的门槛值”则根据场景的不同,而有所不同。

  • 为什么要进行链表节点或者红黑树节点的调整

要回答这个问题,首先就应该看一下扩容后如果不重新移动原来集合中的各个结点(包括可能的红黑树结点或者链表结点)会有什么样的操作效果,如下图所示是一张只进行了数组2倍容量扩容,但是没有进行结点位置调整的容器内部结构:


基于计算公式发生的变化——具体来说就是 “当前容量 - 1” 的结果不一样了,所以在进行键值对定位时一部分存储在原索引位上的结点不再能够匹配正确的索引位置。这些不再能够匹配正确索引位置的键值对结点就需要在扩容时进行移动——移动到正确的索引位上。这样才能保证在扩容后,操作者通过get(K key)方法获取键值对信息时,HashMap容器能够正确定位到该键值对对象新的索引位置。

  • 链表中各结点的调整效果

如果扩容前某个索引位置上的K-V键值对对象是以单向链表结构组织的,那么就需要通过以下方式将当前链表中的各个结点调整为两个新的单向链表,如下图所示:

原链表中(e.hash & oldCap) == 0的结点将构成新的单向链表,这个链表将依据原索引位存储回新的HashMap容器table数组中;另外原链表中 (e.hash & oldCap) != 0 的结点将构成另一个新的单向链表,并依据“原索引位 + 原数组长度”的计算结果作为新的索引位存储回新的HashMap容器table数组中。为什么

这是因为HashMap容器中table数组的扩容都是以2的幂次方为单位,也就是说原容量左移1位。在这种情况下K-V键值对在扩容后,是否能在原有的索引位被查找到,完全取决于新增的一位在进行与运算时是否为0。而oldCap代表的数值刚好就是扩容后新增的一位。所以“(e.hash & oldCap) == 0”成立的节点就可以继续在原索引位上存储,反之则需要进行移动。

  • 红黑树中各节点的调整效果(拆分过程)

如果扩容前某个索引位置上的K-V键值对是以红黑树的结构组织的,那么就需要按照以上原理,将这颗红黑树拆成两个新的红黑树或链表——这完全取决于新树是否达到了节点总数大于6的阈值。一棵红黑树或者链表留在原索引位置,另一颗红黑树或者链表放到“原索引位 + 原数组容量”计算结果对应的新索引位置上。首先我们来看相关源代码:

/**
 * 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 从代码上下文可以,这里传入的bit就是扩容前HashMap中tables数组的原始大小
 */
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引用
    // 因为要根据e.hash & bit的计算情况,构造两个新的链表
    next = (TreeNode<K,V>)e.next;
    e.next = null;
    // 如果条件成立,说明扩容后这个K-V键值对结点的hash值计算结果还是会落在原来的索引位置
    // 这种情况下,就无需移动当前K-V键值对结点
    if ((e.hash & bit) == 0) 
      // 通过以下的代码片段,就可以将这些无需移动的K-V键值对结点组成一个新的链表
      // 但是个人认为这里的代码有定缺陷,因为并没有完整个处理双向链表的所有索引引用
      if ((e.prev = loTail) == null)
        loHead = e;
      else
        loTail.next = e;
      loTail = e;
      ++lc;
    
    // 如果(e.hash & bit) == 0的条件不成立
    // 说明扩容后这个K-V键值对结点的hash值计算结果不会落在原来的索引位置
    // 而一定会落在 当前索引位 + bit的新的索引位上,原因已经在上文解释过,这里不再赘述
    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);
    
  

============
(接下文)

以上是关于源码阅读(20):Java中主要的Map结构——HashMap容器(下2)的主要内容,如果未能解决你的问题,请参考以下文章

源码阅读(15):Java中主要的Map结构——概述

源码阅读(16):Java中主要的Map结构——HashMap容器(上)

源码阅读(22):Java中其它主要的Map结构——TreeMap容器

源码阅读(24):Java中其它主要的Map结构——LinkedHashMap容器(下)

源码阅读(18):Java中主要的Map结构——HashMap容器(中)

源码阅读(19):Java中主要的Map结构——HashMap容器(下1)