一、概覽
HashMap<String, Integer> map = new HashMap<>();
這個語句執行起來,在 jdk1.8 之前,會創建一個長度是 16 的 Entry[]
數組,叫 table
,用來存儲鍵值對。
在 jdk 1.8 后,不在這里創建數組了,而是在第一次 put
的時候才會創建數組叫 Node[] table
,用來存儲鍵值對。
二、源碼的成員變量分析
聲明部分:

HashMap 實現了 Map 接口,又繼承了 AbstractMap
,但是 AbstractMap
也是實現了 Map
接口的,而且很多集合類都是這種實現,這是一個官方失誤造成的冗余,不過一直流傳了下來。
- 繼承
AbstractMap
,這個父類作為抽象類,實現了Map
的很多方法,為了減少直接實現類的工作; - 實現
Cloneable
接口和Serializable
接口,這個問題在 原型模式 里面說過,就是深拷貝的問題,但是值得注意的是,HashMap 實現這兩個接口,重寫的方法仍然不是深拷貝,而是淺拷貝。
屬性部分:

2.1 序列號serialVersionUID
序列化默認版本號,不重要。
2.2 默認初始化容量DEFAULT_INITIAL_CAPACITY
集合默認初始化容量,注釋里寫了必須是 2 的冪次方數
,默認是 16。
問題 1 : 為什么非要是 2 的次方數呢?
答:第一方面為了均勻分布,第二方面為了擴容的時候重新計算下標值的方便。
這個涉及到了插入元素的時候對每一個 node 的應該在的桶位置的計算:

核心在這個方法里,會根據 (n - 1) & hash
這個公式計算出 i
,hash
是提前算出的 key
的哈希值,n
則是整個 map
的數組的長度。
那么這個節點應該放在哪個桶,這就是散列的過程,我們當然希望散列的過程是盡量均勻的,而不會出現都算出來進入了 table[]
的同一個位置。那么,可以選擇的方法有取余啊、之類的,這里采用的方法是位運算來實現取余。
就是(n - 1) & hash 這個位運算,2 的冪 -1 都是11111結尾的:
2 進制,所以 2 的幾次方都是 1 00000(很多個 0 的情況),然后 -1, 就會變成 000 11111(很多個1)那么和 本來計算的具有唯一性的 hash 值相與,
- 用高位的 0 把hash 值的高位都置為了 0 ,所以限制在了 table 的下標范圍內。
- 保證了 hash 值的盡量散開。
對於第 2 點,如果不是 2 的冪次方,那么 -1 就不會得到 1111 結尾,甚至如果是個基數,-1 后就會變成形如 0000 1110
這樣的偶數,那么相與的結果豈不是永遠都是偶數了?這樣 table 數組就會有一半的位置永遠利用不上的。所以 2 的冪次方以及 -1 的操作,才能保證得到和取模一樣的效果。
因此得出結論,如果 n 是 2 的冪次方,計算出的位置會很均勻,相反則會干擾這個運算,導致計算出的位置不均勻。
第二個方面的原因就是擴容的時候,重新要計算下標值 hash
,2 的冪次方
帶給了好處,下面的擴容部分有詳細說明。
注意到我們初始化 HashMap 的時候可以指定容量。
問題 2 那么如果傳入的容量並不是 2 的次方,怎么辦呢?

從構造方法可以看到,調用指定加載因子和 容量的方法,如果大於最大容量,就會改為最大容量,接着對於容量,調用 tableSizeFor
方法,此時傳入的參數已經肯定是 <=
最大容量的數字了。

tableSizeFor
這個方法會產生一個大於傳入數字的、最小的 2
的冪次方數。
2.3 最大容量MAXIMUM_CAPACITY
最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方
。
2.4 默認加載因子DEFAULT_LOAD_FACTOR
默認加載因子為 0.75
,也就是說,如果鍵值對超過了當前的容量 * 0.75
,就會觸發擴容。
問題 為什么是 0.75
而不是別的數呢?
答:如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數據將過於稀疏(很多空間還沒用,就開始擴容了),對空間造成嚴重浪費。
其實 0.75
是一個統計的結果,比較理想的值,根據舊版源碼里面的注釋,和概率的泊松分布有關系,當負載因子是 0.75
的情況下,哈希碰撞的概率遵循參數約為 0.5
的泊松分布,因此選擇它是一個折衷的辦法來滿足時間和空間。
2.5 轉樹的閾值TREEIFY_THRESHOLD
默認為 8
,也就是說一個桶內的鏈表節點數多於 8
的時候,結合數組當前長度會把鏈表轉換為紅黑樹。
問題 為什么是超過 8
就轉為紅黑樹?
答:首先,紅黑樹的節點在內存中是普通鏈表節點方式存儲的 2 倍
,成本是比較高的,那么對於太少的節點數目就沒必要轉化,繼續擴容就行了。
結合負載因子 0.75
的泊松分布結果,每個鏈表有 8
個節點的概率已經到達可以忽略的程度,所以將這個值設置為 8
。為了避免出現惡意的頻繁插入,除此之外還會判斷數組長度是否達到了 64。
所以到這里我個人的理解是:
-> 最開始hashmap的思想就是數組加鏈表;
-> 因為數組里的各個鏈表長度要均勻,所以就有了哈希值的算法,以及適當的擴容,擴容的加載因子定成了 0.75 ;
-> 而擴容只能根據總共的節點數來計算,可能沒來得及擴容的時候還是出現了在同一個鏈表里元素變得很多,所以要轉紅黑樹,而這個數量就根據加載因子結合泊松分布的結果,決定了是8.
2.6 重新退化為鏈表的閾值UNTREEIFY_THRESHOLD
默認為 6
, 也就死說如果操作過程發現鏈表的長度小於 6
,又會把樹退回鏈表。
2.7 轉樹的最小容量
不僅僅是說有鏈表的節點多於 8
就轉換,還要看 table
數組的長度是不是大於 64
,只有大於 64
了才轉換。為了避免開始的時候,正好一些鍵值對都裝進了一個鏈表里,那只有一個鏈表,還轉了樹,其實沒必要。
還有屬性的第二部分:

第一個是容器 table
存放鍵值對的數組,就是保存鏈表或者樹的數組,可以看到 Node
類型也是實現了 Entry
接口的,在 1.8
之前這個節點是不叫 Node
的,就叫的 Entry
,因為就是一個鍵值對,現在換成了 Node
,是因為除了普通的鍵值對類型,還可能換成紅黑樹的樹節點TreeNode
類型,所以不是 Entry
了。

第二個是保存所有鍵值對的一個 set
集合,是一個存放緩存的;
第三個 size
是整個hashmap
里的鍵值對的數目;
第四個是 modCount
是記錄集合被修改的次數,有助於在多個線程操作的時候報根據一致性保證安全;
第五個 threshold 是擴容的閾值,也就是說大於閾值的時候就開始擴容,也就是 threshold = 當前的 capacity * loadfactor
;
第六個 loadFactor
也是對應前面的加載因子。
三、源碼的核心方法分析
3.1 構造方法

可以看到,這幾個重載的構造方法做的事就是設置一些參數。
事實上,在 jdk1.8 之后,並不會直接初始化 hashmap
,只是進行加載因子、容量參數的相關設定,真正開始將 table
數組空間開辟出來,是在 put
的時候才開始的。
第一個:
public HashMap()
是我們平時最常用的,只是設置了默認加載因子,容量沒有設定,那顯然就是 16
。
第二個:
public HashMap(int initialCapacity)
為了盡量少擴容,這個構造方法是推薦的,也就是指定 initialCapacity
,在這個方法里面直接調用的是
第三個構造方法:
public HashMap(int initialCapacity, float loadFactor)
用指定的初始容量和加載因子,確保在最大范圍內,也調整了 threshold 容量是 2 的冪次方數
。
這里就是一個問題,把 capcity
調整成 2 的冪次方
數,計算 threshold
的時候不應該要乘以 loadfactor
嗎,怎么能直接賦給 threshold
呢?
原因是這里沒有用到 threshold
,還是在 put
的時候才進行 table
數組的初始化的,所以這里就沒有操作。
最后一個構造方法是,將本來的一個 hashmap 放到一個新的 map 里。
3.2 put 和 putVal 方法

put
方法是直接調用了計算 hash
值的方法計算哈希值,然后交給 putVal
方法去做的。
hash
方法就是調用本地的 hashCode
方法再做一個位移操作計算出哈希值。

為什么采用這種右移 16 位
再異或的方式計算 hash
值呢?
因為 hashCode
值一般是一個很大的值,如果直接用它的話,實際上在運算的時候碰撞的概率會很高,所以要充分利用這個二進制串的性質:int
類型的數值是 4
個字節的,右移 16
位,再異或可以同時保留高 16 位
和低 16 位
的特征,進行了混合得到的新的數值中,高位與低位的信息都被保留了 。
另外,因為,異或運算能更好的保留各部分的特征,如果采用 &
運算計算出來的值會向 1
靠攏,采用 |
運算計算出來的值會向 0
靠攏, ^
正好。
最后的目的還是一樣,為了減少哈希沖突。
算出 hash 值后,調用的是 putVal 方法:

傳入哈希值;要插入的 key 和 value;然后兩個布爾變量,onlyIfAbsent 代表當前要插入的 value 是否存在了如果是 true,就不修改;evict 代表這個 hashmap 是否處於創建模式,如果是 false,就是創建模式。
下面是源碼及具體注釋:
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方法初始化tab,驗證了我們說的,構造方法不會創建數組,而是插入的時候創建。
//這個算法前面也已經講過,就是計算索引,如果p的位置是 null,就在這里放入一個newNode;
//如果p的位置不是 null,說明這個桶里已經有鏈表或者樹了,就不能直接 new ,而是要遍歷鏈表插入,並同時判斷是不是需要轉樹
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//已經不是鏈表是紅黑樹了,調用putTreeVal
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//是鏈表,用 for 循環遍歷
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;//如果已經有值,覆蓋,這里用到了onlyIfAbsent
afterNodeAccess(e);
return oldValue;
}
}
//增加修改hashMap的次數
++modCount;
//如果已經達到了閾值,就要擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
這里面涉及到的步驟主要如下:
-
調用
resize
方法初始化table
數組,jdk1.8 后確實是到put
的時候才會初始化數組; -
用
hash
值計算出在數組里應該在的索引; -
如果索引位置是
null
,就直接放入一個新節點,也就是Node
對象; -
如果不是
null
,則要在這個桶里插入:- 如果遇見了一個節點的
hash
值、key值和傳入的這個新的一樣,賦值給e
這個節點; - 用
instanceof
判斷是否為TreeNode
類型,也就是說如果這個桶里已經不是鏈表而是紅黑樹了,就調用putTreeVal
方法; - 如果不是,那就要遍歷這個鏈表,同理,遍歷的過程如果也找到了一個階段的
hash
值、key
值和傳入的一樣,賦值給e
這個節點,否則遍歷到最后,把一個Node
對象插到鏈表末尾,插完后鏈表長度已經大於閾值,就要轉樹。
- 如果遇見了一個節點的
-
結束插入的動作后,前面的
e
一旦被賦值過了,說明是有一樣的key
出現,那么就說明不用插入新節點,而是替代舊的val
。
這里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比較復雜的方法,下來進行介紹。
3.3 treeifyBin 方法
轉換為樹的方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果數組的長度還沒有達到 64 ,就不轉樹,只是擴容。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果 e 不為空,那么遍歷整個鏈表,把每個節點都換成具有prev和next兩個指針的樹節點
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
}
}
treeify 里面調用了各種左旋啊、右旋啊,平衡
啊,各種很復雜的紅黑樹操作方法,這里不再深入。
3.4 resize 擴容方法
問題:什么時候會擴容?
從前面成員變量的解釋和插入元素,已經能總結出兩種擴容的情況:
- 當鍵值對的元素個數(也就是鍵值對的個數,size)超過了
數組長度*負載因子(0.75)
的時候,擴容; - 當其中某一個鏈表的元素個數達到
8
個,並且數組長度沒有達到64
,則擴容而不轉紅黑樹。
擴容每次都會把數組的長度擴到 2
倍,並且之后還要把每個元素的下標重新計算,這樣的開銷是很大的。
值得注意的是,重新計算下標值的方法 和第一次的計算方法一樣,這樣很簡便且巧妙:
- 首先,仍然使用
(n - 1) & hash
這個式子計算索引,但是顯然有重新計算的時候,變化的是n-1
,有些就不會在原位置了; - 從
n
的變化入手,因為是2
倍擴容,而數組長度本身也設置是2
的冪次,在二進制位上來說,新算出來的n-1
只是相比舊的n-1
左移了一位;
比如 16-1 = 15,就是 1 0000 - 1 = 0 1111;
新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
- 那么這個值再和
hash
相與運算,節點要么在原來位置,要么在原位置+舊的容量的位置
,也就是在最高位加上了一個原來的容量; - 這樣計算的時候就不用頻繁的再計算,而是用一個加法就直接定位到要挪動的地方。
上面講過的為什么長度設置 2 的冪次,這里也能作為一個優勢的解釋。
源碼如下:
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //這里把新的閾值和新的邊界值都*2
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//for循環就開始把所有舊的節點都放到新數組里
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);//如果是樹節點,拆分
else {
//是鏈表,保持順序,用do-while循環進行新的位置安排
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) {//用hash和oldCap的與結果,拆分鏈表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}else {//用hash和oldCap的與結果,拆分鏈表
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;//放在新索引位置,就是加上 oldCap
}
}
}
}
}
return newTab;
}
3.5 remove 和 removeNode 刪除方法
remove 直接調用的 removeNode 方法,類似於前面的 put 調用 putVal 。

注意 remove
根據 key
的時候肯定默認那個對應的 value
也是要刪除的,所以 matchValue
置為 false
,意思就是不用看 value
。
removeNode
的整體思路比較常規,就是我們能想到的:
-
如果本身
hashmap
不為空,且hash
值對應的索引位置不為空,才去某一個桶里找並刪除;- 在遍歷查找的過程里,分成對於鏈表節點和樹節點的查找,就是根據
key
來比較的; - 找到之后,根據
matchValue
判斷要不要刪除,刪除的過程就是用之前找到的那個位置,然后指針操作就可。
- 在遍歷查找的過程里,分成對於鏈表節點和樹節點的查找,就是根據
-
否則,直接返回
null
。
3.6 get 和 getNode 方法
get
也只直接調用了 getNode
方法:

這里面的代碼就和 remove
方法的前半部分幾乎一樣,也就是找到指定的 key
的位置,並返回對應的 value
。
3.7 HashMap的遍歷
HashMap 本身維護了一個 keySet 的 Set,拿到所有的 key 。(顯然維護 value 是沒辦法的,因為 key 都是唯一的),但這種方法不推薦,因為拿到 key 后再去找 value又是對 map 的遍歷。
Set<String> keys = map.keySet();
for (String key: keys){
System.out.println(key + map.get(key));//根據key得到value
}
也可以拿到所有的 value 需要用 Collection 來接收:
Collection<Integer> values = map.values();
for (Integer v: values){
System.out.println(v);
}
也可以獲取到所有的鍵值對Entry 的 Set 集合,然后拿到對應的迭代器進行遍歷:
Set<Map.Entry<String,Integer>> entries = map.entrySet();
Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();
while (iterator.hasNext()){
Map.Entry<String,Integer> entry = iterator.next();
System.out.println(entry.getKey()+entry.getValue());//得到key和value
}
jdk 1.8 之后,還增加了一個 forEach 方法,可以接口里的這個方法本身也是通過第二種方法實現的,在HashMap 里重寫了這個方法,變成了對 table 數組的遍歷,使用的時候,用 lambda 表達式傳入泛型就可以。
map.forEach((key,value)->{
System.out.println(key + value);
});
這種方法其實用到的也屬於設計模式的代理模式
四、總結 jdk 1.7 和 1.8 之后關於 HashMap 的區別
4.1 數據結構的使用
- 1.7 :單鏈表
- 1.8 :單鏈表,如果鏈表長度>8且數組長度已經>64,轉為紅黑樹
關於數組本身,1.7 是一個 Entry 類型的數組,1.8是一個 Node 類型。
4.2 什么時候擴容?
1.7 擴容時機
- 擴容只有一種情況。利用了兩個信息:
數組長度 * 加載因子
。加載因子默認情況是0.75
,等鍵值對個數size
達到了數組長度 * 加載因子
;- 產生哈希沖突,當前插入的時候數組的這個位置已經不為空了。
擴容后,添加元素。
1.8 的擴容時機
先添加元素,再看是否需要擴容。
- 擴容的第一種情況。
數組長度 * 加載因子。
加載因子默認情況是 0.75
,等鍵值對個數 size
達到了數組長度 * 加載因子
(這點判斷是一樣的)
- 擴容的第二種情況。
當其中某一個鏈表的元素個數達到 8
個,走到轉樹節點的方法里,但是又發現數組長度沒有達到 64
,則擴容而不轉紅黑樹。
4.3 擴容的實現
1.7 擴容的實現
數組長度 * 2
操作;- 然后用一個 transfer 方法進行數據遷移,
transfer
里,對單向鏈表進行一個一個hash
重新計算並且安排,采用頭插法來安排單向鏈表,把節點都安排好。
但是如果多線程的情況下,有別的線程先完成了擴容操作,這個時候鏈表的重新挪動已經導致節點位置的變化,切換回這個線程的時候,繼續改變鏈表指針就可能會產生環,然后這個線程死循環。
具體就是 7 的擴容方法在遷移的時候采用的是頭插法,那么比如兩個元素 ab一個鏈表,線程1和2都發現要擴容,就會去調用transfer方法:
- 1 先讀取了 e 是 a,next 是 b,但是沒來得及繼續操作就掛起了;
- 2 開始讀取,並采用頭插法就是遍歷ab,先把a移到新數組的位置,此時a.next = null;繼續遍歷到 b,b移到新位置,b.next = a;(形成了 b->a)
- 這時候切換到了線程 1 執行,本來已經再循環里面記錄了 e 和 e.next 了,然而這時本來數組都變新的了,所以修改的時候計算位置啥的還是這個新數組里,不會變,因為計算的肯定是一樣的, a.next = b,而前面就修改過了b.next = a,這樣已經是環了,那么線程 1 繼續while,一直next,死循環。
1.8 擴容的實現
因為是先插入,再擴容,所以插入的時候對於鏈表就是一個尾插法。
然后如果達到了擴容的條件,也就先進行數組長度 * 2
操作,直接在 resize
方法里完成數據遷移,這里因為數據結構已經有鏈表+紅黑樹兩種情況:
- 如果是
鏈表
,把單鏈表進行數據遷移,充分利用與運算,將單鏈表針對不同情況拆斷,放到新數組的不同位置; - 如果是
紅黑樹
,樹節點里維護了相當於雙向鏈表的指針,重新處理,如果處理之后發現樹的節點(雙向鏈表)小於等於 6 ,還會再操作把樹又轉換為單鏈表。
但是如果在多線程的情況下,不會形成環鏈表,但是可能會丟失數據,因為會覆蓋到一樣的新位置。
4.4 為什么HashMap線程不安全
- put、get 等等核心方法在多線程情況下,都會出現修改的覆蓋,數據不一致等等問題。比如多個線程 put 先后的問題,會導致結果覆蓋,如果一個 put 一個get,也可能會因為調度問題獲取到錯誤的結果。
- 正如上面具體分析過的死循環問題,在多線程擴容的時候,1.7的 hashmap 因為采用頭插法進行擴容之后的重新節點分配,可能會出現死循環;
- 因為 Hashmap 的迭代器是 fast-fail iterator,所以多線程一邊寫操作一邊遍歷,會出現 ConcurrentModificationException 並發讀寫異常。