HashMap原始碼閱讀

ifrank98發表於2020-11-26

前言

​ 在準備面試的過程中,發現HashMap原始碼是很常見的考點,於是進行了仔細的學習。都說讀原始碼是快速提高Java水平的好途徑,在閱讀了HashMap的部分重要原始碼之後真的是深有體會,於是寫下此篇文章做記錄。具體內容包括,HashMap的構造方法,put,get方法,以及put&get所需要的hash方法,還有擴容時所需要的resize方法。我們將對這幾個方法的原始碼進行閱讀,理解它的邏輯,以及箇中的一些巧妙設計。

HashMap原理

首先,什麼是HashMap?HashMap是基於Map介面的實現,儲存鍵值對時,它可以接收null的鍵值,是非同步的,HashMap儲存著Entry(hash, key, value, next)物件。
常用的方法有
構造方法,可以定義initialCapacity初始容量,factor負載因子。threshold = initialCapacity * factor
put,get,二者需要用到hash方法,也就是雜湊函式。
resize:放陣列容量不足時,元素個數大於threshold時,就要擴容。
HashMap使用連結串列陣列來儲存資料(陣列的每一項都是一個連結串列),JDK1.8開始,當連結串列的長度到達一定程度,就會把該連結串列轉換成紅黑樹。


構造方法:

構造方法一共有4個:
HashMap原始碼閱讀
顯然,第一個就是沒有引數,此時會設定預設的初始容量initialCapacity和預設的負載因子default_factor。
對於第二個,實際上就是傳入自定義的初始容量,將float引數設定為預設的負載因子default_factor。
對於第四個,是傳入一個Map物件進行初始化。我們重點看第三個構造方法:

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

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

前面的都是判斷一下邊界值,就省略了。
HashMap有幾個關鍵的成員屬性:
initialCapacity:初始容量大小(陣列大小,但後面會改變)
factor:負載因子
threshold:initialCapacity * factor(到達這個值的時候,雜湊陣列會擴容)
(初始化之後,後續用size表示雜湊陣列裡的元素個數,當size超過threshold之後,擴容)

然後我們發現一行關鍵的程式碼:

this.threshold = tableSizeFor(initialCapacity);

點進入看tableSizeFor的函式邏輯:

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

可以看到這個方法的目的:returns a power of two size for the given target capacity

也就是說,雜湊陣列的長度,永遠是2的冪次方

question1:雜湊陣列的長度為什麼需要是2的冪次方?
ans:因為在對映的時候,需要執行(n - 1) & hash。如果n是2的冪次方,那麼(n - 1)的低位全都是1(形式是000……1111),1 & x = x,即根據hash相應位的值來決定,而不是一定返回0。因此能降低碰撞機率,充分利用每一個位,使得元素更加均勻地分佈在HashMap上。

關於這個演算法的邏輯,用下圖可以說明:
HashMap原始碼閱讀
連續的n |= n >>> 1, 2, 4, 8, 16,通過這樣,最多可以讓連續32位為1.不管capacity是多少,比如它是1011,減去1之後是1010,第一個不為0的位是第4位,那麼這個演算法會返回10000.
(這裡的關鍵是或運算,因為第一位是1,1和任何數字進行或運算都為1,因此,n>>>1,會使得n的前2位變為2,然後再執行n>>>2,就是前4位,再執行n>>>4,就是前8位。)如果數字沒有那麼高位,那麼高位全是0,並且n>>>x全部都為0,因而或運算為0,高位沒有任何影響,看下圖例子:
HashMap原始碼閱讀
這個演算法,巧妙地通過了位運算,返回了一個不小於capacity 的最小2的冪次方。至於為什麼要在最開始-1,是防止capacity已經是2的冪次方的情況,比如是10000,如果不減1,那麼返回的將會是100000.減去1,使得初始的capacity改為1111(1111和1001,1101等都是一樣的)。
以上的情況都是在capacity不為0的情況考慮的,而當capacity為0的時候,無論經過幾次運算,都為0,那麼最後的capacity將為1(最後有一個n+1的操作),所以也是符合預期結果的。
這樣,我們就得到了一個2的冪次方的capacity,即雜湊陣列的長度(所以比如,當我們傳入的capacity為12,最終生成的雜湊陣列長度會是16.)

但是不要忘記了,我們這裡得到的是capacity,為什麼直接賦值給了threshold?難道不應該是

this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;

關於這個問題,因為此處只是簡單地賦值,而最關鍵的table陣列還沒有初始化。在後面put方法我們會發現,當table陣列還沒有初始化時,會先進行初始化(resize),並對threshold進行重新的計算。

構造方法的結果:
// 如果沒有指定initialCapacity, 則不會給threshold賦值, 該值被初始化為0
// 如果指定了initialCapacity, 該值被初始化成不小於initialCapacity的最小的2的次冪


put方法:

通過put方法可以看到,put方法其實呼叫的是putVal方法,除了key和value,傳入key作為引數的hash方法返回的雜湊值,還有兩個引數,但put方法並沒有過載方法。所以如果我們需要改變後兩個引數,應該使用putVal方法自己修改,但一般用不到,在下文看putVal的方法裡我們就知道這兩個引數是什麼作用了。

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

接下來關鍵是看putVal的方法實現:(逐行分析,中文註釋)

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一次,resize後面再看,這裡只需要知道
//當雜湊陣列為空,put方法會建立一個預設長度的雜湊陣列即可,而且resize會重新計算threshold
// 這也就解決了構造方法最後遺留的問題(重計算threshold)
    if ((p = tab[i = (n - 1) & hash]) == null)	
        tab[i] = newNode(hash, key, value, null);
//如果雜湊陣列該index沒有元素,即沒有發生碰撞,直接插入一個newNode即可。
//這裡的(n - 1) & hash,等同於hash % n,但效率更高,看後續的question2
    else {	//說明已經有元素,發生了碰撞,然後我們就沿著連結串列/紅黑樹去插入Node
        Node<K,V> e; K k;
//我們首先會檢視第一個元素(在第一個元素時就能確定它是連結串列還是紅黑樹)
        if (p.hash == hash &&	
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
//這裡的p是上文的p = tab[i = (n - 1) & hash],即第一個元素,如果第一個元素跟待插入
//的元素是相同的,即key相同(hash肯定是已經相同的了),然後我們只需要更改p的值即可。
//這裡的邏輯是,把p賦值給新建立的Node e,然後跳出整個迴圈之後,再判斷
//e是否為null。如果e為null,那麼直接進行value的替換即可,否則,往後看。
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//判斷該index項是連結串列還是紅黑樹,如果是紅黑樹再進入putTreeVal,此處略
        else {	
//說明是連結串列,並且第一個元素也不相等,所以我們就遍歷連結串列,然後插入到連結串列的最後。
//如果連結串列長度過長,還會引起連結串列樹化的操作。如果是整個陣列的長度過大,
//那麼還要對陣列進行resize。(PS:這裡的元素相等是指key,連結串列裡的key都是互
//不相等的,只是它們發生hash衝突導致都放在陣列的同一個index上。所以如果在中間
//發現了相同的key,那麼就跟前面一樣,其實也是e = p的邏輯,然後在後續直接覆蓋value
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {		
                    p.next = newNode(hash, key, value, null);
//p.next為null,那麼直接將p.next新建一個newNode即可。即已經到達連結串列的最後。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
//這是指連結串列長度大於等於TREEIFY_THRESHOLD的時候,進行樹化。預設是8.注意這
//裡為什麼是>= THREIFY_THRESHOLD,看起來是7個就可以樹化,但實際上還是8個的
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
//如果遇到了相等的key,那麼就是覆蓋value。注意此時並未到連結串列的最後,所以這裡的e不為null
                    break;
                p = e;
//這裡的p = e實際上就是 p = p.next,因為並沒有執行for迴圈裡的兩個if,如果執行了其
//中一個,都會直接break跳出迴圈。(個人覺得這個p = e放在for判定語句裡可讀性更好
            }
        }
        if (e != null) { // existing mapping for key
//這就是前面一直說的e,如果存在相同的key,那麼e就不是null,此時直接覆蓋value
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
//這就是第三個引數,表示已有相同的key時是否更新。onlyIfAbsent預設是false,所以這
//裡的if是一定會觸發,即一定會覆蓋value。如果手動將onlyIfAbsent改為true,那麼就是
//只有當oldValue為null的時候,才能改變key的value,否則都不會改變。
                e.value = value;
            afterNodeAccess(e);	
//這個在HashMap是空方法,在LinkedHashMap的時候才會被重寫並使用。
            return oldValue;
//覆蓋了value(或者不覆蓋),就把oldValue返回,方法結束。因此插入一個相同key的
//元素,實際上是更新該key的value,方法的邏輯在這裡已經完成,不會改變HashMap的大小
        }
    }	//最外層的if-else迴圈結束
    ++modCount;
//到達了這裡,說明不存在相同的key,所以插入了一個新的key,改變modCount以及
//雜湊陣列的元素個數size
    if (++size > threshold)
        resize();
//如果改變了之後,雜湊陣列的元素個數大於threshold,此時發生碰撞的概率較大,因此
//進行resize,即對雜湊陣列進行擴容,後面會講到。
    afterNodeInsertion(evict);	//同樣是LinkedHashMap的東西,此處為空方法。
    return null;
}

主要是這麼幾個步驟:
①如果當前 table為空,先進行初始化
②判斷key的雜湊值是否發生碰撞,如果沒有發生碰撞,直接分配一個 newNode
③如果發生了碰撞,遍歷該連結串列上的節點,檢視是否有相同的 key。因為要考慮此處的結構到底是連結串列還是紅黑樹,所以還要特地判斷第一個節點的型別(instanceof)
④如果是紅黑樹,出門左轉 putTreeVal方法,請。如果不是,說明是連結串列,對連結串列進行遍歷。
⑤如果找到了相同的 key,直接更新 value。如果已經到達了連結串列的終點,說明 key不存在,插入連結串列尾部,如果連結串列長度大於一個閾值,進行連結串列轉化樹的操作。用一個臨時變數 e來記錄是否有相同的key,如果存在相同的key,e最後不為 null,此時無需修改容量大小,否則要把容量 + 1
⑥如果 size大於一個閾值,進行擴容

question2: (n - 1 ) & hash的原理?
ans:因為n是2的冪次方,因而(n - 1)的值為000……1111(若干個1),而(n - 1) & hash,即返回hash的低
⌈log2(n - 1)⌉ (2為底)位的hash的值,並把高位全部置為0,效果等同於hash % n。如下圖:
HashMap原始碼閱讀
使用(n - 1) & hash而不使用hash % n的好處:
位運算是計算機最快的運算,因此效率更高。同樣因為n是2的冪次方,因而該演算法也不會出現超出取模範圍的錯誤。

question3:為什麼判定條件是"binCount >= TREEIFY_THRESHOLD - 1",但樹化的條件仍然是bitCount >= TREEIFY_THRESHOLD - 1?
ans:這裡:binCount >= TREEIFY_THRESHOLD - 1,看起來是大於等於7就會樹化,但其實並不是的。因為在剛執行完p.next = newNode(...);此時binCount仍然還沒有執行完++。所以仍然是連結串列中元素的個數大於等於TREEIFY_THRESHOLD(預設是8),才會樹化。
舉例:當元素個數為1的時候,即只有p,此時binCount為0,然後執行p.next = newNode(...)。if判斷失效,然後才執行binCount++(即新增完p.next之後,裡面已經有k個元素了,但if判斷的時候的binCount值為k - 1,只有到下一輪迴圈才改成k。
當連結串列一共有6個元素的時候,此時binCount為6(已經是下一輪迴圈),執行p.next = newNode,一共有7個元素。if判斷(6 < = 7),所以不會樹化,迴圈結束,binCount為7.然後下一輪迴圈,新增元素,為8,此時 7 <= 7,為真,樹化。
PS:Hash衝突是指不同物件的hashCode通過hash演算法後得出了相同定位的下標,這時候採用鏈地址法,會將此元素插入至此位置連結串列的最後一位,形成單連結串列。當存在位置的連結串列長度 大於等於 8 並且當前陣列容量超過64時,HashMap會將連結串列 轉變為 紅黑樹,這裡要說明一點,往往後者的條件會被大多數人忽略,當桶陣列容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致連結串列長度較長。這個時候應該優先擴容,而不是立即樹化。畢竟高碰撞率是因為桶陣列容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。


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

get方法顯然簡單很多。呼叫getNode方法,判斷返回的Node是否為null,如果不為null,返回Node的value
getNode的邏輯體也是比較簡單,先查詢第一個元素,看key值是否相等。至於為什麼需要“always check first node”,與put方法是一樣的,因為JDK1.8的陣列儲存的可能是連結串列,可能是紅黑樹,需要進行判斷
如果當第一個first就是相等的,那麼就直接返回。如果不在,判斷是否為紅黑樹。如果是,呼叫getTreeNode方法。如果不是,就是簡單的連結串列遍歷,對比,e = e.next。在put方法的時候還要考慮碰撞的問題,而get的方法就不必了。最後只需要遍歷一次連結串列,如果找到了key相同的Node節點,就直接把Node返回。如果到達末尾還沒找到,那麼就返回null。可以看到當連結串列長度較長的時候,get方法的時間複雜度就不能簡單地看作O(1)了,而是O(n),n為連結串列的長度。這也是為什麼當連結串列長度達到一定的時候,選擇轉換成紅黑樹,這使得最後的遍歷時間複雜度為O(logn),可以近似看作O(1)。


hash方法

首先給出HashMap計算雜湊碼的整體步驟
1.獲取 key的 hashCode
2.對 hashCode進行處理(hash方法),主要是高16位不變,而低16位與高16位進行異或操作
3.對 capacity進行取模(使用了 hash & (n - 1)進行優化)
在put和get方法中,可以看到都需要對key進行hash運算:

HashMap原始碼閱讀 HashMap原始碼閱讀 因為hashcode就是為了HashMap而生的,在學習重寫equals時為何要重寫hashCode的時候我們就已經知道了。那麼HashMap裡到底如何重寫hashCode方法呢,如下:
public final int hashCode() {
  return Objects.hashCode(key) ^ Objects.hashCode(value);
}

嗯。。。很簡單的異或運算,結合了key和value的hashCode,沒什麼特別的。結合value同樣是減少碰撞。這個就是步驟1.

那麼接下來看一下步驟2,HashMap自定義的hash方法:

static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

從邏輯上看,就是hash本身與hash右移16位的結果進行異或。
h >>> 16的結果:高16位全部變成0,原本高16位的處於低16位。
h ^ ( h >>> 16)的結果:
1.高16位不變。因為0與任何數進行異或,返回的都是那個數本身。0 ^ 1 = 1, 0 ^ 0 = 0
2.低16位於原本的高16位進行異或。
步驟3,對capacity取模,很好理解,不能超出雜湊陣列的範圍。

question4:為什麼要將低16位與高16位進行異或操作?
ans:先看一下原始碼的程式碼註釋:
Computes key.hashCode() and spreads (XORs) higher bits of hash to lower. Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. (Among known examples are sets of Float keys holding consecutive whole numbers in small tables.) So we apply a transform that spreads the impact of higher bits downward. There is a tradeoff between speed, utility, and quality of bit-spreading. Because many common sets of hashes are already reasonably distributed (so don’t benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.
設計者認為**(n - 1) & hash很容易發生碰撞**,因為如果不對hash進行其他處理,那麼hash起作用的僅僅是低⌈log2(n - 1)⌉位的值,比如當n為16的時候,hashCode起作用的僅僅是低4bit的有效位,那麼當然容易碰撞了。因此,設計者想了一個顧全大局的方法(綜合考慮了速度、作用、質量),就是把高16bit和低16bit異或了一下,這使得高位的bit也能影響到最終的hash值。設計者還解釋到因為現在大多數的hashCode的分佈已經很不錯了,就算是發生了碰撞也用O(logn)的tree去做了。僅僅異或一下,既減少了系統的開銷,也不會造成的因為高位沒有參與下標的計算(table長度比較小時),從而引起的碰撞。(即通過h ^ (h >>> 16)間接讓高16位也參與計算,從而讓鍵值對分佈均勻,降低hash碰撞

如果還是產生了頻繁的碰撞,會發生什麼問題呢?作者註釋說,他們使用樹來處理頻繁的碰撞***(we use trees to handle large sets of collisions in bins),在JEP-180中,描述了這個問題:
Improve the performance of java.util.HashMap under high hash-collision conditions by using balanced trees rather than linked lists to store map entries. Implement the same improvement in the LinkedHashMap class.
之前已經提過,在獲取HashMap的元素時,基本分兩步:
首先根據hashCode()做hash,然後確定bucket的index;
如果bucket的節點的key不是我們需要的,則通過key.equals()在連結串列中找。
在Java 8之前的實現中是用連結串列解決衝突的,在產生碰撞的情況下,get方法的邏輯中,這兩步的時間複雜度是
O(1)+O(n)
。當碰撞很厲害的時候n很大,O(n)的速度顯然是影響速度的,此時不能直接忽略,近似為O(1)。
因此在Java 8中,利用紅黑樹替換連結串列,這樣複雜度就變成了
*O(1)+O(logn)**了,這使得即使在n很大的時候,也能夠比較理想地解決這個問題。


resize方法

在put的過程中,當size超出了threshold,那麼就需要進行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) {		
//oldCap表明,table裡原本已經存在key-value
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
//如果oldCap都已經擴容到最大了,那麼就直接將threshold設為Integer的最大值
//雖然碰撞的概率很大了,但已經無法繼續擴容,這裡使threshold失去意義
            return oldTab;	
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
//沒有超出最大值,那麼就安心擴容為原來的 2倍。值得注意的是newCap跟newThr都擴
//容為2倍,仔細看 if 語句的判定。
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
//這個就是我們前面所說的,當初始化的時候,會將threshold僅僅作為一個變數賦值給
//newCap,然後後面又把newCap*factor賦值給threshold
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
//這裡就是呼叫new HashMap()的情況,一個構造引數也沒有的時候,直接賦預設值
    }
    if (newThr == 0) {
//當上面第一個if裡面沒有執行裡面的兩個子if語句時,newThr仍然沒有變化,即為0.需要在這裡再對threshold進行修改。
//(比如上面的: else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
//                 oldCap >= DEFAULT_INITIAL_CAPACITY),此時newCap可能超出了MAX,那麼
//newThr就仍然為0.又或者是 else if (oldThr > 0)  newCap = oldThr;中,即初始化帶int引數的時
//候,這裡仍然沒有對threshold進行賦值。)
 	float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
//這段程式碼就是普通地判斷threshold是否會超出MAX而已
    }
    threshold = newThr;//這裡就是put方法最後的疑問,threshold最後會變成capacity*factor
  
//我們已經將新陣列的各種引數(capacity,threshold等)都設定好了,接下來需要將原
//本的陣列元素放入到新的雜湊陣列中。顯然,因為這個操作,使得resize方法是一個極
//其耗費時間的方法,所以在大概知道元素個數的時候,不應該使用預設值16,而是顯式
//定義HashMap的初始容量,減少resize次數,可以提高效率
    @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;
//這裡table中存放的只是Node的引用, 將oldTab[j]=null只是清除舊錶的引用, 但是真正的
//node節點還在, 只是現在短暫地由e指向它。所以這裡主要是提醒JVM,這裡可以被GC清理了
                if (e.next == null)
                // e.next為null說明只有1個元素,那麼直接對映到相應位置即可
                    newTab[e.hash & (newCap - 1)] = e;
              // e.next不為null,說明不止1個元素,同樣地要先判斷是紅黑樹還是連結串列
                else if (e instanceof TreeNode)	//如果是樹,則根據紅黑樹的邏輯拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
// 不是紅黑樹,那麼就是連結串列。因此我們需要把該連結串列放入新的雜湊陣列的位置。
//主要是獲取整條連結串列(即使只有 1個元素,結構仍然是連結串列)。
                    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) {		//第n位為0
// 我們知道hash & (n - 1)就是原本的位置,那麼hash & n是什麼?原本的雜湊值長度為n - 1位
// 當它擴容之後,它的雜湊值為n - 1或者n位,即第n位要麼是0,要麼是1,而
// hash & n就是能獲取第n位的值,在後面我們會解釋為什麼
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;	// 尾插法
                            loTail = e;
                        }
                        else {						//第n位為1
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);		//此處do-while剛好使得迴圈至少執行1次
                    if (loTail != null) {		//根據0還是1決定賦值哪個
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

關於resize的最後那一部分:
在JDK1.7之前,都是直接再計算一次hash,然後放入新的雜湊陣列位置(index,bucket)。
但在JDK1.8中,程式碼得到了改進,看一下官方註釋:
Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.
大致意思就是說,當超過限制的時候會resize,然而又因為我們使用的是2次冪的擴充套件(指長度擴為原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。(n - 1位雜湊碼,變成n - 1位或 n位)
怎麼理解呢?例如我們從16擴充套件為32時,具體的變化如下所示:
HashMap原始碼閱讀
因此元素在重新計算hash之後,因為n變為2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index值就會發生這樣的變化:
HashMap原始碼閱讀
因此,**我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,如果是0,hash值沒變。如果是1,hash值變成“原hash+oldCap”。**可以看看下圖為16擴充為32的resize示意圖:
HashMap原始碼閱讀
那麼,為什麼 hash & n就是可以獲得第n位的值呢?
首先我們必須知道,n是一個2的冪次方數,它的二進位制i形式是00……1000……
易知,0 & x = 0,1 & x = x,而我們就是想要hash值的第n位的x值。
因此,hash & n,剛好就是取到了新的hash值的第n位的x值。
故得出結論: (最後的if,else判斷)
如果(e.hash & oldCap) == 0 則該節點在新表的下標位置與舊錶一致都為 j
如果 (e.hash & oldCap) == 1則該節點在新表的下標位置 j + oldCap

question5:JDK1.8和1.7的插入方式有什麼區別?

ans:JDK1.8之前採用的是頭插法,即新來的值會放在連結串列的頭部,而原有的值被順推到連結串列的後面,因為作者認為後來插入的值被查詢的可能性更大一點,這樣做能提高一點效率。而JDK1.8之後,採用尾插法,即新來的值會直接插入到連結串列的結尾。

question6:JDK1.8之前HashMap在多執行緒條件下可能出現死迴圈,為什麼?1.8如何解決?

ans:當兩個執行緒同時進行put操作,然後觸發了resize,此時就可能導致死迴圈。因為JDK1.8之前,連結串列的插入採用的是頭插法,這會改變連結串列中原有元素的順序,在多執行緒的情況下,連結串列可能會形成迴圈連結串列,進而導致死迴圈。JDK1.8的解決方式就是把連結串列的插入方式改用尾插法,保持了連結串列的原有元素順序,這樣即便重複的resize,也不會形成迴圈連結串列。雖然JDK1.8解決了多執行緒下可能導致的死迴圈問題,但HashMap依然會出現併發下的更新丟失等問題。對此沒有辦法,HashMap的使用場景就不應該是多執行緒條件下,多執行緒情況下還是使用ConcurrentHashMap。


其他一些常見問題:

1. 什麼時候會使用HashMap?他有什麼特點?
HashMap是基於Map介面的實現,用於儲存鍵值對,它可以接收null的鍵值,是非同步的,HashMap儲存著Entry(hash, key, value, next)物件。

2. 你知道HashMap的工作原理嗎?
通過hash的方法進行雜湊均勻分佈,通過put和get進行儲存和獲取物件。儲存物件時,我們將K/V傳給put方法時,它呼叫hashCode計算hash從而得到bucket位置,進一步儲存,HashMap會根據當前bucket的佔用情況自動調整容量(超過Load Facotr則resize為原來的2倍)。獲取物件時,我們將K傳給get,它呼叫hashCode計算hash從而得到bucket位置,並進一步呼叫equals()方法確定鍵值對。如果發生碰撞的時候,Hashmap通過連結串列將產生碰撞衝突的元素組織起來,在Java 8中,如果一個bucket中碰撞衝突的元素超過某個限制(預設是8),則使用紅黑樹來替換連結串列,從而提高速度。HashMap是非執行緒安全的,在多執行緒的操作下會存在異常情況,可以使用HashTable或者ConcurrentHashMap進行代替

3. 你知道get和put的原理嗎?equals()和hashCode()的都有什麼作用?
通過對key的hashCode()進行hashing,並計算下標( n-1 & hash),從而獲得buckets的位置。如果產生碰撞,則利用key.equals()方法去連結串列或樹中去查詢對應的節點

4. 你知道hash的實現嗎?為什麼要這樣實現?
在Java 1.8的實現中,是通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在bucket的n比較小的時候,也能保證考慮到高低bit都參與到hash的計算中,同時不會有太大的開銷。

5. 如果HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?
如果超過了負載因子(預設0.75),則會重新resize一個原來長度兩倍的HashMap,並且重新呼叫hash方法。

6. JDK1.8之前,HashMap在併發的情況下會出現問題,比如同時put的時候甚至會引起死迴圈,導致CPU使用率100%,為什麼?
因為JDK1.8之前的resize方法是需要rehash的,導致在舊連結串列遷移到新連結串列的時候,如果在新連結串列的陣列索引相同,會導致連結串列元素倒置,在JDK1.8中不需要rehash,直接根據新增的1bit是0還是1,決定是在原本位置還是增加capacity的位置,不會倒置。
而JDK1.8之前的transfer,以JDK1.7為例,當兩個執行緒同時resize的時候,由於連結串列倒置,有可能出現迴圈連結串列的情況,導致無限迴圈,耗盡CPU算力。具體看這裡:https://tech.meituan.com/2016/06/24/java-hashmap.html
HashMap是非執行緒安全的,在多執行緒的操作下會存在異常情況,比如類似於資料庫的更新丟失(兩個執行緒同時put,可能會導致其中一個put失效)。可以使用Hashtable或者ConcurrentHashMap進行代替。(Hashtable的效率太低,不推薦使用)
PS:回到本題的主幹:存放資料時發現正在擴容會怎麼樣。
對於JDK1.7,應該就是同時resize,導致死迴圈。對於JDK1.8,則不會出現死迴圈。(1.7是頭插法,導致會倒置,形成迴圈連結串列。而1.8增加了tail指標,使用尾插法,時間複雜度仍然是O(1),但不會倒置,因而不會出現死迴圈。)。1.8中hashmap的確不會因為多執行緒put導致死迴圈,但是依然有其他的弊端,比如資料丟失等等。因此多執行緒情況下還是建議使用concurrenthashmap。

7.為什麼重寫equals方法的同時還要重寫hashCode方法?用HashMap舉個例子。

ans:Object類裡的equals方法預設是呼叫“==”運算子。對於值物件,==就是比較兩個物件的值,而對於引用物件,就是比較兩個物件的記憶體地址。HashMap在發生雜湊碰撞的時候,會在index上形成連結串列。但是物件是否equals和hashCode的關係是:物件equals,hashCode一定相同。物件不equals,hashCode可能相同。反過來即hashCode相同,物件不一定equals。hashCode不相同,物件一定不equals。而HashMap要求我們使得:相同的物件返回相同的hash值,不同的物件返回不同的hash值。即重寫為:物件equals,hashCode一定相同。物件不equals,hashCode一定不相同。如果不重寫hashCode,當我們呼叫get或者put方法時,對於一個hashCode值,根本預設的法則,無法確定是對應哪個物件。這有可能導致,put的時候是一個hashCode值,而get的時候傳入物件,根據它的hashCode值,卻無法獲取到資料。而HashMap重寫到hashCode方法,是根據key和value值生成的,可以確保key-value唯一時,hashCode也唯一:

public final int hashCode() {
  return Objects.hashCode(key) ^ Objects.hashCode(value);
}

8.什麼情況下連結串列會轉換成紅黑樹,什麼情況下紅黑樹會退化成連結串列,為什麼?

ans:根據泊松分佈,在負載因子預設為0.75的時候,單個hash槽內元素為8的概率小於百萬分之一,所以將7設為一個分水嶺。等於7的時候不轉換,大於等於8的時候,連結串列轉換成紅黑樹。而當刪除元素導致紅黑樹的元素個數小於等於6時,退化回連結串列。

TODO

紅黑樹部分還是沒有處理,等後面學習完紅黑樹再回頭補吧。HashMap還有很多方法沒有研讀,這裡只重點看了幾個常用的方法,後面有時間的話也可以考慮一下!

參考網站:
[1] 深入理解HashMap(四): 關鍵原始碼逐行分析之resize擴容
[2] Java HashMap工作原理及實現
[3] HashMap原始碼分析(JDK 1.8)
[4] 求求你們不要再問HashMap原理了…
[5] HashMap原始碼註解 之 靜態工具方法hash()、tableSizeFor()(四)
[6] 併發的HashMap為什麼會引起死迴圈?

相關文章