概要
HashMap 最早出現在 JDK 1.2 中,底層基於散列算法實現。HashMap 允許 null 鍵和 null 值,在計算哈鍵的哈希值時,null 鍵哈希值為 0。HashMap 並不保證鍵值對的順序,這意味着在進行某些操作后,鍵值對的順序可能會發生變化。另外,需要注意的是,HashMap 是非線程安全類,在多線程環境下可能會存在問題。
HashMap 底層是基於散列算法實現,散列算法分為散列再探測和拉鏈式。HashMap 則使用了拉鏈式的散列算法,並在 JDK 1.8 中引入了紅黑樹優化過長的鏈表。數據結構示意圖如下:
對於拉鏈式的散列算法,其數據結構是由數組和鏈表(或樹形結構)組成。在進行增刪查等操作時,首先要定位到元素的所在桶的位置,之后再從鏈表中定位該元素。比如我們要查詢上圖結構中是否包含元素 35
,步驟如下:
-
定位元素
35
所處桶的位置,index = 35 % 16 = 3
-
在
3
號桶所指向的鏈表中繼續查找,發現35在鏈表中。
上面就是 HashMap 底層數據結構的原理,HashMap 基本操作就是對拉鏈式散列算法基本操作的一層包裝。不同的地方在於 JDK 1.8 中引入了紅黑樹,底層數據結構由數組+鏈表
變為了數組+鏈表+紅黑樹
,不過本質並未變。
JDK版本 | 實現方式 | 節點數>=8 | 節點數<=6 |
---|---|---|---|
1.8以前 | 數組+單向鏈表 | 數組+單向鏈表 | 數組+單向鏈表 |
1.8以后 | 數組+單向鏈表+紅黑樹 | 數組+紅黑樹 | 數組+單向鏈表 |
源碼分析
下面開始分析 HashMap 源碼實現。
1. Node 節點對象
在分析具體代碼前,先看 Node 節點對象,這是 HashMap 里面的一個內部類,也是 HashMap 的數據存儲對象。具體源碼如下:
static class Node<K,V> implements Map.Entry<K,V> { // hash 值 final int hash; final K key; V value; // 指向下一個節點 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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } // 返回:key的hashCode值和value的hashCode值進行異或運算結果 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 判斷相等的依據是,要么是同一個 Node 對象,要么是Map.Entry的一個實例,並且鍵鍵、值值都相等就返回True public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
該類繼承自 Map.Entry<K, V>,每一個 Entry 就是一個鍵值對。該類還定一個 next 節點,用於指向下一個節點,也是單鏈的構成基礎。
2. HashMap 繼承關系
下面將直接進入源碼分析,首先來看 HashMap 的繼承關系:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
繼承自 AbstractMap,同時也實現了 Map, Cloneable, Serializable 三個接口。也說明了 HashMap 是可序列化,可克隆的的。Map 接口則是定義了一些常用的增刪改查的方法,這樣只要是實現該接口的都有相同的方法,方便大家記憶和使用。
2.1 Cloneable 接口
Java 中一個類要實現 clone 功能 必須實現 Cloneable 接口,否則在調用 clone() 時會報 CloneNotSupportedException 異常,也就是說, Cloneable 接口只是個合法調用 clone() 的標識(marker-interface):
// Object protected Object clone() throws CloneNotSupportedException { if (!(this instanceof Cloneable)) { throw new CloneNotSupportedException("Class " + getClass().getName() + " doesn't implement Cloneable"); } return internalClone(); }
Object 類的 internalClone() 方法是一個 native 方法,native 方法的效率一般來說都是遠高於 Java 中的非 native 方法。這也解釋了為什么要用 Object 中 clone() 方法而不是先 new 一個對象,然后把原始對象中的信息賦到新對象中,雖然這也實現了 clone 功能,但效率較低。
Object 類中的 clone() 方法還是一個 protected 屬性的方法。為了讓其它類能調用這個 clone() 方法,重載之后要把 clone() 方法的屬性設置為public。
3. 變量定義
// 這兩個是限定值 當節點數大於 8 時會轉為紅黑樹存儲 static final int TREEIFY_THRESHOLD = 8; // 當節點數小於6 時會轉為單向鏈表存儲 static final int UNTREEIFY_THRESHOLD = 6; // 紅黑樹最小長度為 64 static final int MIN_TREEIFY_CAPACITY = 64; // HashMap容量初始大小 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 // HashMap容量極限 static final int MAXIMUM_CAPACITY = 1 << 30; // 負載因子默認大小 static final float DEFAULT_LOAD_FACTOR = 0.75f; // Node是 Map.Entry接口的實現類 // 在此存儲數據的 Node 數組容量是2次冪 // 每一個 Node 本質都是一個單向鏈表 transient Node<K,V>[] table; // HashMap 大小,它代表 HashMap 保存的鍵值對的多少 transient int size; // HashMap 被改變的次數 transient int modCount; // 下一次HashMap擴容的大小 int threshold; // 存儲負載因子的常量 final float loadFactor;
上面定義了當中會用到的一些變量,熟悉了就好了。
3.1 transient
在這里細心的小伙伴會發現桶數組 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變量不會被默認的序列化機制序列化。
考慮一個問題:桶數組 table 是 HashMap 底層重要的數據結構,不序列化的話,別人還怎么還原呢?
HashMap 並沒有使用默認的序列化機制,而是通過實現 readObject/writeObject
兩個方法自定義了序列化的內容。HashMap 中存儲的內容是鍵值對,
只要把鍵值對序列化了,就可以根據鍵值對數據重建 HashMap。
也有的人可能會想,序列化 table 不是可以一步到位,后面直接還原不就行了嗎?但序列化 table 存在着兩個問題:
- table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間
- 同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。
以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的 get/put/remove
等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終調用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實現,產生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平台下可能會產生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。
3.2 loadFactor 負載因子
loadFactor 指的是負載因子 HashMap 能夠承受住自身負載(大小或容量)的因子,loadFactor 的默認值為 0.75 認情況下,數組大小為 16,那么當 HashMap 中元素個數超過 16*0.75=12 的時候,就把數組的大小擴展為 2*16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知 HashMap 中元素的個數,那么預設元素的個數能夠有效的提高 HashMap 的性能
負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用鏈表法的散列表來說,查找一個元素的平均時間是 O(1+a),因此如果負載因子越大,對空間的利用更充分,然而后果是查找效率的降低;如果負載因子太小,那么散列表的數據將過於稀疏,對空間造成嚴重浪費
4. 構造函數
// 1 默認的構造函數 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 2 傳入一個Map集合,將Map集合中元素Map.Entry全部添加進HashMap實例中 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } // 3 指定容量大小 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 4 指定容量大小和負載因子大小 public HashMap(int initialCapacity, float loadFactor) { //指定的容量大小不可以小於0,否則將拋出IllegalArgumentException異常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 判定指定的容量大小是否大於HashMap的容量極限 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // 指定的負載因子不可以小於0或為Null,若判定成立則拋出IllegalArgumentException異常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; // 設置“HashMap閾值”,當HashMap中存儲數據的數量達到threshold時,就需要將HashMap的容量加倍。 this.threshold = tableSizeFor(initialCapacity); }
可以根據具體場景和需求,使用不同的構造函數。一般對於第1個構造函數,大家用的比較多。不過從構造函數可以看出來的一點是,負載因子 loadFactor 是一個非常重要的參數,默認值是 DEFAULT_LOAD_FACTOR = 0.75f 。當負載因子確定后,會根據負載因子的值給 HashMap 計算一個閾值 threshold ;一旦超過閾值就會調用 resize () 方法擴容。
5. tableSizeFor 計算閾值
先看看閾值的計算方法,需要指出的一點是:HashMap 要求容量必須是 2 的冪 。閾值具體計算方式如下:
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()
的主要功能是返回一個比給定整數大且最接近的 2 的冪次方整數。如給定 10,返回 2 的 4 次方 16.
下面分析這個算法:
首先,要注意的是這個操作是無符號右移后,再或上原來的值。
為什么要對 cap 做減 1 操作:int n = cap - 1 ?這是為了防止,cap 已經是 2 的冪。如果 cap 已經是 2 的冪, 又沒有執行這個減 1 操作,則執行完后面的幾條無符號右移操作之后,返回的 capacity 將是這個 cap 的 2 倍。如果不懂,要看完后面的幾個無符號右移之后再回來看看。
下面看看這幾個無符號右移操作:
如果 n 這時為 0 了(經過了 cap-1 之后),則經過后面的幾次無符號右移依然是 0,最后返回的 capacity 是 1(最后有個 n+1 的操作)。
這里只討論 n 不等於 0 的情況。
第一次右移
n |= n >>> 1;
由於 n 不等於 0,則 n 的二進制表示中總會有一bit為 1,這時考慮最高位的 1。通過無符號右移 1 位,則將最高位的 1 右移了 1 位,再做或操作,使得 n 的二進制表示中與最高位的 1 緊鄰的右邊一位也為 1,如 000011xxxxxx。
第二次右移
n |= n >>> 2;
注意,這個 n 已經經過了 n |= n >>> 1; 操作。假設此時 n 為 000011xxxxxx ,則 n 無符號右移兩位,會將最高位兩個連續的 1 右移兩位,然后再與原來的 n 做或操作,這樣 n 的二進制表示的高位中會有 4 個連續的 1。如 00001111xxxxxx 。
第三次右移
n |= n >>> 4;
這次把已經有的高位中的連續的 4 個 1,右移 4 位,再做或操作,這樣 n 的二進制表示的高位中會有8個連續的 1。如 00001111 1111xxxxxx 。
以此類推
注意,容量最大也就是 32bit 的正數,因此最后 n |= n >>> 16; ,最多也就 32 個 1,但是這時已經大於了 MAXIMUM_CAPACITY ,所以取值到 MAXIMUM_CAPACITY 。
舉一個例子說明下吧。
注意,得到的這個 capacity 賦值給了 threshold,因此 threshold 就是所說的容量。當 HashMap 的 size 到達 threshold 這個閾值時會擴容。
但是,請注意,在構造方法中,並沒有對 table 這個成員變量進行初始化,table 的初始化被推遲到了 put 方法中,在 put 方法中會對 threshold 重新計算。
上述這段計算邏輯引自 : HashMap方法hash()、tableSizeFor()
6. put 添加元素
6.1 putMapEntries 添加 map 對象
該方法是在構造函數中直接傳入一個 map 對象,下面看具體實現代碼:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); //當 m 中有元素時,則需將map中元素放入本HashMap實例。 if (s > 0) { // 判斷table是否已經初始化,如果未初始化,則先初始化一些變量。(table初始化是在put時) if (table == null) { // pre-size // 根據待插入的map 的 size 計算要創建的 HashMap 的容量。 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 把要創建的 HashMap 的容量存在 threshold 中 if (t > threshold) threshold = tableSizeFor(t); } // 如果table初始化過,因為別的函數也會調用它,所以有可能HashMap已經被初始化過了。 // 判斷待插入的 map 的 size,若 size 大於 threshold,則先進行 resize(),進行擴容 else if (s > threshold) resize(); //然后就開始遍歷 帶插入的 map ,將每一個 <Key ,Value> 插入到本HashMap實例。 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); // put(K,V)也是調用 putVal 函數進行元素的插入 putVal(hash(key), key, value, false, evict); } } }
主要是在判斷一些初始化工作是否已經做了,包括容量,table,確保都可以使用后,再將數據添加到 Map 中。在這里用到了遍歷,這里也簡單說下。可以發現這里 table 為 null 也沒有開始賦值,只是計算了閾值。會在 putVal 中初始化。
6.2 遍歷
和查找查找一樣,遍歷操作也是大家使用頻率比較高的一個操作。對於遍歷 HashMap,我們一般都會用下面的方式:
for(Object key : map.keySet()) { // do something } for(HashMap.Entry entry : map.entrySet()) { // do something } Set keys = map.keySet(); Iterator ite = keys.iterator(); while (ite.hasNext()) { Object key = ite.next(); // do something }
要么是通過獲得 keyset 來遍歷,或者就是拿到 entrySet,最后也可以使用迭代器。具體遍歷流程可以參看下圖:
遍歷上圖的最終結果是 19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59。
HashIterator 在初始化時,會先遍歷桶數組,找到包含鏈表節點引用的桶,對應圖中就是 3 號桶。隨后由 nextNode 方法遍歷該桶所指向的鏈表。遍歷完 3 號桶后,nextNode 方法繼續尋找下一個不為空的桶,對應圖中的 7 號桶。之后流程和上面類似,直至遍歷完最后一個桶。
6.3 put 添加元素
下面將分析如何添加一個新的元素:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } //HashMap.put的具體實現 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不為空並且table長度不可為0,否則將從resize函數中獲取 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //這樣寫法有點繞,其實這里就是通過索引獲取table數組中的一個元素看是否為Nul if ((p = tab[i = (n - 1) & hash]) == null) //若判斷成立,則New一個Node出來賦給table中指定索引下的這個元素 tab[i] = newNode(hash, key, value, null); else { //若判斷不成立 Node<K,V> e; K k; //對這個元素進行Hash和key值匹配,相等則取出該節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) //如果數組中德這個元素P是TreeNode類型 //判定成功則在紅黑樹中查找符合的條件的節點並返回此節點 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //若以上條件均判斷失敗,則執行以下代碼 //向Node單向鏈表中添加數據 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //若節點數大於等於8 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //轉換為紅黑樹 treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; //p記錄下一個節點 } } 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; }
通過以上的源碼,我們可以看出。在添加新節點時,通過 hash 確定其位置。
1.首先獲取 Node 數組 table 對象和長度,若 table 為 null 或長度為 0,則調用 resize() 擴容方法獲取 table 最新對象,並通過此對象獲取長度大小
2.判定數組中指定索引下的節點是否為 Null,若為 Null 則 new 出一個單向鏈表賦給 table 中索引下的這個節點
- 3.若判定不為 Null,我們的判斷再做分支
3.1 首先對 hash 和key進行匹配,若判定成功直接賦予 e
3.2 若匹配判定失敗,則進行類型匹配是否為 TreeNode 若判定成功則在紅黑樹中查找符合條件的節點並將其回傳賦給 e
3.3 若以上判定全部失敗則進行最后操作,向單向鏈表中添加數據若單向鏈表的長度大於等於 8,則將其轉為紅黑樹保存,記錄下一個節點,對 e 進行判定若成功則返回舊值
4.最后判定數組大小需不需要擴容
查找過程是首先得確定它的索引:
// index = (n - 1) & hash first = tab[(n - 1) & hash]
這里通過 (n - 1)& hash
即可算出桶的在桶數組中的位置,可能有的朋友不太明白這里為什么這么做,這里簡單解釋一下。HashMap 中桶數組的大小 length 總是 2 的冪,此時,(n - 1) & hash
等價於對 length 取余。但取余的計算效率沒有位運算高,所以 (n - 1) & hash
也是一個小的優化。舉個例子說明一下吧,假設 hash = 185,n = 16。計算過程示意圖如下:
上面的計算並不復雜,這里就不多說了。這里的 hash 值是 key.hashCode() 得到的。但是在 HashMap 這里,通過位運算重新計算了 hash 值的值。為什么要重新計算?
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
主要是因為 n (HashMap 的容量) 值比較小,hash 只參與了低位運算,高位運算沒有用上。這就增大了 hash 值的碰撞概率。而通過這種位運算的計算方式,使得高位運算參與其中,減小了 hash 的碰撞概率,使 hash 值盡可能散開。如何理解呢?把前面舉的例子 hash = 185,n = 16,按照 HashMap 的計算方法咱們再來走一遍。
圖中的 hash 是由鍵的 hashCode 產生。計算余數時,由於 n 比較小,hash 只有低 4 位參與了計算,高位的計算可以認為是無效的。這樣導致了計算結果只與低位信息有關,高位數據沒發揮作用。為了處理這個缺陷,我們可以上圖中的 hash 高 4 位數據與低 4 位數據進行異或運算,即 hash ^ (hash >>> 4)
。通過這種方式,讓高位數據與低位數據進行異或,以此加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程如下:
經過這次計算以后,發現最后的結果已經不一樣了,hash 的高位值對結果產生了影響。這里為了舉例子,使用了 8 位數據做講解。在 Java 中,hashCode 方法產生的 hash 是 int 類型,32 位寬。前 16 位為高位,后16位為低位,所以要右移 16 位。
7. resize() 擴容
在 Java 中,數組的長度是固定的,這意味着數組只能存儲固定量的數據。但在開發的過程中,很多時候我們無法知道該建多大的數組合適。建小了不夠用,建大了用不完,造成浪費。如果我們能實現一種變長的數組,並按需分配空間就好了。好在,我們不用自己實現變長數組,Java 集合框架已經實現了變長的數據結構。比如 ArrayList 和 HashMap。對於這類基於數組的變長數據結構,擴容是一個非常重要的操作。
首先 resize()
,先看一下哪些函數調用了 resize()
,從而在整體上有個概念:
final Node<K,V>[] resize() { // 保存當前table Node<K,V>[] oldTab = table; // 保存當前table的容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 保存當前閾值 int oldThr = threshold; // 初始化新的table容量和閾值 int newCap, newThr = 0; /* 1. resize()函數在size > threshold時被調用。oldCap大於 0 代表原來的 table 表非空, oldCap 為原表的大小,oldThr(threshold) 為 oldCap × load_factor */ if (oldCap > 0) { // 若舊table容量已超過最大容量,更新閾值為Integer.MAX_VALUE(最大整形值),這樣以后就不會自動擴容了。 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 } /* 2. resize()函數在table為空被調用。oldCap 小於等於 0 且 oldThr 大於0,代表用戶創建了一個 HashMap,但是使用的構造函數為 HashMap(int initialCapacity, float loadFactor) 或 HashMap(int initialCapacity) 或 HashMap(Map<? extends K, ? extends V> m),導致 oldTab 為 null,oldCap 為0, oldThr 為用戶指定的 HashMap的初始容量。 */ else if (oldThr > 0) // initial capacity was placed in threshold //當table沒初始化時,threshold持有初始容量。還記得threshold = tableSizeFor(t)么; newCap = oldThr; /* 3. resize()函數在table為空被調用。oldCap 小於等於 0 且 oldThr 等於0,用戶調用 HashMap()構造函數創建的 HashMap,所有值均采用默認值,oldTab(Table)表為空,oldCap為0,oldThr等於0, */ else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 新閾值為0 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"}) // 初始化table Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把 oldTab 中的節點 reHash 到 newTab 中去 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 若節點是單個節點,直接在 newTab 中進行重定位 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 若節點是 TreeNode 節點,要進行 紅黑樹的 rehash 操作 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 若是鏈表,進行鏈表的 rehash 操作 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 將同一桶中的元素根據(e.hash & oldCap)是否為0進行分割(代碼后有圖解,可以回過頭再來看),分成兩個不同的鏈表,完成rehash do { next = e.next; // 根據算法 e.hash & oldCap 判斷節點位置rehash 后是否發生改變 //最高位==0,這是索引不變的鏈表。 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //最高位==1 (這是索引發生改變的鏈表) else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { // 原bucket位置的尾指針不為空(即還有node) loTail.next = null; // 鏈表最后得有個null newTab[j] = loHead; // 鏈表頭指針放在新桶的相同下標(j)處 } if (hiTail != null) { hiTail.next = null; // rehash 后節點新的位置一定為原來基礎上加上 oldCap,具體解釋看下圖 newTab[j + oldCap] = hiHead; } } } } } return newTab; } }
使用的是 2 次冪的擴展(指長度擴為原來 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移動 oldCap 距離。這句話有些拗口,簡單來說的話,也可以這么理解:
首先,前面說了:(n - 1)& hash 可算出桶的在桶數組中的位置,並且
(n - 1) & hash
等價於對 length 取余。此處 n = length;
根據上述描述,對於 hash 值存就算如公式:hash
= a*n + b;a 是因子,該公式中 b 是余數。
擴容后容量為m: m = 2n;
如果 a>0,且是奇數,那么表達式變為:
hash
= (a-1)/2*m+b+n; 再做一個變換就是:
hash
= a1*m+b1; 其中余數 b1 = b+n;表示索引位置移動了 n 距離。
如果 a>0,且是偶數,那么表達式變為:hash
= a/2*m+b+n; 再做一個變換就是:
hash
= a2*m+b2; 其中余數 b2 = b;位置沒有變動。
如果 a=0,那么位置也不變。
下面采用圖例再來解釋一遍。n 為 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容后 key1 和 key2 兩種 key 確定索引位置的示例,其中 hash1 是 key1 對應的哈希與高位運算結果。


擴容后,需要進行鍵值對節點重新映射的過程。在 JDK 1.8 中,重新映射節點需要考慮節點類型。對於樹形節點,需先拆分紅黑樹再映射。對於鏈表類型節點,則需先對鏈表進行分組,然后再映射。需要的注意的是,分組后,組內節點相對位置保持不變。關於紅黑樹拆分的邏輯將會放在下一小節說明,先來看看鏈表是怎樣進行分組映射的。
什么時候擴容:通過 HashMap 源碼可以看到是在 put 操作時,即向容器中添加元素時,判斷當前容器中元素的個數是否達到閾值(當前數組長度乘以加載因子的值)的時候,就要自動擴容了。此外,HashMap 准備樹形化但又發現數組太短,也會發生擴容。
擴容(resize):其實就是重新計算容量;而這個擴容是計算出所需容器的大小之后重新定義一個新的容器,將原來容器中的元素放入其中。
8. 鏈表樹化、紅黑樹鏈化與拆分
下面這部分內容摘自 HashMap 源碼詳細分析(JDK1.8) 因為,覺得他這部分寫得很好,所以就直接摘過來了。
JDK 1.8 對 HashMap 實現進行了改進。最大的改進莫過於在引入了紅黑樹處理頻繁的碰撞,代碼復雜度也隨之上升。比如,以前只需實現一套針對鏈表操作的方法即可。而引入紅黑樹后,需要另外實現紅黑樹相關的操作。紅黑樹是一種自平衡的二叉查找樹,本身就比較復雜。本篇文章中並不打算對紅黑樹展開介紹,本文僅會介紹鏈表樹化需要注意的地方。至於紅黑樹詳細的介紹,如果大家有興趣,可以參考他的另一篇文章 - 紅黑樹詳細分析。
在展開說明之前,先把樹化的相關代碼貼出來,如下:
static final int TREEIFY_THRESHOLD = 8; /** * 當桶數組容量小於該值時,優先進行擴容,而不是樹化 */ static final int MIN_TREEIFY_CAPACITY = 64; 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; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } } /** * 將普通節點鏈表轉換成樹形節點鏈表 */ final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 桶數組容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { // hd 為頭節點(head),tl 為尾節點(tail) TreeNode<K,V> hd = null, tl = null; do { // 將普通節點替換成樹形節點 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); // 將普通鏈表轉成由樹形節點鏈表 if ((tab[index] = hd) != null) // 將樹形鏈表轉換成紅黑樹 hd.treeify(tab); } } TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) { return new TreeNode<>(p.hash, p.key, p.value, next); }
在擴容過程中,樹化要滿足兩個條件:
-
鏈表長度大於等於 TREEIFY_THRESHOLD
-
桶數組容量大於等於 MIN_TREEIFY_CAPACITY
第一個條件比較好理解,這里就不說了。這里來說說加入第二個條件的原因,個人覺得原因如下:
當桶數組容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致鏈表長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶數組容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新映射。所以在桶容量比較小的情況下,將長鏈表轉成紅黑樹是一件吃力不討好的事。
回到上面的源碼中,我們繼續看一下 treeifyBin 方法。該方法主要的作用是將普通鏈表轉成為由 TreeNode 型節點組成的鏈表,並在最后調用 treeify 是將該鏈表轉為紅黑樹。TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原鏈表的節點順序最終通過 next 引用被保存下來。我們假設樹化前,鏈表結構如下:
HashMap 在設計之初,並沒有考慮到以后會引入紅黑樹進行優化。所以並沒有像 TreeMap 那樣,要求鍵類實現 comparable 接口或提供相應的比較器。但由於樹化過程需要比較兩個鍵對象的大小,在鍵類沒有實現 comparable 接口的情況下,怎么比較鍵與鍵之間的大小了就成了一個棘手的問題。為了解決這個問題,HashMap 是做了三步處理,確保可以比較出兩個鍵的大小,如下:
-
比較鍵與鍵之間 hash 的大小,如果 hash 相同,繼續往下比較
-
檢測鍵類是否實現了 Comparable 接口,如果實現調用 compareTo 方法進行比較
-
如果仍未比較出大小,就需要進行仲裁了,仲裁方法為 tieBreakOrder(大家自己看源碼吧)
tie break 是網球術語,可以理解為加時賽的意思,起這個名字還是挺有意思的。
通過上面三次比較,最終就可以比較出孰大孰小。比較出大小后就可以構造紅黑樹了,最終構造出的紅黑樹如下:
橙色的箭頭表示 TreeNode 的 next 引用。由於空間有限,prev 引用未畫出。可以看出,鏈表轉成紅黑樹后,原鏈表的順序仍然會被引用仍被保留了(紅黑樹的根節點會被移動到鏈表的第一位),我們仍然可以按遍歷鏈表的方式去遍歷上面的紅黑樹。這樣的結構為后面紅黑樹的切分以及紅黑樹轉成鏈表做好了鋪墊,我們繼續往下分析。
8.1 split
紅黑樹拆分
擴容后,普通節點需要重新映射,紅黑樹節點也不例外。按照一般的思路,我們可以先把紅黑樹轉成鏈表,之后再重新映射鏈表即可。這種處理方式是大家比較容易想到的,但這樣做會損失一定的效率。不同於上面的處理方式,HashMap 實現的思路則是上好佳(上好佳請把廣告費打給我)。如上節所說,在將普通鏈表轉成紅黑樹時,HashMap 通過兩個額外的引用 next 和 prev 保留了原鏈表的節點順序。這樣再對紅黑樹進行重新映射時,完全可以按照映射鏈表的方式進行。這樣就避免了將紅黑樹轉成鏈表后再進行映射,無形中提高了效率。
以上就是紅黑樹拆分的邏輯,下面看一下具體實現吧:
// 紅黑樹轉鏈表閾值 static final int UNTREEIFY_THRESHOLD = 6; final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { TreeNode<K,V> b = this; // Relink into lo and hi lists, preserving order TreeNode<K,V> loHead = null, loTail = null; TreeNode<K,V> hiHead = null, hiTail = null; int lc = 0, hc = 0; /* * 紅黑樹節點仍然保留了 next 引用,故仍可以按鏈表方式遍歷紅黑樹。 * 下面的循環是對紅黑樹節點進行分組,與上面類似 */ for (TreeNode<K,V> e = b, next; e != null; e = next) { next = (TreeNode<K,V>)e.next; e.next = null; if ((e.hash & bit) == 0) { if ((e.prev = loTail) == null) loHead = e; else loTail.next = e; loTail = e; ++lc; } else { if ((e.prev = hiTail) == null) hiHead = e; else hiTail.next = e; hiTail = e; ++hc; } } if (loHead != null) { // 如果 loHead 不為空,且鏈表長度小於等於 6,則將紅黑樹轉成鏈表 if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map); else { tab[index] = loHead; /* * hiHead == null 時,表明擴容后, * 所有節點仍在原位置,樹結構不變,無需重新樹化 */ if (hiHead != null) loHead.treeify(tab); } } // 與上面類似 if (hiHead != null) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } } }
從源碼上可以看得出,重新映射紅黑樹的邏輯和重新映射鏈表的邏輯基本一致。不同的地方在於,重新映射后,會將紅黑樹拆分成兩條由 TreeNode 組成的鏈表。如果鏈表長度小於 UNTREEIFY_THRESHOLD,則將鏈表轉換成普通鏈表。否則根據條件重新將 TreeNode 鏈表樹化。舉個例子說明一下,假設擴容后,重新映射上圖的紅黑樹,映射結果如下:
8.2 untreeify
紅黑樹鏈化
前面說過,紅黑樹中仍然保留了原鏈表節點順序。有了這個前提,再將紅黑樹轉成鏈表就簡單多了,僅需將 TreeNode 鏈表轉成 Node 類型的鏈表即可。相關代碼如下:
final Node<K,V> untreeify(HashMap<K,V> map) { Node<K,V> hd = null, tl = null; // 遍歷 TreeNode 鏈表,並用 Node 替換 for (Node<K,V> q = this; q != null; q = q.next) { // 替換節點類型 Node<K,V> p = map.replacementNode(q, null); if (tl == null) hd = p; else tl.next = p; tl = p; } return hd; } Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) { return new Node<>(p.hash, p.key, p.value, next); }
上面的代碼並不復雜,不難理解,這里就不多說了。
9. get 添加元素
//這里直接調用getNode函數實現方法 public V get(Object key) { Node<K,V> e; //經過hash函數運算 獲取key的hash值 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; //判定三個條件 table不為Null & table的長度大於0 & table指定的索引值不為Null if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //判定 匹配hash值 & 匹配key值 成功則返回 該值 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; //若 first節點的下一個節點不為Null if ((e = first.next) != null) { if (first instanceof TreeNode) //若first的類型為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 函數的執行過程
判定三個條件 table 不為 Null & table 的長度大於 0 & table 指定的索引值不為 Null,否則直接返回 null,這也是可以存儲 null
判定匹配 hash 值 & 匹配 key 值,成功則返回該值,這里用了 == 和 equals 兩種方式,對於 int,string,同一個實例對象等可以適用。
若 first 節點的下一個節點不為 Null
若下一個節點類型為 TreeNode 紅黑樹,通過紅黑樹查找匹配值,並返回查詢值
否則就是單鏈表,還是通過匹配 hash 值 & 匹配 key 值來獲取數據。
10. remove 刪除元素
當你看到了 get 獲取元素的細節,在來看刪除原理,其實大同小異。
HashMap 的刪除操作並不復雜,僅需三個步驟即可完成。第一步是定位桶位置,第二步遍歷鏈表並找到鍵值相等的節點,第三步刪除節點。相關源碼如下:
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && // 1. 定位桶位置 (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; // 如果鍵的值與鏈表第一個節點相等,則將 node 指向該節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; else if ((e = p.next) != null) { // 如果是 TreeNode 類型,調用紅黑樹的查找邏輯定位待刪除節點 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 2. 遍歷鏈表,找到待刪除節點 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } // 3. 到這里,已經找到了,刪除節點,並修復鏈表或紅黑樹 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); else if (node == p) tab[index] = node.next; else p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } }
// 找不到,或者沒有數據,返回 null return null; }
刪除操作本身並不復雜,有了前面的基礎,理解起來也就不難了,這里就不多說了。
11. 其他
11.1 HashMap 和 HashTable 的區別
HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:線程安全性,同步(synchronization),以及速度。
-
HashMap 幾乎可以等價於 Hashtable,除了 HashMap 是非 synchronized 的,並可以接受 null -> null 鍵值對,而 Hashtable 則不行)。
-
Hashtable 是線程安全的,多個線程可以共享一個Hashtable;。Java 5提供了ConcurrentHashMap,它是 HashTable 的替代,比 HashTable 的擴展性更好。
-
由於 Hashtable 是線程安全的,在單線程環境下它比 HashMap 要慢。在單一線程下,使用 HashMap 性能要好過 Hashtable。
-
HashMap 不能保證隨着時間的推移 Map 中的元素次序是不變的。
- HashMap 的迭代器 (Iterator) 是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。所以當有其它線程改變了 HashMap 的結構(增加或者移除元素),將會拋出 ConcurrentModificationException,但迭代器本身的 remove() 方法移除元素則不會拋出 ConcurrentModificationException 異常。但這並不是一個一定發生的行為,要看 JVM。這條同樣也是 Enumeration 和 Iterator 的區別。
11.2 JDK 1.7 和 1.8 的 HashMap 的不同點
(1)JDK1.7 用的是頭插法,而 JDK1.8 及之后使用的都是尾插法,那么為什么要這樣做呢?因為 JDK1.7 是用單鏈表進行的縱向延伸,當采用頭插法就是能夠提高插入的效率,但是也會容易出現逆序且環形鏈表死循環問題。但是在 JDK1.8 之后是因為加入了紅黑樹使用尾插法,能夠避免出現逆序且鏈表死循環的問題。(2)擴容后數據存儲位置的計算方式也不一樣:
-
在 JDK1.7 的時候是直接用 hash 值和需要擴容的二進制數進行 &(這里就是為什么擴容的時候為啥一定必須是 2 的多少次冪的原因所在,因為如果只有 2 的 n 次冪的情況時最后一位二進制數才一定是 1,這樣能最大程度減少 hash 碰撞)(hash 值 & length-1) 。
-
而在 JDK1.8 的時候直接用了 JDK1.7 的時候計算的規律,也就是擴容前的原始位置+擴容的大小值 = JDK1.8 的計算方式,而不再是 JDK1.7 的那種異或的方法。但是這種方式就相當於只需要判斷 hash 值的新增參與運算的位是 0 還是 1 就直接迅速計算出了擴容后的儲存方式。
(3)JDK1.7 的時候使用的是數組+ 單鏈表的數據結構。但是在 JDK1.8 及之后時,使用的是數組+鏈表+紅黑樹的數據結構(當鏈表的深度達到 8 的時候,也就是默認閾值,就會自動擴容把鏈表轉成紅黑樹的數據結構來把時間復雜度從 O(N) 變成 O(logN) 提高了效率)。
11.3 當兩個對象的 hashcode 相同會發生什么?獲取元素的時候,如何區分?
hashcode 相同,說明兩個對象 HashMap 數組的同一位置上,接着 HashMap 會遍歷鏈表中的每個元素,通過 key 的 equals 方法來判斷是否為同一個 key,如果是同一個key,則新的 value 會覆蓋舊的 value,並且返回舊的 value。如果不是同一個 key,則存儲在該位置上的鏈表的鏈尾。
獲取元素的時候遍歷 HashMap 鏈表中的每個元素,並對每個 key 進行 hash 計算,只有 hash 和 key 都相等,才返回對應的值對象。