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

Posted 说好不能打脸

tags:

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

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

3.4.4、HashMap添加K-V键值对(红黑树方式)

上文我们介绍了在HashMap中table数组的某个索引位上,基于单向链表添加新的K-V键值对对象(HashMap.Node<K, V>类的实例),但是我们同时知道在某些的场景下,HashMap中table数据的某个索引位上,数据是按照红黑树结构进行组织的,所以有时也需要基于红黑树进行K-V键值对对象的添加(HashMap.TreeNode<K, V>类的实例)。再介绍这个操作前我们首先需要明确一下HashMap容器中红黑树结构的每一个节点TreeNode是如何构成的,请看以下代码片段:

/**
 * HashMap.TreeNode类的部分定义.
 */
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> 
  // red-black tree links
  TreeNode<K,V> parent;  
  TreeNode<K,V> left;
  TreeNode<K,V> right;
  // needed to unlink next upon deletion
  TreeNode<K,V> prev;
  boolean red;
  // ......


// ......

/**
 * LinkedHashMap.Entry类的部分定义.
 */
static class Entry<K,V> extends HashMap.Node<K,V> 
  Entry<K,V> before, after;
  // ......


// ......
/**
 * HashMap.Node类的部分定义.
 */
static class Node<K,V> implements Map.Entry<K,V> 
  final int hash;
  final K key;
  V value;
  Node<K,V> next;
  // ......

从以上代码中的继承关系中我们可以看出,HashMap容器中红黑树的每一个结点属性,并不只是包括父级结点引用(parent)、左儿子结点引用(left)、右儿子结点引用(right)和红黑标记(red);还包括了一些其它属性,例如在双向链表中才会采用的上一结点引用(prev),以及下一结点引用(next);当然还有描述当前结点hash值的属性(hash),以及描述当前结点的key信息的属性(key)、描述当前value信息的属性(value)。HashMap容器中以下代码片段专门负责基于红黑树结构进行K-V键值对对象的添加:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable 
  //......
  static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> 
    //......
    /**
     * 该方法在指定的红黑树结点下,添加新的结点。我们先来介绍一下方法入参
     * @param map 既是当前正在被操作的hashmap对象
     * @param tab 当前HashMap对象中的tab数组
     * @param h 当前新添加的K-V对象的hash值
     * @param k 当前新添加的K-V对象的key值
     * @param v 当前新添加的K-V对象的value值
     * @return 请注意,如果该方法返回的不是null,说明在添加操作之前已经在指定的红黑树结构中找到了与将要添加的K-V键值对的key匹配的已存在的K-V键值对信息,于是后者将会被返回,本次添加操作将被终止。
     * */
    final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) 
      Class<?> kc = null;
      boolean searched = false;
      // 这句代码要注意,parent变量是一个全局变量,指示当前操作结点的父结点。
      // 请注意,当前操作结点并不是当前新增的结点,而是那个被作为新增操作的基准结点
      // 如果按照调用溯源,这个当前操作的结点一般就是table数组指定索引位上的红黑树结点
      // root方法既可以寻找到当前红黑树的根结点
      TreeNode<K,V> root = (parent != null) ? root() : this;
      // 找到根结点后,从根结点开始进行遍历,寻找红黑树中是否存在指定的K-V键值对信息
      // “是否存在”的依据是,Key对象的hash值是否一致
      for (TreeNode<K,V> p = root;;) 
        int dir, ph; K pk;
        if ((ph = p.hash) > h)
          dir = -1;
        else if (ph < h)
          dir = 1;
        // 如果条件成立,说明当前红黑树中存在相同的K-V键值对信息,则将红黑树上的K-V键值对进行返回,方法结束
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
          return p;
        // comparableClassFor()方法将返回当前k对象的类实现的接口或者各父类实现的接口中,是否有java.lang.Comparable接口,如果没有则返回null
        // compareComparables()方法利用已实现的java.lang.Comparable接口,让当前操作结点的key对象,和传入的新增K-V键值对的key对象,进行比较,并返回比较结果,如果返回0,说明该方法返回0,说明当前红黑树结点匹配传入的新增K-V键值对的key值。
        else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) 
          // 这样的情况下,如果条件成立,也说明找到了匹配的K-V键值对结点
          if (!searched) 
             TreeNode<K,V> q, ch;
             searched = true;
             if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) ||
                 ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) 
               return q;
             
           
           dir = tieBreakOrder(k, pk);
        
	    
	    // 执行到这里,说明当前递归遍历的过程中,并没有找到和p结点“相同”的结点,所以做以下判定:
	    // 1、如果以上代码中判定新添加结点的hash值小于或等于p结点的hash值:如果当前p结点存在左儿子,那么向当前p结点的左儿子进行下次递归遍历;如果当前p结点不存在左儿子,则说明当前新增的结点,应该添加成当前p结点的左儿子。
	    // 2、如果以上代码中判定新添加结点的hash值大于p结点的hash值:如果当前p结点存在右儿子,那么向当前p结点的右儿子进行下次递归遍历;如果当前p结点不存在右儿子,则说明当前新增的结点,应该添加成当前p结点的右儿子。
        TreeNode<K,V> xp = p;
        // 注意以下代码中的xp就是代表当前结点p
        if ((p = (dir <= 0) ? p.left : p.right) == null) 
          // 如果代码走到这里,说明可以在当前p结点的左儿子或者右儿子添加新的结点
          Node<K,V> xpn = xp.next;
          // 创建一个新的结点x
          TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
          // 如果条件成立,则将当前新结点添加成当前结点的左儿子;
          // 否则,将当前新结点添加成当前结点的右儿子。
          if (dir <= 0)
            xp.left = x;
          else
            xp.right = x;
          // 将当前结点的“下一结点”引用,指向新添加的结点
          xp.next = x;
          // 将新添加结点的上一结点/父结点引用,指向当前结点
          x.parent = x.prev = xp;
          // 如果以下条件成立说明当前p结点的next引用在之前是指向了某个已有结点的(记为xpn)。
          // 那么需要将xpn结点的“上一个”结点引用指向新添加的结点
          if (xpn != null)
            ((TreeNode<K,V>)xpn).prev = x;
          // balanceInsertion方法的作用是在红黑树增加了新的结点后,重新完成红黑树的平衡
          // 而HashMap容器中的红黑树,内部存在一个隐含的双向链表,重新完成红黑树的平衡后,双向链表的头结点不一定是红黑树的根结点
          // moveRootToFront方法的作用就是让红黑树中根结点对象和隐含的双向链表的头结点保持统一
          moveRootToFront(tab, balanceInsertion(root, x));
          // 完成结点新增、红黑树重平衡、隐含双向链表头结点调整这一系列操作后
          // 返回null,代表结点新增的实际操作完成
          return null;
        
      
    
    //......
  
  //......

以上的代码可以归纳总结为以下步骤:

a. 首先试图在当前红黑树中找到当前将要添加的K-V键值是否已经存在于树中,判断依据总的来说就是看将要新增的K-V键值对的key信息的hash值时候和红黑树中的某一个结点的hash值一致。而从更细节的场景来说,又要看当前key信息的类是否规范化的重写了hash()方法和equals()方法,或者是否实现了java.lang.Comparable接口。

b. 如果a步骤中,在红黑树中找到了匹配的结点,则本次操作结束,将当前找到了红黑树的TreeNode类的对象返回即可,由外部调用者更改这个对象的value值信息——本次添加操作就变更成了对value的修改操作。

c. 如果a步骤中,没有在红黑树中找道匹配的结点,则将在红黑树中某个缺失左儿子或者右儿子的树结点出添加新的结点。

d. 以上c步骤成功结束后,红黑树的平衡性可能被破坏,于是需要通过红黑树的再平衡算法,重新恢复红黑树的平衡。这个具体的原理和工作过程已经在上文《源码阅读(17):红黑树在Java中的实现和应用》中进行了介绍,这里就不再赘述了。

e. 最为关键的一点是这里红黑树结点的添加过程和我们预想的情况有一些不一样,添加过程除了对红黑树相关的父结点引用、左右儿子结点引用进行操作外,还对和双向链表有关的next结点引用、prev结点引用进行了操作。这主要是便于红黑树到链表的转换过程(后文会详细介绍)。那么根据以上的代码描述,我们知道了HashMap容器中的红黑树和我们所知晓的传统红黑树结构是不同的,后者的真实结构可以用下图来表示:

上图中已经进行了说明:隐含的双向链表中各个结点的链接位置不是那么重要,但是该双向链表和头结点和红黑树的根结点必须随时保持一致。HashMap.TreeNode.moveRootToFront()方法就是用来保证以上特性随时成立。

3.4.5、HashMap红黑树、链表互转

当前HashMap容器中Table数组每个索引位上的K-V键值对对象存储的组织结构可能是单向链表也可能是红黑树,在特定的场景下单向链表结构和红黑树结构可以进行相互转换。转换原则可以简单概括为:单向链表中的结点在超过一定长度的情况下就转换为红黑树;红黑树结点数量足够小的情况就转换为单向链表

3.4.5.1、单向链表结构转红黑树结构

转换场景为:

  • 当单向链表添加新的结点后,链表中的结点总数大于某个值,且HashMap容器的tables数组长度大于64时

这里所说的添加操作包括了很多种场景,例如使用HashMap容器的put(K, V)方法添加新的K-V键值对并操作成功,再例如通过HashMap容器实现的BiFunction函数式接口进行两个容器合并时。

HashMap容器中putVal()方法的详细工作过程已经在上文中介绍过(《源码阅读(18):Java中主要的Map结构——HashMap容器(中)》),所以本文就不再赘述该方法了。以下代码片段是putVal()方法中和单向链表转换红黑树相关的判定条件,如下所示:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
  //......
  else 
    // 遍历当前单向链表,到链表的最后一个结点,并使用binCount计数器,记录当前当前单向链表的长度
    for (int binCount = 0; ; ++binCount) 
      if ((e = p.next) == null) 
        // 如果已经遍历到当前链表的最后一个结点位置,则在这个结点的末尾添加一个新的结点
        p.next = newNode(hash, key, value, null);
        // 如果新结点添加后,单向链表的长度大于等于TREEIFY_THRESHOLD(值为8)
        // 也就是说新结新结点添加前,单向链表的长度大于等于TREEIFY_THRESHOLD - 1
        // 这时就通过treeifyBin()方法将单向链表结构转为红黑树结构
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
          treeifyBin(tab, hash);
        break;
      
      // ......
    
  
  //......

那么我们再来看一下单向链表结构如何完成红黑树结构的转换,代码如下所示:

final void treeifyBin(Node<K,V>[] tab, int hash) 
  int n, index; Node<K,V> e;
  // 当转红黑树的条件成立时,也不一定真要转红黑树
  // 例如当HashMap容器中tables数组的大小小于MIN_TREEIFY_CAPACITY常量(该常量为64)时,
  // 则不进行红黑树转换而进行HashMap容器的扩容操作
  if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();
  // 通过以下判断条件取得和当前hash相匹配的索引位上第一个K-V键值对结点的对象引用e
  else if ((e = tab[index = (n - 1) & hash]) != null) 
    TreeNode<K,V> hd = null, tl = null;
    // 以下循环的作用是从头结点开始依次遍历当前单向链表中的所有结点,直到最后一个结点
    do 
      // 每次遍历时都为当前Node对象,创建一个新的、对应的TreeNode结点。
      // 注意,这时所有TreeNode结点还没有构成红黑树,而是首先构成了一个新的双向链表结构
      TreeNode<K,V> p = replacementTreeNode(e, null);
      // 如果条件成立,说明这个新创建的TreeNode结点是新的双向链表的头结点
      if (tl == null)
        hd = p;
      else 
        p.prev = tl;
        tl.next = p;
      
      // 通过以上代码构建了一颗双向链表
      tl = p;
     while ((e = e.next) != null);
    
    // 将双向链表的头结点赋值引用给当前索引位
    if ((tab[index] = hd) != null)
      // 然后开始基于这个新的双向链表进行红黑树转换———通过treeify方法
      hd.treeify(tab);
  


/**
 * Forms tree of the nodes linked from this node.
 * @return root of tree
 */
final void treeify(Node<K,V>[] tab) 
  TreeNode<K,V> root = null;
  // 通过调用该方法的上下文我们知道,this对象指向的是新的双向链表的头结点
  for (TreeNode<K,V> x = this, next; x != null; x = next) 
    next = (TreeNode<K,V>)x.next;
    x.left = x.right = null;
    // 如果条件成立,则构造红黑树的根结点,根结点默认为双向链表的头结点
    if (root == null) 
      x.parent = null;
      x.red = false;
      root = x;
    
    // 否则就基于红黑树构造要求,进行处理。 
    // 以下代码块和putTreeVal()方法类似,所以就不再进行赘述了
    // 简单来说就是遍历双向链表结构中的每一个结点,将它们依次添加到新的红黑树结构,并在每次添加完成后重新平衡红黑树
    else 
      K k = x.key;
      int h = x.hash;
      Class<?> kc = null;
      for (TreeNode<K,V> p = root;;) 
        int dir, ph;
        K pk = p.key;
        if ((ph = p.hash) > h)
          dir = -1;
        else if (ph < h)
          dir = 1;
        else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0)
          dir = tieBreakOrder(k, pk);
        
        TreeNode<K,V> xp = p;
        if ((p = (dir <= 0) ? p.left : p.right) == null) 
          x.parent = xp;
          if (dir <= 0)
            xp.left = x;
          else
            xp.right = x;
          // balanceInsertion方法在红黑树添加了新结点后,重新进行红黑树平衡
          root = balanceInsertion(root, x);
          break;
        
      
    
  

  // 在完成红黑树构造后,通过moveRootToFront方法保证红黑树的根结点和双向链表的头结点是同一个结点
  moveRootToFront(tab, root);

以上两段代码的工作过程,可用下图进行表示:

3.4.5.2、红黑树结构转单向链表结构

以下两种情况下,红黑树结构会转换为单向链表结构,这两种情况都可以概括为:在某个操作后,红黑树变得足够小时

  • 当HashMap中tables数组进行扩容时

这时为了保证依据K-V键值对对象的hash值,HashMap容器依然能正确定位到它存储的数组索引位,就需要依次对这些索引位上的红黑树结构进行拆分操作(详细描述可参考3.4.6小节的详细描述)——拆分结果将可能形成两颗红黑树,一颗红黑树将会被引用回原来的索引位;另一颗红黑树会被引用回“原索引位 + 原数组大小”结果的索引位上。

如果以上两颗红黑树的某一颗的结点总数小于等于“UNTREEIFY_THRESHOLD”常量值(该常量值在JDK8的版本中值为6),则这颗红黑树将转换为单向链表。请看如下代码片段(更为完整代码片段可参考3.4.6.1小节):

// ......
// 如果条件成立,说明拆分后存在一颗将引用回原索引位的红黑树
if (loHead != null) 
  // 如果条件成立,说明这个红黑树中的结点总数不大于6,这时就要转换成单向链表
  // lc变量是一个计数器,记录了红黑树拆分后其中一颗新树的结点总数
  if (lc <= UNTREEIFY_THRESHOLD)
    tab[index] = loHead.untreeify(map);
  else 
    // ......
  

// 如果条件成立,说明拆分后有另一个红黑树
if (hiHead != null)  
  // 如果条件成立,说明这个红黑树中的结点总数不大于6,这时就要转换成单向链表
  // hc变量是另一个计数器,记录了红黑树拆分后另一颗新树的结点总数
  if (hc <= UNTREEIFY_THRESHOLD) 
      tab[index + bit] = hiHead.untreeify(map);
  else  
    // ......
   
 
  • 当使用HashMap容器中诸如remove(K)这样的方法进行K-V键值对移除操作时

这时一旦tables数据的某个索引位上红黑树的结点被移除得足够多,足够满足根结点的左儿子结点引用为null,或者根结点的右儿子结点引用为null,甚至根结点本身都为null的情况,那么红黑树就会转换为单向链表,请看如下代码片段:

// ......
if (root.parent != null)
  root = root.root();
// 由于有以上判断条件的支持,所以当代码运行到这里的时候,root引用一定指向红黑树的根结点
if (root == null || root.right == null || (rl = root.left) == null || rl.left == null) 
  // too small
  // 通过untreeify方法,可将当前HashMap容器中当前索引位下的红黑树转换为单向链表
  tab[index] = first.untreeify(map);  
  return;

// ......

这里本文用图文的方式重现一下以上代码片段中红黑树“足够小”的情况,如下图所示的红黑树都满足“足够小”:

  • untreeify(HashMap<K,V>)方法的工作过程:

以上分析了红黑树转换成链表的两种场景,下面我们给出转换代码:

final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) 
  // ......
  if (root == null || root.right == null || (rl = root.left) == null || rl.left == null) 
    tab[index] = first.untreeify(map);  // too small
    return;
  
  // ......


// ......

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> 
  // ......
  /**
   * Returns a list of non-TreeNodes replacing those linked from
   * this node.
   */
  final Node<K,V> untreeify(HashMap<K,V> map) 
    // hd表示转换后新的单向链表的头结点对象引用
    Node<K,V> hd = null, tl = null;
    // this代表当前结点对象,从代码调用关系上可以看出this对象所代表的结点就是红黑树的第一个结点
    // 那么该循环就是从当前红黑树的第一个结点开始,按照结点next代表的引用依次进行遍历
    for (Node<K,V> q = this; q != null; q = q.next) 
      // replacementNode()方法将创建一个新的Node对象
      // 第一个参数是创建Node对象所参考的TreeNode对象,
      // 第二个参数是新创建的Node对象指向的下一个Node结点
      Node<K,V> p = map.replacementNode(q, null);
      // 如果条件成立,说明这是转换后生成的链表的第一个结点
      // 将hd引用指向新生成的p结点
      if (tl == null)
        hd = p;
      else
        tl.next = p;
      tl = p源码阅读(15):Java中主要的Map结构——概述

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

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

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

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

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