HashMap 可以算是 Java 中最常用的幾個集合類之一。這一篇文章將在代碼層面上詳細解釋 HashMap 的工作原理及實現方式。為了能夠更清晰的說明問題,這篇文章并不會按照源碼中的順序進行講解,而是按照“添加-->查找-->修改-->刪除”的順序進行,并且也不會講解所有源碼,只會涉及其中的主要部分。
在閱讀這篇文章之前,你也可以先閱讀《散列表的基本原理》,該文章從理論層面介紹了散列表的基本原理。
1. 類結構
可以通過大綱視圖(Outline),先大致了解一下在 JDK 1.7 中 HashMap 大致有哪些內容。
從圖 1 中可以看出,HashMap 中有 9 個內部類,其中類型 Entry<K, V> 就是容納元素以及其對應鍵的對象類型,其類型結構如圖 2 所示。而其他的幾個類型,除 Holder 外,其他的幾個類型都是用于遍歷元素的容器以及其對應的迭代器,并不是這篇文章討論的重點。
Entry 有 4 個成員,value 是 HashMap 的元素;key 是 value 所對應的鍵;hash 用于保存 key 的散列值,這樣在使用的時候,就不需要反復計算 key 的散列值;next 指向那些與此記錄發生散列沖突的其他記錄。圖 3 將展示 HashMap 的成員。
HashMap 有 7 個成員(非靜態成員),entrySet 用于提供方法 entrySet() 返回值的緩存;hashSeed 是當前 HashMap 對象的散列值計算種子;loadFactor 是加載因子;modCount 是 HashMap 的變化標記數,為相關迭代器的快速失敗 [1] 提供依據;size 是記錄當前 HashMap 中元素的數量;table 就是 HashMap 中的“散列表的表”;threshold 是擴容閾值,當元素數量大于此值時可能發生擴容。
這一節大致介紹了 HashMap 的類型結構,至于它們如何相互協作,將在接下來的小節中詳細討論。在開始接下來的章節之前,先看一下 HashMap 的構造方法。
圖 4 中是開發過程中最常用的 HashMap 無參構造方法,它只做了一件事情,就是將常量 DEFAULT_INITIAL_CAPACITY 和常量 DEFAULT_LOAD_FACTOR 作為參數調用了自身的另一個構造方法 HashMap(int, float)。常量 DEFAULT_INITIAL_CAPACITY 是默認表長,值為 16;常量 DEFAULT_LOAD_FACTOR 是默認加載因子,值為 0.75。如圖 5。
接下來我們看構造方法 HashMap(int, float)。如圖 6。
HashMap(int, float) 就做了許多事情。首先,檢查參數 initialCapacity 是否小于 0 ,如果小于 0 就拋出異常。initialCapacity 是 HashMap 初始表長, 自然不能小于 0 。然后檢查參數 initialCapacity 是否大于常量 MAXIMUM_CAPACITY,如果大于 MAXIMUM_CAPACITY,就將其等于 MAXIMUM_CAPACITY。MAXIMUM_CAPACITY 是 HashMap 的最大表長,值為 2 ^ 30。如圖 7。
再然后檢查 loadFactor 是否是小于等于 0 或為 NaN [2],如果是,將拋出異常。接下來就將 HashMap 的成員 loadFactor 賦值為參數 loadFactor,將成員 threshold 賦值為 initialCapacity。最后調用方法 init()。init() 是一個空方法,什么也不做,那么問題就來了,HashMap 有 7 個非靜態成員,為什么這里只處理了 2 個?在解答這個問題之前,先看一下 HashMap 成員的初始值,如圖 8 。
可以看到,幾個 int 類型的成員要么寫了初始值為 0,要么沒有寫初始值,那么這些成員初始都為 0 。table 被賦值為常量 EMPTY_TABLE,在下面的小節中會說明 EMPTY_TABLE 是什么。entrySet 初始值為 null。至此,HashMap 的 7 個非靜態成員在構造時的初始值就明確了。
2. 添加元素
從這一節開始,我們將一步一步的分析并理解 HashMap 的實現。首先分析 HashMap 添加元素的方法 put(K, V)。
圖 9 中方法 put(K, V) 的我們再熟悉不過了,就不再介紹兩個參數,直接從方法體開始。方法先檢查當前對象的 table 是否與 EMPTY_TABLE 為同一個對象,那么 EMPTY_TABLE 是什么呢?它長圖 10 這樣。
在上一節中,介紹了 HashMap 構造時,會指定 table 的初始值為 EMPTY_TABLE 而不是創建一個空數組,原因就是如果每次 HashMap 創建了一個新對象但是不使用的話,那么這個 table 的數組仍然會被白白的創建出來。但是,如果?HashMap 在創建一個新對象的時候,并不創建這個數組,而是指向一個全局的、不使用的常量,那么即使創建了許多不使用的 HashMap 對象,也是不會白白創建許多用不到的數組來浪費內存。現在回到方法?put(K,V) 中。
方法 put(K,V) 中第一個表達式如果為真,就將 HashMap 的成員 threshold 作為參數調用方法 inflateTable(int)。inflateTable(int) 方法如圖 12 。
inflateTable(int) 方法首先將 toSize 作為參數調用方法 roundUpToPowerOf2(int)? [3],并將得到的結果賦值給 capacity ?[4]。然后,取 capacity * loadFactor 和 MAXIMUM_CAPACITY + 1 中較小的值作為新的擴容閾值,目的在于防止加載因子過大而使得 HashMap 無法擴容。接下來,HashMap 才真正創建 table,其長度為剛剛得到的 capacity。最后,調用 initHashSeedAsNeeded(int) [5] 獲取一個散列種子。
現在又回到方法 put(K, V) 中,繼續往下看。
圖 13 的是方法 put(K, V) 中的第二個條件表達式,檢查 key 是否為 null,如果為 null,把 value 作為參數調用方法 putForNullKey(K),并將其返回的結果作為自身的結果直接返回。方法 putForNullKey(K) 如圖 14。
方法 putForNullKey(K) 中的 for 語句做了這么一件事情,它將檢查 table[0] 中的所有記錄,如果有某個記錄的 key 也是 null,將用參數 value 替換原來記錄的 value,然后調用被替換 value 的記錄的方法 recordAccess(HashMap) [6] 后,最后將原來的 value 值返回。從這里可以看出,HashMap 總是會將 key 為 null 的記錄放置于 table[0] 的位置。
如果 for 語句執行完后,此方法沒有被返回,說明在這個 HashMap 中并不存在 key 為 null 的記錄,因此需要新建一條記錄。在使 modCount 自增 1 后,將 0、null、value 和 0 作為參數調用方法 addEntry(int, K, V, int) 以添加一個新的記錄。addEntry(int, K, V, int) 如圖 15。
方法 addEntry(int, K, V, int) 首先將檢查當前 HashMap 中的元素數量是否超過擴容閾值并且 table 指定的位置上是否發生散列沖突,如果是這樣,HashMap 將進行擴容。擴容在第 5 節詳細討論。不論是否發生了擴容,都會將 hash、key、value、bucketIndex 作為參數調用方法 createEntry(int, K, V, int) 以創建一個新的記錄。方法 createEntry(int, K, V, int) 如圖 16。
方法 createEntry(int, K, V, int) 做的事情就比較簡單了,它先取出當前 table[buketIndex] 中的記錄,如果原來沒有記錄,這里就得到 null。然后將 hash、key、value 和這個取出的記錄作為參數,構造一個新的記錄 [7],并將這個新紀錄放置于 table[buketIndex] 中。最后把 size 自增 1 以表示添加了一個元素。方法 createEntry(int, K, V, int) 執行完成后,key 為 null 的添加流程就結束了。接下來,回到方法 put(K, V) 繼續分析 key 不為 null 時的添加流程。
如果方法 put(K, V) 在第二個分支語句中并沒有被返回,則表示添加的元素的鍵并不是 null,那么將把 key 作為參數調用方法 hash(Object) 得到這個 key 的散列值。
方法 hash(Object) 如圖 18 。
方法 hash(Object) 首先將檢查參數 k 是否是一個 String 類型對象,如果是,將調用方法 stringHash32(String) [8] 以計算這個 String 對象的散列值。如果不是,先調用參數 k 的方法 hashCode() 獲取原始散列值,將這個原始散列值與自身的散列種子做異或運算得到一個粗加工的散列值,然后分段折疊這個粗加工的散列值,最終得到一個新散列值。
這里的算法將異或運算視作二進制串的“特征合并”,因為 HashMap 在根據散列值得到 table 對應索引的算法中,只會使用與 table 長度對應的二進制串所對等的位,那么當 table 長度較短時,并不會使用到二進制串的高位,所以將二進制串的高位的“特征”通過異或運算“合并”到二進制串的低位中,可以減少因高位不同低位相同的二進制串導致的散列沖突。繼續回到方法 put(K, V) 中。
在得到了 key 的新散列值之后,將得到的散列值和 table 的長度作為參數調用方法 indexFor(int, int) 。方法 indexFor(int, int) 只有一句話,如圖 20。
方法 put(K, V) 接下來的流程,與方法 putForNullKey(K) 圖 14 類似,區別僅在于 key 不再是一個 null 值,因此在判斷相等時,先檢查兩個 key 的散列值是否相等,然后檢查兩個 key 是否是同一個對象,再調用 key 的方法 equals(Object) 進行比較。
至此,HashMap 的添加流程就已經結束。
3. 查找元素
這一節中,將分析查找元素的方法 get(Object) [9]。
方法 get(Object) 首先檢查 key 是否為 null,如果為 null ,就執行方法 getForNullKey(),并將其返回值作為自身方法的返回值返回。方法 getForNullKey() 如圖 23。
還記得添加元素時對 key 為 null 的元素的操作嗎?HashMap 總是將 key 為 null 的元素放置于 table[0] 的位置,那么方法 getForNullKey() 也會在 table[0] 的位置中去找對應的元素。首先,檢查當前 HashMap 的元素數量是否為 0 ,如果為 0 ,那么表示沒有任何元素,直接返回 null。否則,檢查 table[0] 中鏈式結構的所有記錄,如果有某個記錄的 key 為 null,就將其 value 值返回。如果循環結束后仍然沒有找到對應的記錄,返回 null。
方法 getForNullKey() 執行完后,key 為 null 的查找流程就結束了。接下來,回到方法 get(Object) 中,繼續分析 key 不為 null 時的查找流程。
如果方法 get(Object) 沒有在第一個分支語句中被返回,接下來它將把 key 作為參數執行方法 getEntry(Object)。如果方法 getEntry(Object) 得到了一個記錄,表示查找成功,返回這個記錄的 value;如果得到 null,表示查找不到指定的元素,返回 null。方法 getEntry(Object) 如圖24。
方法 getEntry(Object) 同樣會先檢查當前 HashMap 的元素數量是否為 0 ,如果為 0 ,那么表示沒有任何元素,直接返回 null。否則,先把 key 作為參數調用方法 hash(K),以得到一個新的散列值。接下來的 for 語句中,根據得到的新散列值和 table 的長度獲取這個新散列值對應于 table 的索引,然后一次遍歷這個位置上的鏈式結構中的每一個記錄,進行與添加流程類似的 key 匹配檢查,如果找到匹配的記錄,返回這個記錄,否則返回 null。
4. 刪除元素
這一節中,將分析刪除元素的方法 remove(Object)。
方法 remove(Object) 比較簡單,它先將 key 作為參數調用方法 removeEntryForKey(Object),如果方法 removeEntryForKey(Object) 移除了一個記錄,將把移除的記錄作為返回值返回,如果返回 null,表示沒有記錄被移除。方法 removeEntryForKey(Object) 如圖 26。
方法 removeEntryForKey(Object) 雖然很長,但是如果理解了添加、查找流程,理解刪除流程也不困難。方法 removeEntryForKey(Object) 仍然會先檢查當前 HashMap 的元素數量是否為 0,如果為 0 ,那么表示沒有任何元素,直接返回 null。如果元素數量不為 0,和其他流程類似的,先通過方法 hash(Object) 得到 key 的新散列值,然后調用方法 indexFor(int, int) 得到這個新散列值對應 table 中的位置。接下來的 while 語句實際上和添加、查找流程中的記錄查找類似,不同的地方是,如果找到的對應的記錄,先將 modCount 自增,然后使 size 自減,再檢查這個移除的記錄是否是其所在鏈式結構的第一個節點,如果不是,將被移除的節點的上一節點的 next 指向被移除的節點的下一節點,如果是,則將 table 中對應位置指向移除節點的下一節點。最后再調用被移除的記錄的方法 recordRemove(HashMap) [10] ,最后返回被移除的記錄。
5. 擴容
現在回到添加流程中,看看 HashMap 如何在添加的過程中進行擴容的。在方法 addEntry(int, K, V, int) 中有如圖 27 的一段代碼。
這段代碼的邏輯是檢查當前 HashMap 中的元素數量是否超過擴容閾值并且在 table 指定的位置上發生了散列沖突,如果是這樣,HashMap 將進行擴容。
如果進行擴容,將把當前 table 的長度乘 2 后作為參數調用方法 resize(int) 進行擴容,擴容完成后,由于 table 的長度變化了,所以需要重新計算散列值和 table 對應的索引后才能繼續添加流程。方法 resize(int) 如圖 28。
方法 resize(int) 首先檢查當前 table 的長度是否已經到達 HashMap 的最大 table 長度,如果是,則將擴容閾值設置為整數最大值后直接返回,這樣將不再觸發擴容。如果不是,接下來將創建一個新的數組,長度為參數 newCapacity 指定,將這個值作為參數調用方法 transfer(Entry[], boolean) 以擴容,完成之后將這個新的數組作為 HashMap 的 table,并計算出新的擴容閾值。方法 transfer(Entry[], boolean) 如圖 29。
方法 transfer(Entry[], boolean) 遍歷當前 table 中的所有記錄,根據每一個記錄的散列值和新的 table 長度計算出對應于新的數組位置后,將記錄按照添加流程中的方式添加到新的數組中,完成擴容。
讀完這篇文章,你應該基本明白了 HashMap 的基本原理以及實現方式,甚至可以自己實現一個。
[1] 迭代器的快速失敗是指:容器在每次元素結構發生變化(添加或刪除)時,都會使自身的一個標記值發生改變,這個標記值在創建迭代器的時候,會被復制到迭代器中,迭代器每次迭代時,都會檢查自身的這個標記值和被迭代的容器中的標記值是否一致,如果不一致,將拋出異常阻止迭代的繼續。這種方式能夠在一定程度上阻止由于迭代過程中,被迭代的容器結構發生變化而使的迭代器行為不可控。
[2] Java 中的 float 和 double 都是浮點型,有表示為 NaN 的格式。具體參見《浮點型數據的格式》。
[3] 方法 roundUpToPowerOf2(int) 將返回一個比參數大的且一定是 2 的 N 次冪的整數。這個方法不在這篇文章的討論范圍內。
[4] 這里就是為什么 HashMap 的 table 的長度總是 2 的 N 次冪的原因:capacity 得到值是方法 roundUpToPowerOf2(int) 返回的,并且第一次是使用 threshold 作為參數,threshold 默認為 16。所以第一次得到結果就是 16。
[5] 方法initHashSeedAsNeeded(int) 將根據當前的 hashSeed 和指定的 capacity 計算是否需要獲取下一個隨機的散列值種子。這個方法不在這篇文章的討論范圍內。
[6] 方法 recordAccess(HashMap) 是 Entry 中的方法,默認是一個空方法。這個方法不在這篇文章的討論范圍內。
[7]?Entry 的構造方法直接將 4 個參數直接賦值到自身對應的成員,并沒有做其他處理。
[8] 方法 sun.misc.Hashing.stringHash32(String) 將獲取一個 String 的散列值, JDK 1.8 的 HashMap 中不再使用。這個方法不在這篇文章的討論范圍內。
[9]?方法 get(Object) 的參數類型為什么不是 K 而是 Object?是為了在使用過程中不需要進行類型轉換,并且某些不同類型的等價對象也可以進行鍵的匹配。
[10] 方法 recordRemove(HashMap) 是 Entry 中的方法,默認是一個空方法。這個方法不在這篇文章的討論范圍內。