JDK容器學習之HashMap (二) : 讀寫邏輯詳解

Map讀寫實現邏輯說明

前一篇博文 JDK容器學習之HashMap (一) : 底層存儲結構分析 分析了HashMap的底層存儲數據結構java

經過put(k,v)方法的分析,說明了爲何Map底層用數組進行存儲,爲何Node內部有一個next節點,這篇則將集中在讀寫方法的具體實現上node

本片博文將關注的重點:數組

  • 經過key獲取value的實現邏輯
  • 新增一個kv對的實現邏輯
  • table 數組如何自動擴容
  • 如何刪除一個kv對(刪除kv對以後,數組長度是否會縮水 ?)

1. 根據key索引

get(key) 做爲map最經常使用的方法之一,根據key獲取映射表中的value,一般時間複雜度爲o(1)數據結構

在分析以前,有必要再把HashMap的數據結構撈出來看一下ide

結構描述

根據上面的結構,若是讓咱們本身來實現這個功能,對應的邏輯應該以下:學習

  • 計算key的hash值
  • 根據hash肯定在table 數組中的位置
  • 判斷數組的Node對象中key是否等同與傳入的key
  • 若不是,則一次掃描next節點的key,直到找到爲止

jdk實現以下測試

public V get(Object key) {
   Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
  Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  // 判斷條件 if 內部邏輯以下
  // table 數組已經初始化(即非null,長度大於0)
  // 數組中根據key查到的Node對象非空
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (first = tab[(n - 1) & hash]) != null) {
      if (first.hash == hash && // always check first node
          ((k = first.key) == key 
          || (key != null && key.equals(k))))
          return first;
      if ((e = first.next) != null) {
          if (first instanceof TreeNode)
              // 紅黑樹中查找
              return ((TreeNode<K,V>)first).getTreeNode(hash, key);
          do {// 遍歷鏈表
              if (e.hash == hash &&
                  ((k = e.key) == key 
                  || (key != null && key.equals(k))))
                  return e;
          } while ((e = e.next) != null);
      }
  }
  return null;
}

上面的邏輯算是比較清晰,再簡單的劃一下重點this

  1. 經過key定位table數組中索引的具體邏輯
  • hash(key) & (table.length - 1)
  • key的hash值與(數組長度-1)進行按位與,計算獲得下標
  1. 判斷Node是否爲所查找的目標邏輯
  • node.hash == hash(key) && (node.key == key || (key!=null && key.equals(node.key))
  • 首先是hash值必須相等
  • and == or equals
    • key爲同一個對象
    • or Node的key等價於傳入的key
  1. TreeNode 是個什麼鬼

上面的邏輯中,當出現hash碰撞時,會判斷數組中的Node對象是否爲 TreeNode,若是是則調用 TreeNode.getTreeNode(hash,key) 方法.net

那麼這個TreeNode有什麼特殊的地方呢?code

2. TreeNode 分析

TreeNode 依然是 HashMap 的內部類, 不一樣於Node的是,它繼承自LinkedHashMap.Entry,相比較與Node對象而言,多了兩個屬性 before, after

1. 數據結構

TreeNode對象中,包含的數據以下(將父類中的字段都集中在下面了)

// Node 中定義的屬性
final int hash;
final K key;
V value;
Node<K,V> next;
// ---------------

// LinkedHashMap.Entry 中的屬性
Entry<K,V> before, after;
// ---------------


// TreeNode 中定義的屬性
TreeNode<K,V> parent;  // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;    // needed to unlink next upon deletion
boolean red;

2. 內部方法

方法比較多,實現也很多,可是看看方法名以及註釋,很容易猜到這是個什麼東西了

紅黑樹

具體方法實現身略(對紅黑樹實現有興趣的,就能夠到這裏來膜拜教科書的實現方式)

3. TreeNode 方式的HashMap存儲結構

普通的Node就是一個單向鏈表,所以HashMap的結構就是上面哪一種

TreeNode是一顆紅黑樹的結構,因此對上面的圖走一下簡單的改造,將單向鏈表改爲紅黑樹便可

newTech

3. 添加kv對

博文 JDK容器學習之HashMap (一) : 底層存儲結構分析 對於添加kv對的邏輯進行了說明,所以這裏將主要集中在數組的擴容上

擴容的條件: 默認擴容加載因子爲(0.75),臨界點在當HashMap中元素的數量等於table數組長度*加載因子,長度擴爲原來的2倍

數組擴容方法, 實現比較複雜,先擼一把代碼,並加上必要註釋

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 { 
      // 首次初始化
      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;
  @SuppressWarnings({"rawtypes","unchecked"})
  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) {
                  // 若Node節點沒有出現hash碰撞,則直接塞入新的數組
                  newTab[e.hash & (newCap - 1)] = e;
              } else if (e instanceof TreeNode) {
                  // 對於出現hash碰撞,且紅黑樹結構時,須要從新分配
                  ((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) {
                      // 新的位置相比原來的新增了 oldCap
                          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;
}

上面的邏輯主要劃分爲兩塊

  • 新的數組長度肯定,並初始化新的數組
  • 將原來的數據遷移到新的數組中
    • 遍歷舊數組元素
    • 若Node沒有尾節點(Next爲null),則直接塞入新的數組
    • 判斷Node的數據結構,紅黑樹和鏈表邏輯有區分
    • 對於鏈表格式,新的座標要麼是原來的位置,要麼是原來的位置+原數組長度,鏈表順序不變

說明

這個擴容的邏輯仍是比較有意思的,最後面給一個測試case,來看一下擴容先後的數據位置

4. 刪除元素

刪除的邏輯和上面的大體相似,顯示肯定節點,而後從整個數據結構中移除引用

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 刪除的前置條件:
    // 1. 數組已經初始化
    // 2. key對應的Node節點存在
    if ((tab = table) != null && (n = tab.length) > 0 
      && (p = tab[index = (n - 1) & hash]) != null) { 
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash && 
          ((k = p.key) == key || (key != null && key.equals(k)))) {
          // 數組中的Node節點即爲目標
            node = p;
        } else if ((e = p.next) != null) {
          // hash碰撞,目標可能在鏈表or紅黑樹中
          // 便利鏈表or紅黑樹,肯定目標
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        if (node != null && 
            (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 找到目標節點,直接從數組or紅黑樹or鏈表中移除
            // 不改變Node節點的內容
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

測試

上面的幾個經常使用方法的邏輯大體相同,核心都是在如何找到目標Node節點,其中比較有意思的一點是數組的擴容,舊元素的遷移邏輯,下面寫個測試demo來演示一下

首先定義一個Deom對象,覆蓋hashCode方法,確保第一次從新分配數組時,正好須要遷移

public static class Demo {
  public int num;

  public Demo(int num) {
      this.num = num;
  }

  @Override
  public boolean equals(Object o) {
      if (this == o) return true;
      if (o == null || getClass() != o.getClass()) return false;

      Demo demo = (Demo) o;

      return num == demo.num;
  }

  @Override
  public int hashCode() {
      return num % 3 + 16;
  }
}


@Test
public void testMapResize() {
  Map<Demo, Integer> map = new HashMap<>();
  for(int i = 1; i < 12; i++) {
      map.put(new Demo(i), i);
  }

  // 下面這一行執行,並不會觸發resize方法
  map.put(new Demo(12), 12);
  // 執行下面這一行,會觸發HashMap的resize方法
  // 由於 hashCode值 & 16 == 1,因此新的位置會是原來的位置+16
  map.put(new Demo(13), 13);
}

實際演示示意圖

showdemo.gif

小結

1. 根據Key定位Node節點

  • key計算hash,hash值對數組長度取餘即爲數組中的下標
    • hash & (len - 1) === hash % len
  • 以數組中Node爲鏈表頭or紅黑樹根節點遍歷,確認目標節點
    • 判斷邏輯:
    • hash值相同
    • key1 == key2 or key1.quals(key2)

2. 擴容邏輯

  • 當添加元素後,數組的長度超過閥值,實現擴容
    • 初始容量爲16,閥值爲12
  • 計算新的數組長度,並初始化
    • 新的長度爲原來的長度 * 2
    • 新的閥值爲 新的長度 * loadFactor; loadFactory 通常爲 0.75
  • 將原來的數據遷移到新的數組
    • 原位置不變 (hash % 原長度 == 0)
    • 原位置 + 原數組長度 (hash % 原長度 == 1)

3. 其餘

  • jdk1.8 以後,當鏈表長度超過閥值(8)後,轉爲紅黑樹
  • 新增元素,在添加完畢以後,再判斷是否須要擴容
  • 刪除元素不會改變Node對象自己,只是將其從Map的數據結構中 出來
  • Map如何退化爲鏈表
    • 一個糟糕的hashCode方法便可模擬實現,如咱們上面的測試用例
    • 紅黑樹會使這種退化的效果不至於變得那麼糟糕

相關博文

關注更多

掃一掃二維碼,關注 小灰灰blog

https://static.oschina.net/uploads/img/201709/22221611_Fdo5.jpg

參考

相關文章
相關標籤/搜索