Java集合系列之四:HashMap底層原理

HashMap底層原理

HashMap是最經常使用的存儲鍵值對的集合,繼承了AbstractMap類,實現了Map等接口,內部原理是基於散列函數計算出元素存儲的位置,查詢的時候也是根據散列函數繼續計算出存儲的位置去獲取該位置上存儲的元素,非併發安全。node


  1. 底層數據結構數組

    值得注意的是1.8版本的HashMap對底層結構作了優化,因此這裏以1.7版本和1.8版本做爲對照版本。安全

    • 1.7版本數據結構

      在1.7版本中,底層數據結構是數組和鏈表,也就是一個數組用來存儲value,那爲啥還有鏈表呢?由於散列函數是頗有可能出現哈希碰撞的,也就是兩個不一樣的key計算得出同一個哈希值,結果就存到同一個數組索引上了,那不能覆蓋掉前面的值呀,因此數組中存的是鏈表,若是有衝突了,同一個索引上就使用鏈表來存儲多個值。併發

      hashmap1.png

      實現鏈表的內部類app

      static class Node<K,V> implements Map.Entry<K,V> {...}
    • 1.8版本函數

      由於鏈表的查詢時間是O(n),因此衝突很嚴重,一個索引上的鏈表很是長,效率就很低了,因此在1.8版本的時候作了優化,當一個鏈表的長度超過8的時候就轉換數據結構,再也不使用鏈表存儲,而是使用紅黑樹,紅黑樹是一個保證大體平衡的平衡樹,因此性能相較AVL樹這樣的高度平衡樹來將性能會更好。性能

      hashmap2.png

      實現紅黑樹的內部類優化

      static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {...}
  2. 構造方法this

    總共有四個構造方法,第一個默認無參的,第二個是能夠設置map大小,第三個是既能夠設置大小又能夠設置裝填因子,裝填因子和大小是計算map是否須要擴容的兩個必要因素,後面會介紹,第四個是接收另外一個map做爲參數,建立出來的新map裏會包含這個map中全部的值。

    HashMap(){};
     HashMap(int initialCapacity){};
     HashMap(int initialCapacity, float loadFactor){};
     HashMap(Map<? extends K, ? extends V> m){};
  3. put()方法

    • 1.7版本

      能夠看到1.7版本的put()方法相對來講比較簡單,畢竟固定就是數組加鏈表的結構,先計算下哈希值,再根據哈希值計算索引,再判斷索引上有沒有值,有的話就是鏈表尾部增長節點,注意的是keynull的話直接存到了數組索引爲0的位置上,由於會遍歷鏈表覆蓋相同key的緣故,因此HashMap中只能有一個keynull,還有就是addEntry()方法裏面調用會進行容量判斷是否調用擴容方法resize()

      public V put(K key, V value) {
         if (key == null)
             // 存放null值
             return putForNullKey(value);
         // 計算哈希值
         int hash = hash(key);
         // 根據哈希值計算索引
         int i = indexFor(hash, table.length);
         // 若是索引上已經有值就在鏈表上添加節點
         for (Entry<K,V> e = table[i]; e != null; e = e.next) {
             Object k;
             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                 V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
             }
         }
      
         modCount++;
         // 索引上沒值,直接添加value
         addEntry(hash, key, value, i);
         return null;
      }
      
      private V putForNullKey(V value) {
         for (Entry<K,V> e = table[0]; e != null; e = e.next) {
             if (e.key == null) {
                 V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
             }
         }
         modCount++;
         addEntry(0, null, value, 0);
         return null;
      }
    • 1.8版本

      1.8版本的put()方法就要複雜的多了,直接新寫了一個putVal()方法來調用,在1.7的步驟上還加多了一步,判斷是插入鏈表仍是插入紅黑樹,若是是插入鏈表則還要判斷是否須要轉換爲紅黑樹。

      public V put(K key, V value) {
         return putVal(hash(key), key, value, false, true);
       }
      
       // 計算哈希值
       static final int hash(Object key) {
           int h;
           return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
       }
      
       final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                    boolean evict) {
         Node<K,V>[] tab; Node<K,V> p; int n, i;
         // map還沒初始化或者數組長度爲0時調用擴容方法來初始化
         if ((tab = table) == null || (n = tab.length) == 0)
             n = (tab = resize()).length;
         // 計算哈希值,存儲數據,若是該索引上沒有數據,數據是節點形式插入
         if ((p = tab[i = (n - 1) & hash]) == null)
             tab[i] = newNode(hash, key, value, null);
         // 索引上已經有數據
         else {
             Node<K,V> e; K k;
             // 對比尋找索引上第一個元素hash值相等,key相等,新插入的值會覆蓋舊值
             if (p.hash == hash &&
                 ((k = p.key) == key || (key != null && key.equals(k))))
                 e = p;
             // 若是節點是紅黑樹節點,插入紅黑樹
             else if (p instanceof TreeNode)
                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
             // 插入鏈表
             else {
                 // 循環到尾部,插入
                 for (int binCount = 0; ; ++binCount) {
                     if ((e = p.next) == null) {
                         p.next = newNode(hash, key, value, null);
                         // 鏈表長度大於等於8了,轉換爲紅黑樹
                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                             treeifyBin(tab, hash);
                         break;
                     }
                     if (e.hash == hash &&
                         ((k = e.key) == key || (key != null && key.equals(k))))
                         break;
                     p = e;
                 }
             }
             // 若是插入的數據是相同key直接覆蓋
             if (e != null) { // existing mapping for key
                 V oldValue = e.value;
                 if (!onlyIfAbsent || oldValue == null)
                     e.value = value;
                 afterNodeAccess(e);
                 return oldValue;
             }
         }
         ++modCount;
      
         // 判斷是否擴容
         if (++size > threshold)
             resize();
         afterNodeInsertion(evict);
         return null;
       }
  4. get()方法

    • 1.7版本

      1.7版本的get()方法就很簡單了,keynull值直接取數組的第一個元素,不然就計算哈希值找到對應索引,遍歷鏈表查找。

      public V get(Object key) {
         if (key == null)
             return getForNullKey();
         Entry<K,V> entry = getEntry(key);
      
         return null == entry ? null : entry.getValue();
      }
      
      private V getForNullKey() {
         for (Entry<K,V> e = table[0]; e != null; e = e.next) {
             if (e.key == null)
                 return e.value;
         }
         return null;
      }
      
      final Entry<K,V> getEntry(Object key) {
         int hash = (key == null) ? 0 : hash(key);
         // 遍歷鏈表
         for (Entry<K,V> e = table[indexFor(hash, table.length)];
              e != null;
              e = e.next) {
             Object k;
             // 查找鏈表中哪個節點key和哈希值都對的上
             if (e.hash == hash &&
                 ((k = e.key) == key || (key != null && key.equals(k))))
                 return e;
         }
         return null;
      }
    • 1.8版本

      1.8版本的get()方法也要複雜一點,還須要判斷當前索引上是鏈表仍是紅黑樹,不一樣的結構須要調用不一樣的方法。

      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 ((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匹配就返回value
                 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;
      }
  5. 擴容方法

    HashMap中最核心的方法,當按照計算規則發現存儲的元素超過閥值的時候就須要調用該方法擴容,全部的元素都會從新計算哈希值來從新存儲到不一樣的位置,很是耗費時間。

    // 默認的初始容量是16
     static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
     // 默認的填充因子
     static final float DEFAULT_LOAD_FACTOR = 0.75f;
     // 最大容量
     static final int MAXIMUM_CAPACITY = 1 << 30;
     // 閥值,超過這個數就要擴容
     int threshold;
    
     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) {
             // 當前容量超過最大值就直接把容量設置爲Integer.MAX_VALUE
             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
         }
         // 設置了容量的構造方法,設置了閥值,元素個數爲0
         else if (oldThr > 0) // initial capacity was placed in threshold
             newCap = oldThr;
         // 無參構造方法默認初始化
         else {               // zero initial threshold signifies using defaults
             newCap = DEFAULT_INITIAL_CAPACITY;
             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
         }
    
         // 計算新的閥值
         if (newThr == 0) {
             // 當前新容量乘以裝填因子,好比100*0.75=75
             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)
                         newTab[e.hash & (newCap - 1)] = e;
                     else if (e instanceof TreeNode)
                         ((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) {
                                 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;
     }
  6. 初始化的容量

    前面講到能夠初始化的是有指定容量,可是要注意的是並非說指定多少就必定是多少,能夠看到構造方法中調用了一個tableSizeFor()方法,進行了一系列的位運算,20會計算成32,10000則會計算成16384,而後閥值則是在這個數字的基礎上乘以裝填因子,這纔是真正的容量和閥值。

    public HashMap(int initialCapacity, float loadFactor) {
         if (initialCapacity < 0)
             throw new IllegalArgumentException("Illegal initial capacity: " +
                                                initialCapacity);
         if (initialCapacity > MAXIMUM_CAPACITY)
             initialCapacity = MAXIMUM_CAPACITY;
         if (loadFactor <= 0 || Float.isNaN(loadFactor))
             throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
         this.loadFactor = loadFactor;
         this.threshold = tableSizeFor(initialCapacity);
     }
    
     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;
     }
  7. 併發問題

    雖說HashMap原本就不是線程安全的不該該使用,可是在1.8版本之前的HashMap在併發狀況下使用並不只僅是可能會形成數據不許確,還有可能形成內部鏈表死循環,大體緣由是在併發狀況下進行擴擴容,從新計算鏈表上的元素的哈希值,由於是循環操做,因此併發狀況下有機率造成循環鏈表,不過1.8版本已經修復了循環鏈表的問題,但仍是會有數據丟失問題,因此切記併發狀況下使用ConcurrentHashMap


總結

  • HashMap在1.8版本之前底層是數組加鏈表,從1.8開始是數組加鏈表加紅黑樹,鏈表長度超過8就轉化爲紅黑樹。
  • HashMap的構造方法雖然能夠指定容量,可是容量還會再通過以便運算,目的是爲了保證容量必定是2的冪次方,由於代碼中有不少地方是使用位運算符計算,2的冪次方計算效率會更高。
  • 由於是散列表的數據結構,因此不管是插入、修改和查詢時間複雜度都是O(1)。
相關文章
相關標籤/搜索