JDK1.8 Collection知識點與代碼分析--HashMap

HashMap

在瞭解HashMap之前, 首先談一談經常和它一起出現的HashTable.Hashtable 是早期 Java 類庫提供的一個哈希表實現,本身是同步的,不支持 null 鍵和值,由於同步導致的性能開銷,所以已經很少被推薦使用. TreeMap是基於紅黑樹實現的Map類, 其put,get操作的時間複雜度在O(logN), 但是它的鍵值存儲是有序的, 順序與compareToComparator有關.

下面是Map家族的結構概覽
在這裏插入圖片描述
在以上的類中, 如果需要常數時間複雜度的增刪改查, 並且對順序沒有要求, 最常用的就是HashMap類. 哈希是通過哈希函數, 將任意長度的輸入變成固定長度的輸出, HashMap正是利用這一性質, 將鍵值對的特徵(hash)轉化爲固定長度的整型輸出, 將輸出作爲數組下標, 將鍵值對存儲到數組中, 由於數組的隨機訪問的性能, 能夠保證其常數時間的讀寫.

用到hash的地方, 必須要考慮的問題就是哈希衝突的解決, 哈希衝突的解決思路有以下幾種:

  • 開放定址法: 當發生衝突時, 採用線性探測或二次探測等方法, 在初始位置的周圍尋找一個空的位置放置. 封閉哈希, 元素數量不能超過桶數量, 高負載下性能下降嚴重
  • 再哈希法: 當第一個hash函數產生的值發生碰撞, 用第二個hash函數再產生一個值
  • 鏈地址法: 也就是拉鍊法, 每個地址相當於一個桶, 當發生衝突時, 還是將鍵值對放到桶中, 同一個桶的鍵值對構成鏈表. 適用頻繁插入刪除的情況
  • 溢出區法: 專門建立一個溢出區, 將發生碰撞的元素用鏈表存儲起來. 和拉鍊法的思想相似, 但是溢出集中時, 性能不如拉鍊法.

HashMap採用的方法是拉鍊法, 當同時當元素數/桶數達到一定比例(負載因子)時, 進行擴容. 接下來分析源碼. 以下分析基於JDK1.8

HashMap#常量

首先是HashMap中有幾個重要的常量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 HashMap的默認初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap的最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;  // 推薦的負載因子, 通常情況下, 不用修改
static final int TREEIFY_THRESHOLD = 8; // 單個桶中元素數量, 達到樹化門限則會從鏈表調整爲紅黑樹, 防止哈希碰撞拒絕服務攻擊
static final int UNTREEIFY_THRESHOLD = 6; // 單個桶中數量小於門限, 則會退化成鏈表
static final int MIN_TREEIFY_CAPACITY = 64; // 最小的樹化容量, 當Map的容量小於該值時, 如果一個桶的元素過多, 會首先採用擴容方法嘗試緩解. 超過該值時, 如果達到TREEIFY-THRESH則會樹化

HashMap#構造器

從構造器開始看, HashMap提供了以下幾種構造器

public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)

後面的幾種構造函數, 無非就是將一些參數設爲了默認值, 我們直接看第一個構造器

    public HashMap(int initialCapacity, float loadFactor) {
        // 判斷initialCapacity和loadFactor的合法性, 大於0, 不爲NaN等
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

這個構造器總共就做了兩個參數的賦值, 沒有其他實際的創建結構, 所以這裏其實採用了懶惰加載的方法, 在真正調用的時候,纔去初始化內部的結構.

HashMap#tableSize()

然後這裏的tableSizeFor()方法, 其目的是將輸入的容量, 轉換爲大於等於輸入容量的2的冪.爲什麼HashMap的容量總要保持2的冪, 我們之後再討論, 但是這裏好像還是不對.

返回值爲什麼是賦值給的threshold, 這個參數不是擴容的門限值麼?
其實這個參數的解釋是: 如果table數組還沒有被創建, 這個參數等於數組的初始大小, 如果爲0則採用默認初始大小.

接着我們看一看tableSizeFor是怎麼轉成2的冪的

// 1.8的實現
static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

// 早期版本的實現
int capacity = 1;
while (capacity < initialCapacity)
    capacity <<= 1;  

從早期版本實現, 我們很容易看懂, 就是找一個最小的2的冪, 大於等於initalCapacity嘛, 但是新版的好像就有點懵了, 這是在幹什麼, 我們不妨舉個例子.

  1. 假設我們的輸入是65, 二進制0100 0001, 減1得到0100 0000
  2. 注意觀察最高位的1, 當第一次右移, 然後做或運算, (0100 0000 |0010 0000) = 0110 0000
  3. 這樣最高位的1就變成了兩個1, 接着做右移 2位, 然後或運算, (0110 0000 | 0001 1000) = (0111 1000)
  4. 現在從最高位往右就有至少4個1了, 再用它們往右移4位覆蓋低位, 最終得到的從最高位開始往右都是1的結果, 0111 1111
  5. 最後加1, 得到了1000 0000, 128

這樣相比原來循環1位一位往上移, 又快了一些, 如果還沒有看懂, 可以看下這個參考資料, 裏面帶了一些示意圖.

HashMap#put

接下來看下具體的初始化工作. 初始化發生在第一次put, 這個函數只有一行, 但是有個細節, 這裏用hash函數處理了key, 然後再進行實際的putVal操作.

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

HashMap#hash

我一開始以爲, hash函數只是返回了key.hashCode()的值, 然後發現並不是.

// 1.8
static final int hash(Object key) {
       int h;
       return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
   }
// 以往版本
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

在 JDK1.8 的實現中,將 hashCode 的高 16 位與 hashCode 進行異或運算,主要是爲了在 table 的 length 較小的時候,讓高位也參與運算,並且不會有太大的開銷。爲什麼比以往版本的hash有所簡化, 我所看到的資料主張是因爲加入了樹化後, 碰撞情況的查找成本小了, 所以hash的計算可以簡化.

HashMap#putVal

這個函數是整個類中, 邏輯最密集的之一, 所以相當精彩, 函數中要做的包括懶惰加載-初始化, 檢查是否key已經存在, 檢查是否要樹化等.

/**
     * Implements Map.put and related methods
     *
     * @param hash 經過hash函數得到的hash值
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent 爲true時, 如果已經存在就不修改
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0) // 如果首次調用, 進行初始化
            n = (tab = resize()).length; // resize函數進行初始化
        if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash相當於hash對n取模, 詳細看後面的解釋. 
        // 如果找到的桶爲空, 則key肯定不存在
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // p是桶的頭節點 e是p的後一個節點, 兩個指針一前一後遍歷鏈表
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) // 如果頭結點就是要找的key的node
                e = p;
            else if (p instanceof TreeNode) // 如果頭節點是個樹節點, 則調用樹插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 如果有key, e=node 否則e = null
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) { // 如果整個鏈沒有找到, e= null
                        p.next = newNode(hash, key, value, null); // 創建一個新節點
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash); // 如果達到樹化條件, 則樹化或擴容
                        break;
                    }
                    if (e.hash == hash && // 找到了key
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e; // 和e = p.next 構成鏈表往下走
                }
            }
            if (e != null) { // existing mapping for key
            // 找到key所在節點, 修改value並返回舊值   
            }
        }
        ++modCount;
        if (++size > threshold) // 超過門限, 擴容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

這裏留個懸念, 爲什麼在創建新節點的時候, 調用的是newNode()方法而不是直接new Node()產生一個對象? 具體原因請看JDK1.8 Collection知識點與代碼分析–LinkedHashMap(LinkedHashMap需要將新節點連接到鏈表上)

HashMap#resize

resize函數是另一個重要的方法, 其功能包括初始化和擴容

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) {
               // 不進行擴容
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else 
        //  初始化時, thresh的值不爲0, 則按照thresh初始化, 否則按照默認的初始化容量初始化
        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; // 創建新的table
        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)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 讓樹自己拆分並放置到新數組中
                    else { // preserve order // 尾插法保持順序
                    // 如果(e.hash & oldCap) == 0 則留在原桶中, 否則進入新桶
                    }
                }
            }
        }
        return newTab;
    }

HashMap#Treeify

樹化是JDK1.8中HashMap實現的一大亮點, 代碼量也很多, 如果一起分析的話, 篇幅過長了, 但是這個放一個關於樹化部分的詳細分析的文章, 供大家參考. 本文中, 僅對幾個和樹化相關的非常有意思的核心方法進行分析.

樹化的整體流程如下: treeifyBin方法把一個Bin鏈表上的節點全部包裝成TreeNode, 然後由treeify對鏈表進行建樹, log(N)的時間複雜度下將bin中的元素插入到新建的樹中, 每插入一個元素需要通過balanceInsertion對樹進行再平衡.

我們直接來看treeify的方法源碼:

final void treeify(Node<K,V>[] tab) {
      TreeNode<K,V> root = null;
        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;
            }
            else {
                // 當不是根節點時,
                K k = x.key;
                int h = x.hash;
                Class<?> kc = null;
                for (TreeNode<K,V> p = root;;) {
                    int dir, ph; // dir表示方向 -1 左, 1 右
                    K pk = p.key;
                    if ((ph = p.hash) > h)
                        dir = -1;
                    else if (ph < h)
                        dir = 1;
                    // hash 相等的情況
                    else if ((kc == null &&
                              (kc = comparableClassFor(k)) == null) || // 如果class實現了Comparable 返回class 否則返回null
                             (dir = compareComparables(kc, k, pk)) == 0) // 如果pk的getClass是kc, 返回k.compareTo(pk), 否則返回0
                        // 如果兩者不可比較
                        // 如果k, pk的class名不同, 比較兩個string
                        // 如果class相同或者兩個之間有至少一個null, 比較Object.hashCode返回的地址
                        dir = tieBreakOrder(k, pk);

                    TreeNode<K,V> xp = p; // 記住當前p, 嘗試左右兒子
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;
                        else
                            xp.right = x;

                        // 通過log(n)的複雜度找到插入位置, 插入後, 重新做balance
                        root = balanceInsertion(root, x);
                        break;
                    }
                }
            }
        }
        moveRootToFront(tab, root);
    }

這個方法中有一步調用了tieBreakOrder方法, 這個方法的具體功能我在註釋中進行了解釋, 但是這個方法非常有意思, 一個二叉搜索樹需要是嚴格有序的, 在map中又允許null作爲key(只有一個) 因此這個方法的作用就是在兩個key不可比的時候, 通過穩定的比較方法計算兩者誰更大, 保證之後查找這個key的時候, 可以通過該tieBreakOrder方法, 再次找到保存在樹中的key.

在上述方法的最後, 調用了balanceInsertion方法進行再平衡, 該方法也就是最關鍵的判斷是否左右旋, flipColor邏輯的代碼, 我這裏直接貼上我自己的對balanceInsertion方法的註釋貼上來, 作爲資料的補充

static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                              TreeNode<K,V> x) {
      x.red = true; // 2-3樹嘗試同一層插入
      for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // xp x-parent, xpp x-parent-parent, xppl xpp-left, xppr xpp-right
          // 找到root節點了
          if ((xp = x.parent) == null) {
              x.red = false;
              return x;
          }
          // 如果父節點就是root節點,
          // 如果父節點非紅, 不需要提高層數, 注意這裏沒有要求只有左兒子能是紅邊
          else if (!xp.red || (xpp = xp.parent) == null)
              return root;
          // 1. x的父節點是紅邊, 遇到了兩個紅邊相連的狀態
          // 2. x的祖父xpp不爲空

          // 父節點是祖先節點的左兒子
          if (xp == (xppl = xpp.left)) {
              // 祖先節點的左兒子是紅邊
              // 如果祖先節點的右兒子也是紅邊, flip
              // 這是由於將xpp變成紅色以後可能與xpp的父節點發生兩個相連紅色節點的衝突,這就又構成了第二種旋轉變換,所以必須從底向上的進行變換,直到根。
              // 所以令x = xpp,然後進行下下一層循環,接着往上走。
              if ((xppr = xpp.right) != null && xppr.red) {
                  xppr.red = false;
                  xp.red = false;
                  xpp.red = true;
                  x = xpp; // 轉到這個flip後持有紅邊的祖先節點
              }
              else {
                  // 進入else說明 祖先節點xpp的右兒子不是紅邊
                  if (x == xp.right) {
                      // x是右兒子,
                  /*
                    xpp
                    /
                  xp紅
                     \
                     x紅
                     需要先左旋, 再右旋
                   */
                      root = rotateLeft(root, x = xp);
                      xpp = (xp = x.parent) == null ? null : xp.parent;
                  }
                  if (xp != null) {
                      /*
                            xpp
                            /
                          xp紅
                           /
                         x紅
                     需要xp右旋, flip
                   */
                      xp.red = false;
                      if (xpp != null) {
                          xpp.red = true;
                          root = rotateRight(root, xpp);
                      }
                  }
              }
          }
          // 父節點是祖先節點的右兒子, 且父節點爲紅節點
          else {
              // flip
              if (xppl != null && xppl.red) {
                  xppl.red = false;
                  xp.red = false;
                  xpp.red = true;
                  x = xpp;
              }
              else {
                  if (x == xp.left) {
                      /*
                        xpp
                          \
                          xp紅
                          /
                         x紅
                         需要先x右旋, 再xp左旋
                       */
                      root = rotateRight(root, x = xp);
                      xpp = (xp = x.parent) == null ? null : xp.parent;
                  }
                  if (xp != null) {
                      /*
                        xpp
                          \
                          xp紅
                             \
                             x紅
                         需要xp左旋, 再flip
                       */
                      xp.red = false;
                      if (xpp != null) {
                          xpp.red = true;
                          root = rotateLeft(root, xpp);
                      }
                  }
              }
          }
      }
  }
  
 static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                       TreeNode<K,V> p) {
     /*
           pp          pp
           /           /
           p          r
            \        /
             r      p
            /        \
           rl         rl
           
           或
           
           pp          pp
            \           \
             p          r
              \        /
              r       p
              /        \
             rl         rl
            
     */
     TreeNode<K,V> r, pp, rl;
     if (p != null && (r = p.right) != null) {
         if ((rl = p.right = r.left) != null)
             rl.parent = p;
         if ((pp = r.parent = p.parent) == null) // 說明p就是root
             (root = r).red = false; 
         else if (pp.left == p)
             pp.left = r;
         else
             pp.right = r;
         r.left = p;
         p.parent = r;
     }
     return root;
 }

HashMap常考知識點總結

  • 爲什麼要保證容量是2的冪?
    對於任意給定的對象,只要它的 hashCode() 返回值相同,那麼計算得到的 hash 值總是相同的。我們首先想到的就是把 hash 值對 table 長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。
    但是模運算消耗還是比較大的,我們知道計算機比較快的運算爲位運算,因此 JDK 團隊對取模運算進行了優化,使用上面代碼2的位與運算來代替模運算。這個方法非常巧妙,它通過 “(table.length -1) & h” 來得到該對象的索引位置,這個優化是基於以下公式:xmod2n=x&(2n1)x mod 2^n = x \& (2^n - 1)。我們知道 HashMap 底層數組的長度總是 2 的 n 次方,並且取模運算爲 “h mod table.length”,對應上面的公式,可以得到該運算等同於“h & (table.length - 1)”。這是 HashMap 在速度上的優化,因爲 & 比 % 具有更高的效率。

  • 爲什麼要鏈表要從頭插法變成尾插法?
    頭插法還是尾插法指的都是resize函數, 在將節點移動到新的桶中時, 節點如何插入的方法, 頭插法的考量是後插入的數據可能是熱點數據, 頭插更容易訪問, 但是存在的問題是頭插法在每次resize的時候, 節點之間的前後關係都是倒置, 所以這種優化並不成立. 然而, 在下面的競態條件下,頭插法可能引起鏈表成環, 而尾插法不會, 因此當被錯誤用在併發情形下, 尾插法只有可能丟失一部分數據, 而頭插法會導致死循環, 徹底不可用.

  • 如果把HashMap用在併發情形下, 會導致的問題?
    出現很典型的競態條件, 在resize中將鏈表變成環導致resize無限循環, cpu佔用, HashMap無法正常繼續.具體情形這篇博客講的很清楚, 還帶有配圖.

其他參考資料
Java集合:HashMap詳解(JDK 1.8)

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