原始碼閱讀(19):Java中主要的Map結構——HashMap容器(下1)

說好不能打臉發表於2020-01-13

(接上文《原始碼閱讀(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)》)

相關文章