源碼閱讀(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;
    }
    // 將新的鏈表(的頭結點)返回,以便調用者獲取到這個新的單向鏈表
    return hd;
  }
  // ......
}
// ......

以上untreeify(HashMap<K,V>)方法的工作過程可以用下圖描述:
在這裏插入圖片描述

============
(接下文《源碼閱讀(20):Java中主要的Map結構——HashMap容器(下2)》)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章