不再怕面試官問我JDK8 HashMap了

上一篇文章中提到了ThreadLocalMap是使用開放地址法來解決衝突問題的,而咱們今天的主角HashMap是採用了鏈表法來處理衝突的,什麼是鏈表法呢?java

數據結構

在散列表中,每一個 「 桶(bucket)」 或者 「 槽(slot)」 會對應一條鏈表,全部散列值相同的元素咱們都放到相同槽位對應的鏈表中。node

jdk8和jdk7不同,jdk7中沒有紅黑樹,數組中只掛載鏈表。而jdk8中在桶容量大於等於64且鏈表節點數大於等於8的時候轉換爲紅黑樹。當紅黑樹節點數量小於6時又會轉換爲鏈表。數組

插入

但插入的時候,咱們只須要經過散列函數計算出對應的槽位,將其插入到對應鏈表或者紅黑樹便可。若是此時元素數量超過了必定值則會進行擴容,同時進行rehash.安全

查找或者刪除

經過散列函數計算出對應的槽,而後遍歷鏈表或者刪除bash

鏈表爲何會轉爲紅黑樹?

上一篇文章有提到過經過裝載因子來斷定空閒槽位還有多少,若是超過裝載因子的值就會動態擴容,HashMap會擴容爲原來的兩倍大小(初始容量爲16,即槽(數組)的大小爲16)。可是不管負載因子和散列函數設得再合理,也避免不了鏈表過長的狀況,一旦鏈表過長查找和刪除元素就比較耗時,影響HashMap性能,因此JDK8中對其進行了優化,當鏈表長度大於等於8的時候將鏈表轉換爲紅黑樹,利用紅黑樹的特色(查找、插入、刪除的時間複雜度最壞爲O(logn)),能夠提升HashMap的性能。當節點個數少於6個的時候,又會將紅黑樹轉化爲鏈表。由於在數據量較小的狀況下,紅黑樹要維持平衡,比起鏈表來,性能上的優點並不明顯,並且編碼難度比鏈表要大上很多。數據結構

源碼分析

構造方法以及重要屬性

public HashMap(int initialCapacity, float loadFactor);

public HashMap(int initialCapacity);

public HashMap();

複製代碼

HashMap的構造方法中能夠分別指定初始化容量(bucket大小)以及負載因子,若是不指定默認值分別是16和0.75.它幾個重要屬性以下:多線程

// 初始化容量,必需要2的n次冪
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 負載因子默認值
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 須要從鏈表轉換爲紅黑樹時,鏈表節點的最小長度
static final int TREEIFY_THRESHOLD = 8;

// 轉換爲紅黑樹時數組的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;

// resize操做時,紅黑樹節點個數小於6則轉換爲鏈表。
static final int UNTREEIFY_THRESHOLD = 6;

// HashMap閾值,用於判斷是否須要擴容(threshold = 容量*loadFactor)
int threshold;

// 負載因子
final float loadFactor;

// 鏈表節點
static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  V value;
  Node<K,V> next;

}

// 保存數據的數組
transient Node<K,V>[] table;

// 紅黑樹節點
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
  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;
}
複製代碼

上面的table就是存儲數據的數組(能夠叫作桶或者槽),數組掛載的是鏈表或者紅黑樹。值得一提的是構造HashMap的時候並無初始化數組容量,而是在第一次put元素的時候才進行初始化的。併發

hash函數的設計

int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
int index = hash & (tab.length-1);
複製代碼

從上面能夠看出,key爲null是時候放到數組中的第一個位置的,咱們通常定位key應當存放在數組哪一個位置的時候通常是這樣作的 key.hashCode() % tab.length。可是當tab.length是2的n次冪的時候,就能夠轉換爲 A % B = A & (B-1);因此 index = hash & (tab.length-1)就能夠理解了。app

這裏是使用了除留餘數法的理念來設計的,能夠可能減小hash衝突 除留餘數法 : 用關鍵字K除以某個不大於hash表長度m的數p,將所得餘數做爲hash表地址 好比x/8=x>>3,即把x右移3位,獲得了x/8的商,被移掉的部分(後三位),則是x%8,也就是餘數。函數

而對於hash值的運算爲何是(h = key.hashCode()) ^ (h >>> 16)呢?也就是爲何要向右移16位呢?直接使用 key.hashCode() & (tab.length -1)很差嗎? 若是這樣作,因爲tab.length確定是遠遠小於hash值的,因此位運算的時候只有低位才參與運算,而高位毫無做爲,會帶來hash衝突的風險。

而hashcode自己是一個32位整形值,向右移位16位以後再進行異或運行計算出來的整形將具備高位和低位的性質,就能夠獲得一個很是隨機的hash值,在經過除留餘數法,獲得的index就更低機率的減小了衝突。

插入數據

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {

 Node<K,V>[] tab; Node<K,V> p; int n, i;

 // 1. 若是數組未初始化,則初始化數組
 if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

 // 2. 若是當前節點未被插入數據(未碰撞),則直接new一個節點進行插入
 if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
 else {
    Node<K,V> e; K k;

    // 3. 碰撞了,已存在相同的key,則進行覆蓋
   if (p.hash == hash &&
       ((k = p.key) == key || (key != null && key.equals(k))))
       e = p;
   else if (p instanceof TreeNode)
        // 4. 碰撞後發現爲樹結構,則掛載在樹上
       e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
   else {
       for (int binCount = 0; ; ++binCount) {
            // 5. 進行尾插入,若是鏈表節點數達到上線則轉換爲紅黑樹
           if ((e = p.next) == null) {
               p.next = newNode(hash, key, value, null);
               if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                   treeifyBin(tab, hash);
               break;
           }
           // 6. 鏈表中碰撞了
           if (e.hash == hash &&
               ((k = e.key) == key || (key != null && key.equals(k))))
               break;
           p = e;
       }
     }
     // 7. 用新value替換舊的value
     if (e != null) { // existing mapping for key
       V oldValue = e.value;
       if (!onlyIfAbsent || oldValue == null)
           e.value = value;
       afterNodeAccess(e);
       return oldValue;
     }
 }
 ++modCount;

 // 8. 操做閾值則進行擴容
 if (++size > threshold)
     resize();

 // 給LinkedHashMap實現
 afterNodeInsertion(evict);
 return null;
}
複製代碼

簡述下put的邏輯,它主要分爲如下幾個步驟:

  1. 首先判斷是否初始化,若是未初始化則初始化數組,初始容量爲16
  2. 經過hash&(n-1)獲取數組下標,若是該位置爲空,表示未碰撞,直接插入數據
  3. 發生碰撞且存在相同的key,則在後面處理中直接進行覆蓋
  4. 碰撞後發現爲樹結構,則直接掛載到紅黑樹上
  5. 碰撞後發現爲鏈表結構,則進行尾插入,當鏈表容量大於等於8的時候轉換爲樹節點
  6. 發如今鏈表中進行碰撞了,則在後面處理直接覆蓋
  7. 發現以前存在相同的key,只直接用新值替換舊值
  8. map的容量(存儲元素的數量)大於閾值則進行擴容,擴容爲以前容量的2倍

擴容

resize()方法中,若是發現當前數組未初始化,則會初始化數組。若是已經初始化,則會將數組容量擴容爲以前的兩倍,同時進行rehash(將舊數組的數據移動到新的數組).JDK8的rehash過程頗有趣,相比JDK7作了很多優化,咱們來看下這裏的rehash過程。

// 數組擴容爲以前2倍大小的代碼省略,這裏主要分析rehash過程。

if (oldTab != null) {
 // 遍歷舊數組
 for (int j = 0; j < oldCap; ++j) {
   Node<K,V> e;
   if ((e = oldTab[j]) != null) {
     oldTab[j] = null;

     // 1. 若是舊數組中不存在碰撞,則直接移動到新數組的位置
     if (e.next == null)
        newTab[e.hash & (newCap - 1)] = e;
     else if (e instanceof TreeNode)
        // 2. 若是存在碰撞,且節點類型是樹節點,則進行樹節點拆分(掛載到擴容後的數組中或者轉爲鏈表)
        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
     else { // preserve order

        // 3. 處理衝突是鏈表的狀況,會保留原有節點的順序

       Node<K,V> loHead = null, loTail = null;
       Node<K,V> hiHead = null, hiTail = null;
       Node<K,V> next;
       do {
         next = e.next;
         // 4. 判斷擴容後元素是否在原有的位置(這裏很是巧妙,下面會分析)
         if ((e.hash & oldCap) == 0) {
           if (loTail == null)
               loHead = e;
           else
               loTail.next = e;
           loTail = e;
         }

         // 5. 元素不是在原有位置
         else {
           if (hiTail == null)
               hiHead = e;
           else
               hiTail.next = e;
           hiTail = e;
         }
       } while ((e = next) != null);

       // 6. 將擴容後未改變index的元素複製到新數組
       if (loTail != null) {
         loTail.next = null;
         newTab[j] = loHead;
       }

       // 7. 將擴容後改變了index位置的元素複製到新數組
       if (hiTail != null) {
         hiTail.next = null;
         // 8. index改變後,新的下標是j+oldCap,這裏也很巧妙,下面會分析
         newTab[j + oldCap] = hiHead;
       }
     }
   }
 }
}
複製代碼

上面的代碼中展示了整個rehash的過程,先遍歷舊數組中的元素,接着作下面的事情

  1. 若是舊數組中不存在數據碰撞(未掛載鏈表或者紅黑樹),那麼直接將元素賦值到新數組中,其中index=e.hash & (newCap - 1)
  2. 若是存在碰撞,且節點類型是樹節點,則進行樹節點拆分(掛載到擴容後的數組中或者轉爲鏈表)
  3. 若是存在碰撞,且節點是鏈表,則處理鏈表的狀況,rehash過程會保留節點原始順序(JDK7中不會保留,這也是致使jdk7中多線程出現死循環的緣由)
  4. 判斷元素在擴容後是否還處於原有的位置,這裏經過(e.hash & oldCap) == 0判斷,oldCap表示擴容前數組的大小。
  5. 發現元素不是在原有位置,更新hiTail和hiHead的指向關係
  6. 將擴容後未改變index的元素複製到新數組
  7. 將擴容後改變了index位置的元素複製到新數組,新數組的下標是 j + oldCap

其中第4點和第5點中將鏈表的元素分爲兩部分(do..while部分),一部分是rehash後index未改變的元素,一部分是index被改變的元素。分別用兩個指針來指向頭尾節點。

好比當oldCap=8時,1-->9-->17都掛載在tab[1]上,而擴容後,1-->17掛載在tab[1]上,9掛載在tab[9]上。

那麼是如何肯定rehash後index是否被改變呢?改變以後的index又變成了多少呢?

這裏的設計非常巧妙,還記得HashMap中數組大小是2的n次冪嗎?當咱們計算索引位置的時候,使用的是 e.hash & (tab.length -1)。

這裏咱們討論數組大小從8擴容到16的過程。

tab.length -1 = 7   0 0 1 1 1
e.hashCode = x      0 x x x x
==============================
                    0 0 y y y  

複製代碼

能夠發如今擴容前index的位置由hashCode的低三位來決定。那麼擴容後呢?

tab.length -1 = 15   0 1 1 1 1
e.hashCode = x       x x x x x
==============================
                     0 z y y y

複製代碼

擴容後,index的位置由低四位來決定,而低三位和擴容前一致。也就是說擴容後index的位置是否改變是由高字節來決定的,也就是說咱們只須要將hashCode和高位進行運算便可獲得index是否改變。

而恰好擴容以後的高位和oldCap的高位同樣。如上面的15二進制是1111,而8的二進制是1000,他們的高位都是同樣的。因此咱們經過e.hash & oldCap運算的結果便可判斷index是否改變。

同理,若是擴容後index該變了。新的index和舊的index的值也是高位不一樣,其新值恰好是 oldIndex + oldCap的值。因此當index改變後,新的index是 j + oldCap。

至此,resize方法結束,元素被插入到了該有的位置。

get()

get()的方法就相對來講要簡單一些了,它最重要的就是找到key是存放在哪一個位置

final Node<K,V> getNode(int hash, Object key) {
  Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

  // 1. 首先(n-1) & hash肯定元素位置
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (first = tab[(n - 1) & hash]) != null) {

      // 2. 判斷第一個元素是不是咱們須要找的元素
      if (first.hash == hash &&
          ((k = first.key) == key || (key != null && key.equals(k))))
          return first;
      if ((e = first.next) != null) {
        // 3. 節點若是是樹節點,則在紅黑樹中尋找元素
        if (first instanceof TreeNode)
            return ((TreeNode<K,V>)first).getTreeNode(hash, key);
        4. 在鏈表中尋找對應的節點
        do {
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        } while ((e = e.next) != null);
      }
  }
  return null;
}

複製代碼

remove

remove方法尋找節點的過程和get()方法尋找節點的過程是同樣的,這裏咱們主要分析尋找到節點後是如何處理的

if (node != null && (!matchValue || (v = node.value) == value ||
    (value != null && value.equals(v)))) {
    // 1. 刪除樹節點,刪除時若是不平衡會從新移動節點位置
    if (node instanceof TreeNode)
        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
    // 刪除的節點是鏈表第一個節點,則直接將第二個節點賦值爲第一個節點
    else if (node == p)
        tab[index] = node.next;
    // 刪除的節點是鏈表的中間節點,這裏的p爲node的prev節點
    else
        p.next = node.next;
    ++modCount;
    --size;
    afterNodeRemoval(node);
    return node;
}

複製代碼

remove方法中,最爲複雜的部分應該是removeTreeNode部分,由於刪除紅黑樹節點後,可能須要退化爲鏈表節點,還可能因爲不知足紅黑樹特色,須要移動節點位置。 代碼也比較多,這裏就不貼上來了。但也所以佐證了爲何不所有使用紅黑樹來代替鏈表。

JDK7擴容時致使的死循環問題

/** * Transfers all entries from current table to newTable. */
void transfer(Entry[] newTable) {
 Entry[] src = table;
 int newCapacity = newTable.length;
 for (int j = 0; j < src.length; j++) {
   Entry<K,V> e = src[j];
   if (e != null) {
       src[j] = null;
       do {
           // B線程執行到這裏以後就暫停了
           Entry<K,V> next = e.next;
           int i = indexFor(e.hash, newCapacity);
           e.next = newTable[i];
           // 會把元素放到鏈表頭,因此擴容後數據會被倒置
           newTable[i] = e;
           e = next;
       } while (e != null);
   }
 }
}

複製代碼

擴容時上面的代碼容易致使死循環,是怎樣致使的呢?假設有兩個線程A和B都在執行這一段代碼,數組大小由2擴容到4,在擴容前tab[1]=1-->5-->9。

擴容前

當B線程執行到 next = e.next時讓出時間片,A線程執行完整段代碼可是尚未將內部的table設置爲新的newTable時,線程B繼續執行。

此時A線程執行完成以後,掛載在tab[1]的元素是9-->5-->1,注意這裏的順序被顛倒了。此時e = 1, next = 5;

tab[i]的按照循環次數變動順序, 1. tab[i]=1, 2. tab[i]=5-->1, 3. tab[i]=9-->5-->1

線程A執行完成後

一樣B線程咱們也按照循環次數來分析

  1. 第一次循環執行完成後,newTable[i]=1, e = 5
  2. 第二次循環完成後: newTable[i]=5-->1, e = 1。
  3. 第三次循環,e沒有next,因此next指向null。當執行e.next = newTable[i](1-->5)的時候,就造成了 1-->5-->1的環,再執行newTable[i]=e,此時newTable[i] = 1-->5-->1。

當在數組該位置get尋找對應的key的時候,就發生了死循環,引發CPU 100%問題。

線程B執行擴容過程

而JDK8就不會出現這個問題,它在這裏就有一個優化,它使用了兩個指針來分別指向頭節點和尾節點,並且還保證了元素本來的順序。 固然HashMap仍然是不安全的,因此在多線程併發條件下推薦使用ConcurrentHashMap。


你的點贊是對我最大的支持,固然你關注我就更好了

相關文章
相關標籤/搜索