一、概覽
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 ,還會再操作把樹又轉換為單連結串列。
但是如果在多執行緒的情況下,不會形成環連結串列,但是可能會丟失資料,因為會覆蓋到一樣的新位置。