1. 程式人生 > 實用技巧 >深入剖析HashMap

深入剖析HashMap

前言

很高興遇見你~

HashMap是一個非常重要的集合,日常使用也非常的頻繁,同時也是面試重點。本文並不打算講解基礎的使用api,而是深入HashMap的底層,講解關於HashMap的重點知識。需要讀者對散列表和HashMap有一定的認識。

HashMap本質上是一個散列表,那麼就離不開散列表的三大問題:雜湊函式、雜湊衝突、擴容方案;同時作為一個數據結構,必須考慮多執行緒併發訪問的問題,也就是執行緒安全。這四大重點則為學習HashMap的重點,也是HashMap設計的重點。

HashMap屬於Map集合體系的一部分,同時繼承了Serializable介面可以被序列化,繼承了Cloneable介面可以被複制。他的的繼承結構如下:

HashMap並不是全能的,對於一些特殊的情景下的需求官方拓展了一些其他的類來滿足,如執行緒安全的ConcurrentHashMap、記錄插入順序的LinkHashMap、給key排序的TreeMap等。

文章內容主要講解四大重點:雜湊函式、雜湊衝突、擴容方案、執行緒安全,再補充關鍵的原始碼分析和相關的問題。

本文所有內容如若未特殊說明,均為JDK1.8版本。

雜湊函式

雜湊函式的目標是計算key在陣列中的下標。判斷一個雜湊函式的標準是:雜湊是否均勻、計算是否簡單。

HashMap雜湊函式的步驟:

  1. 對key物件的hashcode進行擾動
  2. 通過取模求得陣列下標

擾動是為了讓hashcode的隨機性更高,第二步取模就不會讓所以的key都聚集在一起,提高雜湊均勻度。擾動可以看到hash()

方法:

static final int hash(Object key) {
    int h;
    // 獲取到key的hashcode,在高低位異或運算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

也就是低16位是和高16位進行異或,高16位保持不變。一般的陣列長度都會比較短,取模運算中只有低位參與雜湊;高位與地位進行異或,讓高位也得以參與雜湊運算,使得雜湊更加均勻。具體運算如下圖(圖中為了方便採用8位進行演示,32位同理):

對hashcode擾動之後需要對結果進行取模。HashMap在jdk1.8並不是簡單使用%

進行取模,而是採用了另外一種更加高效能的方法。HashMap控制陣列長度為2的整數次冪,好處是對hashcode進行求餘運算和讓hashcode與陣列長度-1進行位與運算是相同的效果。如下圖:

但位與運算的效率卻比求餘高得多,從而提升了效能。在擴容運算中也利用到了此特性,後面會講。取模運算的原始碼看到putVal()方法,該方法在put()方法中被呼叫:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    ...
	// 與陣列長度-1進行位與運算,得到下標
    if ((p = tab[i = (n - 1) & hash]) == null)
        ...
}

完整的hash計算過程可以參考下圖:

上面我們提到HashMap的陣列長度為2的整數次冪,那麼HashMap是如何控制陣列的長度為2的整數次冪的?修改陣列長度有兩種情況:

  1. 初始化時指定的長度
  2. 擴容時的長度增量

先看第一種情況。預設情況下,如未在HashMap構造器中指定長度,則初始長度為16。16是一個較為合適的經驗值,他是2的整數次冪,同時太小會頻繁觸發擴容、太大會浪費空間。如果指定一個非2的整數次冪,會自動轉化成大於該指定數的最小2的整數次冪。如指定6則轉化為8,指定11則轉化為16。結合原始碼來分析,當我們初始化指定一個非2的整數次冪長度時,HashMap會呼叫tableSizeFor()方法:

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    // 這裡呼叫了tableSizeFor方法
    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;
}

tableSizeFor()方法的看起來很複雜,作用是使得最高位1後續的所有位都變為1,最後再+1則得到剛好大於initialCapacity的最小2的整數次冪數。如下圖(這裡使用了8位進行模擬,32位也是同理):

那為什麼必須要對cap進行-1之後再進行運算呢?如果指定的數剛好是2的整數次冪,如果沒有-1結果會變成比他大兩倍的數,如下:

00100 --高位1之後全變1--> 00111 --加1---> 01000

第二種改變陣列長度的情況是擴容。HashMap每次擴容的大小都是原來的兩倍,控制了陣列大小一定是2的整數次冪,相關原始碼如下:

final Node<K,V>[] resize() {
    ...
    if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 設定為原來的兩倍
            newThr = oldThr << 1;
    ...
}

小結:

  1. HashMap通過高16位與低16位進行異或運算來讓高位參與雜湊,提高雜湊效果;
  2. HashMap控制陣列的長度為2的整數次冪來簡化取模運算,提高效能;
  3. HashMap通過控制初始化的陣列長度為2的整數次冪、擴容為原來的2倍來控制陣列長度一定為2的整數次冪。

雜湊衝突解決方案

再優秀的hash演算法永遠無法避免出現hash衝突。hash衝突指的是兩個不同的key經過hash計算之後得到的陣列下標是相同的。解決hash衝突的方式很多,如開放定址法、再雜湊法、公共溢位表法、鏈地址法。HashMap採用的是鏈地址法,jdk1.8之後還增加了紅黑樹的優化,如下圖:

出現衝突後會在當前節點形成連結串列,而當連結串列過長之後,會自動轉化成紅黑樹提高查詢效率。紅黑樹是一個查詢效率很高的資料結構,時間複雜度為O(logN),但紅黑樹只有在資料量較大時才能發揮它的優勢。關於紅黑樹的轉化,HashMap做了以下限制

  • 當連結串列的長度>=8且陣列長度>=64時,會把連結串列轉化成紅黑樹。
  • 當連結串列長度>=8,但陣列長度<64時,會優先進行擴容,而不是轉化成紅黑樹。
  • 當紅黑樹節點數<=6,自動轉化成連結串列。

那就有了以下問題:

  • 為什麼需要陣列長度到64才會轉化紅黑樹?

    當陣列長度較短時,如16,連結串列長度達到8已經是佔用了最大限度的50%,意味著負載已經快要達到上限,此時如果轉化成紅黑樹,之後的擴容又會再一次把紅黑樹拆分平均到新的陣列中,這樣非但沒有帶來效能的好處,反而會降低效能。所以在陣列長度低於64時,優先進行擴容。

  • 為什麼要大於等於8轉化為紅黑樹,而不是7或9?

    樹節點的比普通節點更大,在連結串列較短時紅黑樹並未能明顯體現效能優勢,反而會浪費空間,在連結串列較短是採用連結串列而不是紅黑樹。在理論數學計算中(裝載因子=0.75),連結串列的長度到達8的概率是百萬分之一;把7作為分水嶺,大於7轉化為紅黑樹,小於7轉化為連結串列。紅黑樹的出現是為了在某些極端的情況下,抗住大量的hash衝突,正常情況下使用連結串列是更加合適的。

注意,紅黑樹在jdk1.8之後出現的,jdk1.7採用的是陣列+連結串列模式。

小結:

  1. HashMap採用鏈地址法,當發生衝突時會轉化為連結串列,當連結串列過長會轉化為紅黑樹提高效率。
  2. HashMap對紅黑樹進行了限制,讓紅黑樹只有在極少數極端情況下進行抗壓。

擴容方案

當HashMap中的資料越來越多,那麼發生hash衝突的概率也就會越來越高,通過陣列擴容可以利用空間換時間,保持查詢效率在常數時間複雜度。那什麼時候進行擴容?由HashMap的一個關鍵引數控制:裝載因子

裝載因子=HashMap中節點數/陣列長度,他是一個比例值。當HashMap中節點數到達裝載因子這個比例時,就會觸發擴容;也就是說,裝載因子控制了當前陣列能夠承載的節點數的閾值。如陣列長度是16,裝載因子是0.75,那麼可容納的節點數是16*0.75=12。裝載因子的數值大小需要仔細權衡。裝載因子越大,陣列利用率越高,同時發生雜湊衝突的概率也就越高;裝載因子越小,陣列利用率降低,但發生雜湊衝突的概率也降低了。所以裝載因子的大小需要權衡空間與時間之間的關係。在理論計算中,0.75是一個比較合適的數值,大於0.75雜湊衝突的概率呈指數級別上升,而小於0.75衝突減少並不明顯。HashMap中的裝載因子的預設大小是0.75,沒有特殊要求的情況下,不建議修改他的值。

那麼在到達閾值之後,HashMap是如何進行擴容的呢?HashMap會把陣列長度擴充套件為原來的兩倍,再把舊陣列的資料遷移到新的陣列,而HashMap針對遷移做了優化:使用HashMap陣列長度是2的整數次冪的特點,以一種更高效率的方式完成資料遷移

JDK1.7之前的資料遷移比較簡單,就是遍歷所有的節點,把所有的節點依次通過hash函式計算新的下標,再插入到新陣列的連結串列中。這樣會有兩個缺點:1、每個節點都需要進行一次求餘計算;2、插入到新的陣列時候採用的是頭插入法,在多執行緒環境下會形成連結串列環。jdk1.8之後進行了優化,原因在於他控制陣列的長度始終是2的整數次冪,每次擴充套件陣列都是原來的2倍,帶來的好處是key在新的陣列的hash結果只有兩種:在原來的位置,或者在原來位置+原陣列長度。具體為什麼我們可以看下圖:

從圖中我們可以看到,在新陣列中的hash結果,僅僅取決於高一位的數值。如果高一位是0,那麼計算結果就是在原位置,而如果是1,則加上原陣列的長度即可。這樣我們只需要判斷一個節點的高一位是1 or 0就可以得到他在新陣列的位置,而不需要重複hash計算。HashMap把每個連結串列拆分成兩個連結串列,對應原位置或原位置+原陣列長度,再分別插入到新的陣列中,保留原來的節點順序,如下:

前面還遺留一個問題:頭插法會形成連結串列環。這個問題線上程安全部分講解。

小結:

  1. 裝載因子決定了HashMap擴容的閾值,需要權衡時間與空間,一般情況下保持0.75不作改動;
  2. HashMap擴容機制結合了陣列長度為2的整數次冪的特點,以一種更高的效率完成資料遷移,同時避免頭插法造成連結串列環。

執行緒安全

HashMap作為一個集合,主要功能則為CRUD,也就是增刪查改資料,那麼就肯定涉及到多執行緒併發訪問資料的情況。併發產生的問題,需要我們特別關注。

HashMap並不是執行緒安全的,在多執行緒的情況下無法保證資料的一致性。舉個例子:HashMap下標2的位置為null,執行緒A需要將節點X插入下標2的位置,在判斷是否為null之後,執行緒被掛起;此時執行緒B把新的節點Y插入到下標2的位置;恢復執行緒A,節點X會直接插入到下標2,覆蓋節點Y,導致資料丟失,如下圖:

jdk1.7及以前擴容時採用的是頭插法,這種方式插入速度快,但在多執行緒環境下會造成連結串列環,而連結串列環會在下一次插入時找不到連結串列尾而發生死迴圈。限於篇幅,關於這個問題可參考面試官:HashMap 為什麼執行緒不安全?,作者詳細解答了關於HashMap的併發問題。jdk1.8之後擴容採用了尾插法,解決了這個問題,但並沒有解決資料的一致性問題。

那如果結果資料一致性問題呢?解決這個問題有三個方案:

  • 採用Hashtable
  • 呼叫Collections.synchronizeMap()方法來讓HashMap具有多執行緒能力
  • 採用ConcurrentHashMap

前兩個方案的思路是相似的,均是每個方法中,對整個物件進行上鎖。Hashtable是老一代的集合框架,很多的設計均以及落後,他在每一個方法中均加上了synchronize關鍵字保證執行緒安全

// Hashtable
public synchronized V get(Object key) {...}
public synchronized V put(K key, V value) {...}
public synchronized V remove(Object key) {...}
public synchronized V replace(K key, V value) {...}
...

第二種方法是返回一個SynchronizedMap物件,這個物件預設每個方法會鎖住整個物件。如下原始碼:

這裡的mutex是什麼呢?直接看到構造器:

final Object      mutex;        // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
    this.m = Objects.requireNonNull(m);
    // 預設為本物件
    mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
    this.m = m;
    this.mutex = mutex;
}

可以看到預設鎖的就是本身,效果和Hashtable其實是一樣的。這種簡單粗暴鎖整個物件的方式造成的後果是:

  • 鎖是非常重量級的,會嚴重影響效能。
  • 同一時間只能有一個執行緒進行讀寫,限制了併發效率。

ConcurrentHashMap的設計就是為了解決此問題。他通過降低鎖粒度+CAS的方式來提高效率。簡單來說,ConcurrentHashMap鎖的並不是整個物件,而是一個陣列的一個節點,那麼其他執行緒訪問陣列其他節點是不會互相影響,極大提高了併發效率;同時ConcurrentHashMap讀操作並不需要獲取鎖,如下圖:

關於ConcurrentHashMap和Hashtable的更多內容,限於篇幅,我會在另一篇文章講解。

那麼,使用了上述的三種解決方案是不是絕對執行緒安全?先觀察下面的程式碼:

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("abc","123");

Thread1:
if (map.containsKey("abc")){
    String s = map.get("abc");
}

Thread2:
map.remove("abc");

當Thread1呼叫containsKey之後釋放鎖,Thread2獲得鎖並把“abc”移除再釋放鎖,這個時候Thread1讀取到的s就是一個null了,也就出現了問題了。所以ConcurrentHashMap類或者Collections.synchronizeMap()方法或者Hashtable都只能在一定的限度上保證執行緒安全,而無法保證絕對執行緒安全。

關於執行緒安全,還有一個fast-fail問題,即快速失敗。當使用HashMap的迭代器遍歷HashMap時,如果此時HashMap發生了結構性改變,如插入新資料、移除資料、擴容等,那麼Iteractor會丟擲fast-fail異常,防止出現併發異常,在一定限度上保證了執行緒安全。如下原始碼:

final Node<K,V> nextNode() {
    ...
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
   ...
}

建立Iteractor物件時會記錄HashMap的modCount變數,每當HashMap發生結構性改變時,modCount會加1。在迭代時判斷HashMap的modCount和自己儲存的expectedModCount是否一致即可判斷是否發生了結構性改變。

fast-fail異常只能當做遍歷時的一種安全保證,而不能當做多執行緒併發訪問HashMap的手段。若有併發需求,還是需要使用上述的三種方法。

小結

  1. HashMap並不能保證執行緒安全,在多執行緒併發訪問下會出現意想不到的問題,如資料丟失等
  2. HashMap1.8採用尾插法進行擴容,防止出現連結串列環導致的死迴圈問題
  3. 解決併發問題的的方案有HashtableCollections.synchronizeMap()ConcurrentHashMap。其中最佳解決方案是ConcurrentHashMap
  4. 上述解決方案並不能完全保證執行緒安全
  5. 快速失敗是HashMap迭代機制中的一種併發安全保證

原始碼解析

關鍵變數的理解

HashMap原始碼中有很多的內部變數,這些變數會在下面原始碼分析中經常出現,首先需要理解這些變數的意義。

// 存放資料的陣列
transient Node<K,V>[] table;
// 儲存的鍵值對數目
transient int size;
// HashMap結構修改的次數,主要用於判斷fast-fail
transient int modCount;
// 最大限度儲存鍵值對的數目(threshodl=table.length*loadFactor),也稱為閾值
int threshold;
// 裝載因子,表示可最大容納資料數量的比例
final float loadFactor;
// 靜態內部類,HashMap儲存的節點型別;可儲存鍵值對,本身是個連結串列結構。
static class Node<K,V> implements Map.Entry<K,V> {...}

擴容

HashMap原始碼中把初始化操作也放到了擴容方法中,因而擴容方法原始碼主要分為兩部分:確定新的陣列大小、遷移資料。詳細的原始碼分析如下,我打了非常詳細的註釋,可選擇檢視。擴容的步驟在上述已經講過了,讀者可以自行結合原始碼,分析HashMap是如何實現上述的設計。

final Node<K,V>[] resize() {
    // 變數分別是原陣列、原陣列大小、原閾值;新陣列大小、新閾值
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // 如果原陣列長度大於0
    if (oldCap > 0) {
        // 如果已經超過了設定的最大長度(1<<30,也就是最大整型正數)
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 直接把閾值設定為最大正數
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 設定為原來的兩倍
            newThr = oldThr << 1; 
    }
    
    // 原陣列長度為0,但最大限度不是0,把長度設定為閾值
    // 對應的情況就是新建HashMap的時候指定了陣列長度
    else if (oldThr > 0) 
        newCap = oldThr;
    // 第一次初始化,預設16和0.75
    // 對應使用預設構造器新建HashMap物件
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果原陣列長度小於16或者翻倍之後超過了最大限制長度,則重新計算閾值
    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);
                // 新的位置只有兩種可能:原位置,原位置+老陣列長度
                // 把原連結串列拆成兩個連結串列,然後再分別插入到新陣列的兩個位置上
                // 不用多次呼叫put方法
                else { 
                    // 分別是原位置不變的連結串列和原位置+原陣列長度位置的連結串列
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍歷老連結串列,判斷新增判定位是1or0進行分類
                    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()方法新增鍵值對,最終會呼叫putVal()來真正實現新增邏輯。程式碼解析如下:

public V put(K key, V value) {
    // 獲取hash值,再呼叫putVal方法插入資料
    return putVal(hash(key), key, value, false, true);
}

// onlyIfAbsent表示是否覆蓋舊值,true表示不覆蓋,false表示覆蓋,預設為false
// evict和LinkHashMap的回撥方法有關,不在本文討論範圍
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    
    // tab是HashMap內部陣列,n是陣列的長度,i是要插入的下標,p是該下標對應的節點
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    // 判斷陣列是否是null或者是否是空,若是,則呼叫resize()方法進行擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 使用位與運算代替取模得到下標
    // 判斷當前下標是否是null,若是則建立節點直接插入,若不是,進入下面else邏輯
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        
        // e表示和當前key相同的節點,若不存在該節點則為null
        // k是當前陣列下標節點的key
        Node<K,V> e; K k;
        
        // 判斷當前節點與要插入的key是否相同,是則表示找到了已經存在的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時轉化為紅黑樹
                    // 注意,treeifyBin方法中會進行陣列長度判斷,
                    // 若小於64,則優先進行陣列擴容而不是轉化為樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        treeifyBin(tab, hash);
                    break;
                }
                // 找到相同的直接跳出迴圈
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 如果找到相同的key節點,則判斷onlyIfAbsent和舊值是否為null
        // 執行更新或者不操作,最後返回舊值
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    // 如果不是更新舊值,說明HashMap中鍵值對數量發生變化
    // modCount數值+1表示結構改變
    ++modCount;
    // 判斷長度是否達到最大限度,如果是則進行擴容
    if (++size > threshold)
        resize();
    // 最後返回null(afterNodeInsertion是LinkHashMap的回撥)
    afterNodeInsertion(evict);
    return null;
}

程式碼中關於每個步驟有了詳細的解釋,這裡來總結一下:

  1. 總體上分為兩種情況:找到相同的key和找不到相同的key。找了需要判斷是否更新並返回舊value,沒找到需要插入新的Node、更新節點數並判斷是否需要擴容。
  2. 查詢分為三種情況:陣列、連結串列、紅黑樹。陣列下標i位置不為空且不等於key,那麼就需要判斷是否樹節點還是連結串列節點並進行查詢。
  3. 連結串列到達一定長度後需要擴充套件為紅黑樹,當且僅當連結串列長度>=8且陣列長度>=64。

最後畫一張圖總體再加深一下整個流程的印象:

其他問題

為什麼jdk1.7以前控制陣列的長度為素數,而jdk1.8之後卻採用的是2的整數次冪?

答:素數長度可以有效減少雜湊衝突;JDK1.8之後採用2的整數次冪是為了提高求餘和擴容的效率,同時結合高低位異或的方法使得雜湊雜湊更加均勻。

為何素數可以減少雜湊衝突?若能保證key的hashcode在每個數字之間都是均勻分佈,那麼無論是素數還是合數都是相同的效果。例如hashcode在1~20均勻分佈,那麼無論長度是合數4,還是素數5,分佈都是均勻的。而如果hashcode之間的間隔都是2,如1,3,5...,那麼長度為4的陣列,位置2和位置4兩個下標無法放入資料,而長度為5的陣列則沒有這個問題。長度為合數的陣列會使間隔為其因子的hashcode聚集出現,從而使得雜湊效果降低。詳細的內容可以參考這篇部落格:演算法分析:雜湊表的大小為何是素數,這篇部落格採用資料分析證實為什麼素數可以更好地實現雜湊。

為什麼插入HashMap的資料需要實現hashcode和equals方法?對這兩個方法有什麼要求?

答:通過hashcode來確定插入下標,通過equals比較來尋找資料;兩個相等的key的hashcode必須相等,但擁有相同的hashcode的物件不一定相等。

這裡需要區分好他們之間的區別:hashcode就像一個人的名,相同的人名字肯定相等,但是相同的名字不一定是同個人;equals比較內容是否相同,一般由物件覆蓋重寫,預設情況下比較的是引用地址;“==”引用隊形比較的是引用地址是否相同,值物件比較的是值是否相同。

HashMap中需要使用hashcode來獲取key的下標,如果兩個相同的物件的hashcode不同,那麼會造成HashMap中存在相同的key;所以equals返回相同的key他們的hashcode一定要相同。HashMap比較兩個元素是否相同採用了三種比較方法結合:p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))) 。關於更加深入的講解可以參考這篇文章:Java提高篇——equals()與hashCode()方法詳解,作者非常詳細地剖析了這些方法之間的區別。

最後

關於HashMap的內容很難在一篇文章講完,他的設計到的內容非常多,如執行緒安全的設計可以延伸到ConcurrentHashMap與Hashtable,這兩個類與HashMap的區別以及內部設計均非常重要,這些內容我將在另外的文章做補充。

最後,希望文章對你有幫助。

全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
筆者才疏學淺,有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信交流。

另外歡迎光臨筆者的個人部落格:傳送門