HashMap源碼分析(JDK8)

Map

前言

彷佛全部的java面試或者考察都繞不開hash,準確說是必問集合,問集合必問hash表。雖然一直以來都常常的使用HashMap,可是卻一直沒有看過源碼,多是沒有意識到閱讀源碼的好處,通過前幾篇的一個分析,發現閱讀源碼讓本身對集合有了更加深入的瞭解,所以會一直將這個系列進行下去,此次要說的HashMap。java

HashMap的基本概況

HashMap是一個Hash表(以前有寫過數據結構的文章,專門對哈希表作過講解),其數據以鍵值對的結構進行存儲,在遇到衝突的時候會使用鏈表來進行解決,JDK8之後引入了紅黑樹的模式,具體會在文中分析。node

其次,HashMap是非線程安全的,Key和Value都容許爲空,Key重複會覆蓋、Value容許重複。補充一句,在多線程下咱們可使用concurrentHashMap。git

HashMap和Hashtable的區別

HashMap和Hashtable

HashMap

定義
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable 複製代碼

HashMap沒有什麼要說的,直接切入正題,初始化一個HashMap。github

初始化
HashMap map = new HashMap();
複製代碼

經過這個方法會調用HashMap的無參構造方法。web

//兩個常量 向下追蹤
public HashMap() {
  this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

//無參構造建立對象以後 會有兩個常量
//DEFAULT_INITIAL_CAPACITY 默認初始化容量 16 這裏值得借鑑的是位運算
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//DEFAULT_LOAD_FACTOR 負載因子默認爲0.75f 負載因子和擴容有關 後文詳談
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//最大容量爲2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

//以Node<K,V>爲元素的數組,長度必須爲2的n次冪
transient Node<K,V>[] table;

//已經儲存的Node<key,value>的數量,包括數組中的和鏈表中的,邏輯長度
transient int size;

threshold 決定能放入的數據量,通常狀況下等於 Capacity * LoadFactor
複製代碼

經過上述代碼咱們不難發現,HashMap的底層仍是數組(注意,數組會在第一次put的時候經過 resize() 函數進行分配),數組的長度爲2的N次冪。面試

在HashMap中,哈希桶數組table的長度length大小必須爲2的n次方(必定是合數),這是一種很是規的設計,常規的設計是把桶的大小設計爲素數。相對來講素數致使衝突的機率要小於合數,Hashtable初始化桶大小爲11,就是桶大小設計爲素數的應用(Hashtable擴容後不能保證仍是素數)。HashMap採用這種很是規設計,主要是爲了在取模和擴容時作優化,同時爲了減小衝突,HashMap定位哈希桶索引位置時,也加入了高位參與運算的過程。算法

那麼Node<K,V>是什麼呢?json

//一個靜態內部類 其實就是Map中元素的具體存儲對象 
static class Node<K,V> implements Map.Entry<K,V> {
  		//每一個儲存元素key的哈希值
        final int hash;
  		//這就是key-value
        final K key;
        V value;
  		//next 追加的時候使用,標記鏈表的下一個node地址
        Node<K,V> next;
		
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
複製代碼

此時咱們就擁有了一個空的HashMap,下面咱們看一下put數組

put

JDK8 HashMap put的基本思路:安全

  1. 對key的hashCode()進行hash後計算數組下標index;
  2. 若是當前數組table爲null,進行resize()初始化;
  3. 若是沒碰撞直接放到對應下標的位置上;
  4. 若是碰撞了,且節點已經存在,就替換掉 value;
  5. 若是碰撞後發現爲樹結構,掛載到樹上。
  6. 若是碰撞後爲鏈表,添加到鏈表尾,並判斷鏈表若是過長(大於等於TREEIFY_THRESHOLD,默認8),就把鏈表轉換成樹結構;
  7. 數據 put 後,若是數據量超過threshold,就要resize。
public V put(K key, V value) {
  //調用putVal方法 在此以前會對key作hash處理
  return putVal(hash(key), key, value, false, true);
}
//hash
static final int hash(Object key) {
  int h;
 // h = key.hashCode() 爲第一步 取hashCode值
 // h ^ (h >>> 16) 爲第二步 高位參與運算
  //具體的算法就不解釋了 做用就是性能更加優良
  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;
  //若是當前數組table爲null,進行resize()初始化
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  //(n - 1) & hash 計算出下標 若是該位置爲null 說明沒有碰撞就賦值到此位置
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    //反之 說明碰撞了 
    Node<K,V> e; K k;
    //判斷 key是否存在 若是存在就覆蓋原來的value 
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    //沒有存在 判斷是否是紅黑樹
    else if (p instanceof TreeNode)
      //紅黑樹是爲了防止哈希表碰撞攻擊,當鏈表鏈長度爲8時,及時轉成紅黑樹,提升map的效率
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    //都不是 就是鏈表 
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          //將next指向新的節點
          p.next = newNode(hash, key, value, null);
          //這個判斷是用來判斷是否要轉化爲紅黑樹結構
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        // key已經存在直接覆蓋value
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    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;
}
複製代碼

在剛纔的代碼中咱們提到了紅黑樹是爲了防止**哈希表碰撞攻擊,當鏈表鏈長度爲8時,及時轉成紅黑樹,提升map的效率。**那麼接下來講一說什麼是哈希表碰撞攻擊。

如今作web開發RESTful風格的接口至關的普及,所以不少的數據都是經過json來進行傳遞的,而json數據收到以後會被轉爲json對象,一般都是哈希表結構的,就是Map。

咱們知道理想狀況下哈希表插入和查找操做的時間複雜度均爲O(1),任何一個數據項能夠在一個與哈希表長度無關的時間內計算出一個哈希值(key),從而獲得下標。可是不免出現不一樣的數據被定位到了同一個位置,這就致使了插入和查找操做的時間複雜度不爲O(1),這就是哈希碰撞

java的中解決哈希碰撞的思路是單向鏈表和黑紅樹,上文提到紅黑樹是JDK8以後添加,爲了防止哈希表碰撞攻擊,爲何?。

不知道你有沒有設想過這樣一種場景,添加的全部數據都碰撞在一塊兒,那麼這些數據就會被組織到一個鏈表中,隨着鏈表愈來愈長,哈希表會退化爲單鏈表。哈希表碰撞攻擊就是經過精心構造數據,使得全部數據所有碰撞,人爲將哈希表變成一個退化的單鏈表,此時哈希表各類操做的時間均提高了一個數量級,所以會消耗大量CPU資源,致使系統沒法快速響應請求,從而達到拒絕服務攻擊(DoS)的目的。

咱們須要注意的是紅黑樹實際上並不能解決哈希表攻擊問題,只是減輕影響,防禦該種攻擊還須要其餘的手段,譬如控制POST數據的數量。

擴容resize()

不論是list仍是map,都會遇到容量不足須要擴容的時候,可是不一樣於list,HashMap的擴容設計的很是巧妙,首先在上文提到過數組的長度爲2的N次方,也就是說初始爲16,擴容一次爲32... 好處呢?就是上文提到的擴容是性能優化和減小碰撞,就是體如今此處。

數組下標計算: index = (table.length - 1) & hash ,因爲 table.length 也就是capacity 確定是2的N次方,使用 & 位運算意味着只是多了最高位,這樣就不用從新計算 index,元素要麼在原位置,要麼在原位置+ oldCapacity.

若是增長的高位爲0,resize 後 index 不變;高位爲1在原位置+ oldCapacity。resize 的過程當中原來碰撞的節點有一部分會被分開。

擴容簡單說有兩步:

1.擴容

建立一個新的Entry空數組,長度是原數組的2倍。

2.ReHash

遍歷原Entry數組,把全部的Entry從新Hash到新數組。

//HashMap的源碼真的長 0.0 這段改天補上
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 {               // zero initial threshold signifies using defaults
    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)
          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;
}
複製代碼
爲何HashMap是線程不安全的

因爲源碼過長,HashMap的其餘方法就不寫了。下面說一下關於HashMap的一些問題

1.若是多個線程同時使用put方法添加元素會丟失元素

假設正好存在兩個put的key發生了碰撞,那麼根據HashMap的實現,這兩個key會添加到數組的同一個位置,這樣最終就會發生其中一個線程的put的數據被覆蓋。

2.多線程同時擴容會形成死循環

多線程同時檢查到擴容,而且執行擴容操做,在進行rehash的時候會形成閉環鏈表,從而在get該位置元素的時候,程序將會進入死循環。【證實HashMap高併發下問題會在之後的文章中出現】

如何讓HashMap實現線程安全?

  1. 直接使用Hashtable
  2. Collections.synchronizeMap方法
  3. 使用ConcurrentHashMap 下篇文章就是分析ConcurrentHashMap是如何實現線程安全的
總結
  1. HashMap 在第一次 put 時初始化,相似 ArrayList 在第一次 add 時分配空間。
  2. HashMap 的 bucket 數組大小必定是2的n次方
  3. HashMap 在 put 的元素數量大於 Capacity * LoadFactor(默認16 * 0.75) 以後會進行擴容
  4. 負載因子是能夠修改的,也能夠大於1,可是建議不要輕易修改,除非狀況很是特殊
  5. JDK8處於提高性能的考慮,在哈希碰撞的鏈表長度達到TREEIFY_THRESHOLD(默認8)後,會把該鏈表轉變成樹結構
  6. JDK8在 resize 的時候,經過巧妙的設計,減小了 rehash 的性能消耗
  7. 擴容是一個特別耗性能的操做,因此當在使用HashMap的時候,估算map的大小,初始化的時候給一個大體的數值,避免map進行頻繁的擴容

我不能保證每個地方都是對的,可是能夠保證每一句話,每一行代碼都是通過推敲和斟酌的。但願每一篇文章背後都是本身追求純粹技術人生的態度。

永遠相信美好的事情即將發生。

相關文章
相關標籤/搜索