別說你還不懂 HashMap

來自專輯
我有點兒基礎
別說你還不懂 HashMap
古時的風箏第 79 篇原創文章 node

做者 | 風箏
公衆號:古時的風箏(ID:gushidefengzheng)
轉載請聯繫受權,掃碼文末二維碼加微信程序員

這是上篇文章 有趣的圖說 HashMap,普通人也能看懂 的文字版,實際上是這篇先寫完,而後畫了很多圖片,因此就寫了一篇圖片版的。圖片版雖然讀起來比較輕鬆,可是沒有文字版的詳細,本篇 8000 多字,建議三連。面試

在 Java 中,最經常使用的數據類型是 8 中基本類型以及他們的包裝類型以及字符串類型,其次應該就是 ArrayList和HashMap了吧。HashMap存的是鍵值對類型的數據,其存儲和獲取的速度快、性能高,是很是好用的一個數據結構,每個 Java 開發者都確定用過它。算法

並且 HashMap的設計巧妙,其結構和原理也常常被拿去當作面試題。其中有不少巧妙的算法和設計,好比 Hash 算法、拉鍊法、紅黑樹設計等,值得每個開發者借鑑學習。數組

想了老半天,怎麼才能簡單易懂的把 HashMap說明白呢,那就從我理解它的思路和過程去說吧。要理解一個事物最好的方式就是先了解總體結構,再去追究細節。因此,咱們先從結構談起。安全

先從結構提及

拿我自身的一個體會來講吧,風箏我做爲一個專業路癡,對於迷路這件事兒毫不含糊,雖然在北京混跡多年,可是隻在中關村能分清南北,其餘地方,哪怕是我天天住的小區、天天工做的公司也分不太清方向,回家只能認一條路,要是打車換條路回家,也得迷糊一陣,這麼說吧,在小區前面能回家,小區後面找不到家。去個新地方,得盯着地圖看半天。這時,我就在想啊,要是我能在城市上空俯瞰下面的街道,那我就不再怕找不到回家的路了。這不就是三體裏的降維打擊嗎,站在高維的立場,理解低維的事物,那就簡單多了。微信

理解數據結構也是一個道理,大多數時候,咱們都是停留在會用的層面上,理解一些原理也只是支離破碎的,困在數據機構的迷宮裏跌跌撞撞,迫切的須要一張地圖或者一架直升機。數據結構

先來看一下整個 Map家族的集成關係圖,一看東西還很多,但其餘的可能都沒怎麼用過,只有 HashMap最熟悉。多線程

別說你還不懂 HashMap
如下描述可能不夠專業,只爲簡單的描述 HashMap的結構,請結合下圖進行理解。
別說你還不懂 HashMap併發

HashMap主體上就是一個數組結構,每個索引位置英文叫作一個 bin,咱們這裏先管它叫作桶,好比你定義一個長度爲 8 的 HashMap,那就能夠說這是一個由 8 個桶組成的數組。當咱們像數組中插入數據的時候,大多數時候存的都是一個一個 Node 類型的元素,Node 是 HashMap中定義的靜態內部類。

當插入數據(也就是調用 put 方法)的時候,並非按順序一個一個向後存儲的,HashMap中定義了一套專門的索引選擇算法,叫作散列計算,但散列計算存在一種狀況,叫哈希碰撞,也就是兩個不同的 key 散列計算出來的 hash 值是一致的,這種狀況怎麼辦呢,採用拉鍊法進行擴展,好比圖中藍色的鏈表部分,這樣一來,具備相同 hash 值的不一樣 key 便可以落到相同的桶中,又保證不會覆蓋以前的內容。

但隨着插入的元素愈來愈多,發生碰撞的機率就越大,某個桶中的鏈表就會愈來愈長,直到達到一個閾值,HashMap就受不了了,爲了提高性能,會將超過閾值的鏈表轉換形態,轉換成紅黑樹的結構,這個閾值是 8 。也就是單個桶內的鏈表節點數大於 8 ,就會將鏈表變身爲紅黑樹。

以上歸納性的描述就是 HashMap的總體結構,也是咱們進一步研究細節的藍圖。咱們將從中抽取出幾個關鍵點一一解釋,從總體到細節,降維打擊 HashMap。

接下來就是說明爲何會設計成這樣的結構以及從單純數組到桶內鏈表產生,接着把鏈表轉換成紅黑樹的詳細過程。

認清幾個關鍵概念

存儲容器
由於HashMap內部是用一個數組來保存內容的,數組定義以下:

transient Node<K,V>[] table;

Node 類型
table 是一個 Node類型的數組,Node是其中定義的靜態內部類,主要包括 hash、key、value 和 next 的屬性。好比以後咱們使用 put 方法像其中加鍵值對的時候,就會轉換成 Node 類型。

static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  V value;
  Node<K,V> next;
}

TreeNode

前面說了,當桶內鏈表到達 8 的時候,會將鏈表轉換成紅黑樹,就是 TreeNode類型,它也是 HashMap中定義的靜態內部類。

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 數組的長度,也就是咱們所說的桶的個數。其定義以下

int threshold;

默認是 16,若是咱們在初始化的時候沒有指定大小,那就是 16。固然咱們也能夠本身指定初始大小,而 HashMap 要求初始大小必須是 2 的 冪次方。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

元素個數

容量是指定了桶的個數,而 size 是說 HashMap中實際存了多少個鍵值對。

transient int size;

最大容量

table 的長度也是有限制的,不能無限大,HashMap規定最大長度爲 2 的30次方。

static final int MAXIMUM_CAPACITY = 1 << 30;

負載因子
這是一個係數,它和 threshold 結合起做用,默認是 0.75。通常狀況下不要改。

final float loadFactor;

擴容閾值

閾值 = 容量 x 負載因子,假設當前 HashMap的容量是 16,負載因子是默認值 0.75,那麼當 size 到達 16 x 0.75= 12 的時候,就會觸發擴容。

初始化 HashMap

使用 HashMap確定要初始化吧,不少狀況下都是用無參構造方法建立。

Map<String,String> map = new HashMap<>();

這種狀況下全部屬性都是默認值,好比容量是 16,負載因子是 0.75。

另外推薦的一種初始化方式,就是給定一個默認容量,好比指定默認容量是 32。

Map<String,String> map = new HashMap<>(32);

可是 HashMap 要求初始大小必須是 2 的 n 次方,可是又不能要求每一個開發人員指定初始容量的時候都按要求來,好比咱們指定初始大小爲爲 七、18 這種會怎麼樣呢?

不要緊,HashMap中有個方法專門負責將傳過來的參數值轉換爲最接近、且大於等於指定參數的 2 的 n 次方的值,好比指定大小爲 7 的話,最後實際的容量就是 8 ,若是指定大小爲 18的話,那最後實際的容量就是 32 。

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);
}

執行這個轉換動做的就是 tableSizeFor方法,通過轉換後,將最終的結果賦值給 threshold變量,也就是初始容量,也就是本篇中所說的桶個數。

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;
}

tableSizeFor這個方法就有意思了,先把初始參數減 1,而後連着作或等於和無符號右移操做,最後算出一個接近的 2 的冪次方,下圖演示了初始參數爲 18 時的一系列操做,最後得出的初始大小爲 32。

別說你還不懂 HashMap

這個算法頗有意思了,好比你給的初始大小是 63,那獲得的結果就是 64,若是初始大小給定 65 ,那獲得的結果就是 128,老是能得出不小於給定初始大小,而且最接近的2的n次方的最終值。

從 put 方法解密核心原理

put方法是增長鍵值對最經常使用的方法,也是最複雜的過程,增長鍵值對的過程涉及了 HashMap最核心的原理,主要包括如下幾點:

  1. 什麼狀況下會擴容,擴容的規則是什麼?

  2. 插入鍵值對的時候如何肯定索引,HashMap可不是按順序插入的,那樣不就真成了數組了嗎。

  3. 如何確保 key 的惟一性?

  4. 發生哈希碰撞怎麼處理?

  5. 拉鍊法是什麼?

  6. 單桶內的鏈表如何轉變成紅黑樹?

如下是 put 方法的源碼,我在其中作了註釋。

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  HashMap.Node<K,V>[] tab; // 聲明 Node 數組 tab
  HashMap.Node<K,V> p;    // 聲明一個 Node 變量 p
  int n, i;
  /**
  * table 定義 transient Node<K,V>[] table; 用來存儲 Node 節點
  * 若是 當前table爲空,則調用resize() 方法分配數組空間
  */
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // n 老是爲 2 的冪次方,(n-1) & hash 可肯定 tab.length (也就是table數組長度)內的索引
  // 而後 建立一個 Node 節點賦給當前索引
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    //若是當前索引位置已經有值了,怎麼辦
    // 拉鍊法出場
    HashMap.Node<K,V> e;
    K k;
    // 判斷 key 值惟一性
    // p 是當前待插入索引處的值
    // 哈希值一致而且(當前位置的 key == 待插入的key(注意 == 符號),或者key 不爲null 而且 key.equals(k))
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k)))) //若是當前節點只有一個元素,且和待插入key同樣 則覆蓋
      // 將 p(當前索引)節點臨時賦予 e
      e = p;
    else if (p instanceof HashMap.TreeNode) // 若是當前索引節點是一顆樹節點
      //插入節點樹中 並返回
      e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
      // 當前索引節點即不是隻有一個節點,也不是一顆樹,說明是一個鏈表
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) { //找到沒有 next 的節點,也就是最後一個
          // 建立一個 node 賦給 p.next
          p.next = newNode(hash, key, value, null);
          // 若是當前位置+1以後大於 TREEIFY_THRESHOLD 則要進行樹化
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            //執行樹化操做
            treeifyBin(tab, hash);
          break;
        }
        //若是又發生key衝突則中止 後續這個節點會被相同的key覆蓋
        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;
  // 當實際長度大於 threshold 時 resize
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}

首次初始化數組和擴容

在執行 put方法時,第一步要檢查 table 數組是否爲空或者長度是否爲 0,若是是這樣的,說明這是首次插入鍵值對,須要執行 table 數組初始化操做。

另外,隨之鍵值對添加的愈來愈多,HashMap的 size 愈來愈大,注意 size 前面說了,是實際的鍵值對數量,那麼 size 到了多少就要擴容了呢,並非等 size 和 threshold(容量)同樣大了才擴容,而是到了閾值就開始擴容,閾值上面也說了,是容量 x 負載因子。

爲何放在一塊兒說呢,由於首次初始化和擴容都是用的同一個方法,叫作 resize()。如下是我註釋的 resize()方法。

final HashMap.Node<K,V>[] resize() {
  // 保存 table 副本,接下來 copy 到新數組用
  HashMap.Node<K,V>[] oldTab = table;
  // 當前 table 的容量,是 length 而不是 size
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 當前桶大小
  int oldThr = threshold;

  int newCap, newThr = 0;
  if (oldCap > 0) { //若是當前容量大於 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) // 初始化的場景(給定默認容量),好比 new HashMap(32)
    newCap = oldThr; //將容量設置爲 threshold 的值
  else {               // 無參數初始化場景,new HashMap()
    // 容量設置爲 DEFAULT_INITIAL_CAPACITY
    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"})
  HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
  table = newTab;
  if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
      HashMap.Node<K,V> e;
      //遍歷 得到獲得元素 賦給 e
      if ((e = oldTab[j]) != null) { //若是當前桶不爲空
        oldTab[j] = null; // 置空回收
        if (e.next == null) //節點 next爲空的話 從新尋找落點 
          newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof HashMap.TreeNode) //若是是樹節點
          //紅黑樹節點單獨處理
          ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // 保持原順序
          HashMap.Node<K,V> loHead = null, loTail = null;
          HashMap.Node<K,V> hiHead = null, hiTail = null;
          HashMap.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;
}

首次初始化

put方法中先檢查 table 數組是否爲空,若是爲空就初始化。

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

首次初始化分爲無參初始化和有參初始化兩種狀況,前面在講 HashMap初始化的時候說了,無參狀況默認就是 16,也就是 table 的長度爲 16。有參初始化的時候,首先使用 tableSizeFor()方法肯定實際容量,最後 new 一個 Node 數組出來。

HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];

其中 newCap就是容量,默認16或者自定義的。

而這個過程當中還有很重要的一步,就是維護擴容閾值。

擴容

put方法中,判斷當 size(實際鍵值對個數)到達 threshold (閾值)時,觸發擴容操做。

// 當實際長度大於 threshold 時 resize
if (++size > threshold)
    resize();

HashMap遵循兩倍擴容規則,每次擴容以後的大小是擴容前的兩倍。另外,說到底,底層的存儲仍是一個數組,Java 中沒有真正的動態數組這一說,數組初始化的時候是多大,那它就一直是這麼大,那擴容是怎麼來的呢,答案就是建立一個新數組,而後將老數組的數據拷貝過去。

拷貝的時候可能會有以下幾種狀況:

  1. 若是節點 next 屬性爲空,說明這是一個最正常的節點,不是桶內鏈表,也不是紅黑樹,這樣的節點會從新計算索引位置,而後插入。

  2. 若是是一顆紅黑樹,則使用 split方法處理,原理就是將紅黑樹拆分紅兩個 TreeNode 鏈表,而後判斷每一個鏈表的長度是否小於等於 6,若是是就將 TreeNode 轉換成桶內鏈表,不然再轉換成紅黑樹。

  3. 若是是桶內鏈表,則將鏈表拷貝到新數組,保證鏈表的順序不變。

肯定插入點

當咱們調用 put方法時,第一步是對 key 進行 hash 計算,計算這個值是爲了以後尋找落點,也就是究竟要插入到 table 數組的哪一個桶中。

hash 算法是這樣的,拿到 key 的 hashCode,將 hashCode 作一次16位右位移,而後將右移的結果和 hashCode 作異或運算,這段代碼叫作「擾動函數」,之因此不直接拿 hashCode 是爲了增長隨機性,減小哈希碰撞次數。

/**
* 用來計算 key 的 hash 值
**/
static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

拿到這個 hash 值以後,會進行這樣的運算 i = (n - 1) & hash,其中 i就是最終計算出來的索引位置。

有兩個場景用到了這個索引計算公式,第一個場景就是 put方法插入鍵值對的時候。第二個場景是在 resize 擴容的時候,new 出來新數組以後,將已經存在的節點移動到新數組的時候,若是節點不是鏈表,也不是紅黑樹,而是一個普通的 Node 節點,會從新計算,找到在新數組中的索引位置。

接着看圖,仍是圖說的清楚。

HashMap 要求容量必須是 2 的 n 次方,2的 n 次方的二進制表示你們確定都很清楚,2的6次方,就是從右向左 6 個 0,而後第 7 位是 1,下圖展現了 2 的 6 次方的二進制表示。
別說你還不懂 HashMap

而後這個 n-1的操做就厲害了,減一以後,後面以前二進制表示中 1 後面的 0 全都變成了 1,1 所在的位變爲 0。好比 64-1 變爲 63,其二進制表示是下面這樣的。
別說你還不懂 HashMap

下圖中,前面 4 行分別列出了當 map 的容量爲 八、1六、3二、64的時候,假設容量爲 n,則對應的 n-1 的二進制表示是下面這樣的,尾部一片紅,都是 1 ,能預感到將要有什麼騷操做。

沒錯,將這樣的二進制表示代入這個公式 (n - 1) & hash中,最終就能肯定待插入的索引位了。接着看圖最下面的三行,演示了假設當前 HashMap的容量爲 64 ,而待插入的一個 key 通過 hash 計算後獲得的結果是 99 時,代入公式計算 index 的值,也就是 (64-1)& 99,最終的計算結果是 35,也就是這個 key 會落到 table[35] 這個位置。

爲何 HashMap必定要保證容量是 2 的冪次方呢,經過二進制表示能夠看出,若是有多位是 1 ,那與 hash 值進行與運算的時候,更能保證最後散列的結果均勻,這樣很大程度上由 hash 的值來決定。

別說你還不懂 HashMap

如何確保 key 的惟一性

HashMap中不容許存在相同的 key 的,那怎麼保證 key 的惟一性呢,判斷的代碼以下。

if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))

首先經過 hash 算法算出的值必須相等,算出的結果是 int,因此能夠用 == 符號判斷。只是這個條件可不行,要知道哈希碰撞是什麼意思,有可能兩個不同的 key 最後產生的 hash 值是相同的。

而且待插入的 key == 當前索引已存在的 key,或者 待插入的 key.equals(當前索引已存在的key),注意== 和 equals 是或的關係。== 符號意味着這是同一個對象, equals 用來肯定兩個對象內容相同。

若是 key 是基本數據類型,好比 int,那相同的值確定是相等的,而且產生的 hashCode 也是一致的。

String 類型算是最經常使用的 key 類型了,咱們都知道相同的字符串產生的 hashCode 也是同樣的,而且字符串能夠用 equals 判斷相等。

可是若是用引用類型當作 key 呢,好比我定義了一個 MoonKey 做爲 key 值類型

public class MoonKey {

    private String keyTile;

    public String getKeyTile() {
        return keyTile;
    }

    public void setKeyTile(String keyTile) {
        this.keyTile = keyTile;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MoonKey moonKey = (MoonKey) o;
        return Objects.equals(keyTile, moonKey.keyTile);
    }
}

而後用下面的代碼進行兩次添加,你說 size 的長度是 1 仍是 2 呢?

Map<MoonKey, String> m = new HashMap<>();
MoonKey moonKey = new MoonKey();
moonKey.setKeyTile("1");
MoonKey moonKey1 = new MoonKey();
moonKey1.setKeyTile("1");
m.put(moonKey, "1");
m.put(moonKey1, "2");
System.out.println(hash(moonKey));
System.out.println(hash(moonKey1));
System.out.println(m.size());

答案是 2 ,爲何呢,由於 MoonKey 沒有重寫 hashCode 方法,致使 moonkey 和 moonKey1 的 hash 值不可能同樣,當不重寫 hashCode 方法時,默認繼承自 Object的 hashCode 方法,而每一個 Object對象的 hash 值都是獨一無二的。

劃重點,正確的作法應該是加上 hashCode的重寫。

@Override
public int hashCode() {
  return Objects.hash(keyTile);
}

這也是爲何要求重寫 equals 方法的同時,也必須重寫 hashCode方法的緣由之一。若是兩個對象經過調用equals方法是相等的,那麼這兩個對象調用hashCode方法必須返回相同的整數。有了這個基礎才能保證 HashMap或者HashSet的 key 惟一。

發生哈希碰撞怎麼辦

前面剛說了相等的對象產生的 hashCode 也要相等,可是不相等的對象使用 hash方法計算以後也有可能產生相同的值,這就叫作哈希碰撞。雖然經過算法已經很大程度上避免碰撞的發生,可是卻沒法避免。

產生碰撞以後,天然得出的在 table 數組的索引(也就是桶)也是同樣的,這時,怎麼辦呢,一個桶裏怎麼放多個鍵值對?

拉鍊法

文章剛開頭就提到了,HashMap可不是簡單的數組而已。當碰撞發生就坦然接收。有一種方法叫作拉鍊法,不是衣服上那種拉鍊。而是,當碰撞發生了,就在當前桶上拉一條鏈表出來,這樣解釋就合理了。

前面介紹關鍵概念的時候提到了 Node類型,裏面有個屬性叫作 next,它就是爲了這種鏈表設計的,以下圖所示。node一、node二、node3都落在了同一個桶中,這時候就得用鏈表的方式處理了,node1.next = node2,node2.next = node3,這樣將鏈表串起來。而 node3.next = null,則說明這是鏈表的尾巴。

當有新元素準備插入到鏈表的時候,採用的是尾插法,而不是頭插法了,JDK 1.7 的版本採用的是頭插法,可是頭插法有個問題,就是在兩個線程執行 resize() 擴容的時候,極可能形成環形鏈表,致使 get 方法出現死循環。
別說你還不懂 HashMap

鏈表轉換成樹

鏈表不是碰撞處理的終極結構,終極結構是紅黑樹,當鏈表長度到達 8 以後,再有新元素進來,那就要開始由鏈表到紅黑樹的轉換了。方法 treeifyBin是完成這個過程的。

使用紅黑樹是出於性能方面的考慮,紅黑樹的查找速度要優於鏈表。那爲何不是一開始就直接生成紅黑樹,而是鏈表長度大於 8 以後才升級成樹呢?

首先來講,哈希碰撞的機率仍是很小的,大部分狀況下都是一個桶裝一個 Node,即使發生碰撞,都碰撞到一個桶的機率那就更是少之又少了,因此鏈表長度不多有機會能到 8 ,若是鏈表長度到 8 了,那說明當前 HashMap中的元素數量已經很是大了,那這時候用紅黑樹來提升性能是可取的。而反過來,若是 HashMap總的元素不多,即使用紅黑樹對性能的提高也不大,何況紅黑樹對空間的使用要比鏈表大不少。

get 方法

T value = map.get(key);

例如經過上面的語句經過 key 獲取 value 值,是咱們最經常使用到的方法了。

別說你還不懂 HashMap

看圖理解,當調用 get方法後,第一步仍是要肯定索引位置,也就是咱們所說的桶的位置,方法和 put方法時同樣,都是先使用 hash這個擾動函數肯定 hash 值,而後用 (n-1) & hash獲取索引。這不廢話嗎,固然得和 put的時候同樣了,不同還怎麼找到正確的位置。

肯定桶的位置後,會出現三種狀況:

單節點類型:也就是這個桶內只有一個鍵值對,這也在 HashMap中存在最多的類型,只要不發生哈希碰撞都是這種類型。其實 HashMap最理想的狀況就是這樣,全都是這種類型就完美了。

鏈表類型:若是發現 get 的 key 所在的是一個鏈表結構,就須要遍歷鏈表,知道找到 key 相等的 Node。

紅黑樹類型:當鏈表長度超過 8 就轉變成紅黑樹,若是發現找到的桶是一顆紅黑樹,就使用紅黑樹專有的快速查找法查找。

另外,Map.containsKey方法其實用的就是 get方法。

remove 方法

remove與put、get方法相似,都是先求出 key 的 hash 值,而後 (n-1) & hash獲取索引位置,以後根據節點的類型採起不一樣的措施。

單節點類型:直接將當前桶元素替換爲被刪除 node.next ,其實就是 null。

鏈表類型: 若是是鏈表類型,就將被刪除 node 的前一個節點的 next 屬性設置爲 node.next。

紅黑樹類型:若是是一棵紅黑樹,就調用紅黑樹節點刪除法,這裏,若是節點數在 2~6之間,就將樹結構簡化爲鏈表結構。

非線程安全

HashMap沒有作併發控制,若是想在多線程高併發環境下使用,請用 ConcurrentHashMap。同一時刻若是有多個線程同時執行 put 操做,若是計算出來的索引(桶)位置是相同的,那會形成前一個 key 被後一個 key 覆。

好比下圖線程 A 和 線程 B 同時執行 put 操做,很巧的是計算出的索引都是 2,而此時,線程A 和 線程B都判斷出索引爲 2 的桶是空的,而後就是插入值了,線程A先 put 進去了 key1 = 1的鍵值對,可是,緊接着線程B 又 put 進去了 key2 = 2,線程A 表示聲淚俱下,白忙活一場。最後索引爲2的桶內的值是 key2=2,也就是線程A的存進去的值被覆蓋了。
別說你還不懂 HashMap

總結

前面沒說,HashMap搞的這麼複雜不是白搞的,它的最大優勢就是快,尤爲是 get數據,是 O(1)級別的,直接定位索引位置。

HashMap不是單純的數組結構,當發生哈希碰撞時,會採用拉鍊法生成鏈表,當鏈表大於 8 的時候會轉換成紅黑樹,紅黑樹能夠很大程度上提升性能。

HashMap容量必須是 2 的 n 次方,這樣設計是爲了保證尋找索引的散列計算更加均勻,計算索引的公式爲 (n - 1) & hash。

HashMap在鍵值對數量達到擴容閾值「容量 x 負載因子」的時候進行擴容,每次擴容爲以前的兩倍。擴容的過程當中會對單節點類型元素進行從新計算索引位置,若是是紅黑樹節點則使用 split方法從新考量,是否將紅黑樹變爲鏈表。

還能夠讀:

有趣的圖說 HashMap,普通人也能看懂

Lambda、函數式接口、Stream 一次性全給你

隔離作的好,數據操做沒煩惱[MySQL]


公衆號:古時的風箏

一個兼具深度與廣度的程序員鼓勵師,一個本打算寫詩卻寫起了代碼的田園碼農!你可選擇如今就關注我,或者看看歷史文章再關注也不遲。

技術交流還能夠加羣或者直接加我微信。

別說你還不懂 HashMap

相關文章
相關標籤/搜索