一、jdk1.8中,對“HashMap的容量一定要是2的n次冪”做了嚴格控制
1.默認初始容量:
[Java]
純文本查看 復制代碼
|
1
2
3
4
|
/**
* The default initial capacity - MUST be a power of two.(默認初始容量——必須是2的n次冪。)
*/
static
final
int
DEFAULT_INITIAL_CAPACITY =
1
<<
4
;
// aka 16(16 = 2^4)
|
2.使用HashMap的有參構造函數來自定義容量的大小(保證容量是2的n次冪):
HashMap總共有4個構造函數,其中有2個構造函數可以自定義容量的大小:
①HashMap(int initialCapacity):底層調用的是②HashMap(int initialCapacity, float loadFactor)構造函數
[Java]
純文本查看 復制代碼
|
1
2
3
|
public
HashMap(
int
initialCapacity) {
this
(initialCapacity, DEFAULT_LOAD_FACTOR);
}
|
②HashMap(int initialCapacity, float loadFactor)
[Java]
純文本查看 復制代碼
|
01
02
03
04
05
06
07
08
09
10
|
public
HashMap(
int
initialCapacity,
float
loadFactor) {
if
(initialCapacity <
0
)
throw
new
IllegalArgumentException(
"Illegal initial capacity: "
+ initialCapacity);
if
(initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if
(loadFactor <=
0
|| Float.isNaN(loadFactor))
throw
new
IllegalArgumentException(
"Illegal load factor: "
+ loadFactor);
this
.loadFactor = loadFactor;
this
.threshold = tableSizeFor(initialCapacity);
//tableSizeFor(initialCapacity)方法是重點!!!
}
|
這里有個問題:使用①或②構造函數來自定義容量時,怎么能夠保證傳入的容量一定是2的n次冪呢?
答案就在標記出來的tableSizeFor(initialCapacity)方法中:
[Java]
純文本查看 復制代碼
|
01
02
03
04
05
06
07
08
09
10
11
12
|
/**
* Returns a power of two size for the given target capacity.
*/
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
;
}
|
上面這段代碼的作用:
假如你傳的cap是5,那么最終的初始容量為8;假如你傳的cap是24,那么最終的初始容量為32。
這是因為5並非是2的n次冪,而大於等於5且距離5最近的2的n次冪是8(8 = 2^3);同樣的,24也並非2的n次冪,大於等於24且距離24最近的2的n次冪是32(32 = 2^5)。
假如你傳的cap是64,那么最終的初始容量就是64,因為64是2^6,它就是等於cap的最小2的n次冪。
總結起來就一句話:通過位移運算,找到大於或等於 cap 的 最小2的n次冪。
jdk1.7的初始容量處理機制和上面jdk1.8具有相同的作用,但1.7的代碼好懂很多:
[Java]
純文本查看 復制代碼
|
1
2
3
4
5
6
7
8
|
public
HashMap(
int
initialCapacity,
float
loadFactor) {
……
int
capacity =
1
;
while
(capacity < initialCapacity) {
capacity <<=
1
;
}
……
}
|
3.擴容:同樣需要保證擴容后的容量是2的n次冪( jdk1.8 HashMap.resize()擴容方法的源碼解析)
resize()擴容方法主要做了三件事(這里這里重點講前兩件事,第三件事在下文的“三、2.”中講):
①計算新容量(新桶) newCap 和新閾值 newThr;
②根據計算出的 newCap 創建新的桶數組table,並對table做初始化;
③將鍵值對節點重新放到新的桶數組里;
[Java]
純文本查看 復制代碼
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
final
Node<K,V>[] resize() {
//擴容
//---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr。 ---------------------------------
Node<K,V>[] oldTab = table;
int
oldCap = (oldTab ==
null
) ?
0
: oldTab.length;
//看容量是否已初始化
int
oldThr = threshold;
//下次擴容要達到的閾值。threshold(閾值) = capacity * loadFactor。
int
newCap, newThr =
0
;
if
(oldCap >
0
) {
//容量已初始化過了:檢查容量和閾值是否達到上限《==========
if
(oldCap >= MAXIMUM_CAPACITY) {
//oldCap >= 2^30,已達到擴容上限,停止擴容
threshold = Integer.MAX_VALUE;
return
oldTab;
}
// newCap < 2^30 && oldCap > 16,還能再擴容:2倍擴容
else
if
((newCap = oldCap <<
1
) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr <<
1
;
// 擴容:閾值*2。(注意:閾值是有可能越界的)
}
//容量未初始化 && 閾值 > 0。
//【啥時會滿足層判斷:使用HashMap(int initialCapacity, float loadFactor)或 HashMap(int initialCapacity)構造函數實例化HashMap時,threshold才會有值。】
else
if
(oldThr >
0
)
newCap = oldThr;
//初始容量設為閾值
else
{
//容量未初始化 && 閾值 <= 0 :
//【啥時會滿足這層判斷:①使用無參構造函數實例化HashMap時;②在“if (oldCap > 0)”判斷層newThr溢出了。】
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (
int
)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if
(newThr ==
0
) {
//什么情況下才會進入這個判斷框:前面執行了else if (oldThr > 0),並沒有為newThr賦值,就會進入這個判斷框。
float
ft = (
float
)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (
float
)MAXIMUM_CAPACITY ? (
int
)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//------------------------------------------------------2.擴容:------------------------------------------------------------------
@SuppressWarnings
({
"rawtypes"
,
"unchecked"
})
Node<K,V>[] newTab = (Node<K,V>[])
new
Node[newCap];
//擴容
table = newTab;
//--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組里。------------------------------------------------
……
//此處源碼見下文“二、2.”
return
newTab;
}
|
通過resize()擴容方法的源碼可以知道:每次擴容,都是將容量擴大一倍,所以新容量依舊是2的n次冪。如oldCap是16的話,那么newCap則為32。
通過上面三點可以確定,不論是默認初始容量,還是自定義容量大小,又或者是擴容后的容量,都必須保證一定是2的n次冪。
二、為什么HashMap的容量一定要是2的n次冪?或者說,保證“HashMap的容量一定是2的n次冪”有什么好處?
原因有兩個:
1.關系到元素在桶中的位置計算問題:
簡單來講,一個元素放到哪個桶中,是通過 “hash % capacity” 取模運算得到的余數來確定的(注:“元素的key的哈希值”在本文統一簡稱為“hash”)。
hashMap用另一種方式來替代取模運算——位運算:(capacity - 1) & hash。這種運算方式為什么可以得到跟取模一樣的結果呢? 答案是capacity是2的N次冪。(計算機做位運算的效率遠高於做取模運算的效率,測試見:https://www.cnblogs.com/laipimei/p/11316812.html)
證明取模和位運算結果的一致性:
2.關系到擴容后元素在newCap中的放置問題:
擴容后,如何實現將oldCap中的元素重新放到newCap中?
我們不難想到的實現方式是:遍歷所有Node,然后重新put到新的table中, 中間會涉及計算新桶位置、處理hash碰撞等處理。這里有個不容忽視的問題——哈希碰撞。在元素put進桶中時,就已經處理過了哈希碰撞問題:哈希值一樣但通過equals()比較確定內容不同的元素,會在同一個桶中形成鏈表,鏈表長度 >=8 時將鏈表轉為紅黑樹;擴容時,需要重新處理這些元素的哈希碰撞問題,如果數據量一大.......要完……
jdk1.8用了優雅又高效的方式來處理擴容后元素的放置問題,下面我們一起來看看jdk1.8到底是怎么做的。
2.1 先看jdk1.8源碼實現:
[Java]
純文本查看 復制代碼
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
final
Node<K,V>[] resize() {
//擴容方法
//---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr: -------------------------------------------
……
//此處源碼見前文“一、3.”
//---------------------------------------------------------2.擴容:------------------------------------------------------------------
……
//此處源碼見前文“一、3.”
//--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組里:------------------------------------------------
if
(oldTab !=
null
) {
//容量已經初始化過了:
for
(
int
j =
0
; j < oldCap; ++j) {
//一個桶一個桶去遍歷,j 用於記錄oldCap中當前桶的位置
Node<K,V> e;
if
((e = oldTab[j]) !=
null
) {
//當前桶上有節點,就賦值給e節點
oldTab[j] =
null
;
//把該節點置為null(現在這個桶上什么都沒有了)
if
(e.next ==
null
)
//e節點后沒有節點了:在新容器上重新計算e節點的放置位置《===== ①桶上只有一個節點
newTab[e.hash & (newCap -
1
)] = e;
else
if
(e
instanceof
TreeNode)
//e節點后面是紅黑樹:先將紅黑樹拆成2個子鏈表,再將子鏈表的頭節點放到新容器中《===== ②桶上是紅黑樹
((TreeNode<K,V>)e).split(
this
, newTab, j, oldCap);
else
{
// preserve order
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
) {
//“定位值等於0”的為一組:
if
(loTail ==
null
)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else
{
//“定位值不等於0”的為一組:
if
(hiTail ==
null
)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
}
while
((e = next) !=
null
);
//將分好的子鏈表放到newCap中:
if
(loTail !=
null
) {
loTail.next =
null
;
newTab[j] = loHead;
//原鏈表在oldCap的什么位置,“定位值等於0”的子鏈表的頭節點就放到newCap的什么位置
}
if
(hiTail !=
null
) {
hiTail.next =
null
;
newTab[j + oldCap] = hiHead;
//“定位值不等於0”的子節點的頭節點在newCap的位置 = 原鏈表在oldCap中的位置 + oldCap
}
}
}
}
}
return
newTab;
}
|
2.2 深入分析(含圖解)
① 如果桶上只有一個節點(后面即沒鏈表也沒樹):元素直接做 “hash & (newCap - 1)” 運算,根據結果將元素節點放到newCap的相應位置;
②如果桶上是鏈表:
將鏈表上的所有節點做 “hash & oldCap” 運算(注意,這里oldCap沒有-1),會得到一個定位值(“定位值”這個名字是我自己取的,為了更好理解該值的意義)。定位值要么是“0”,要么是“小於capacity的正整數”!這是個規律,之所以能得此規律和capacity取值一定是2的n次冪有直接關系,如果容量不是2的n次冪,那么定位值就不再要么是“0”,要么是“小於capacity的正整數”,它還有可能是其他的數;
根據定位值的不同,會將鏈表一分為二得到兩個子鏈表,這兩個子鏈表根據各自的定位值直接放到newCap中:
子鏈表的定位值 == 0: 則鏈表在oldCap中是什么位置,就將子鏈表的頭節點直接放到newCap的什么位置;
子鏈表的定位值 == 小於capacity的正整數:則將子鏈表的頭節點放到newCap的“oldCap + 定位值”的位置;
這么做的好處:鏈表在被拆分成兩個子鏈表前就已經處理過了元素的哈希碰撞問題,子鏈表不用重新處理哈希碰撞問題,可以直接將頭節點直接放到newCap的合適的位置上,完成 “擴容后將元素放到newCap”這一工作。正因為如此,大大提高了jdk1.8的HashMap的擴容效率。
下面將通過畫圖的形式,進一步理解HashMap到底是怎么將元素放到newCap中的。
前面我們說了jdk1.8的HashMap元素放到哪個桶中哪個位置,是通過計算 “(capacity - 1) & hash” 得到的余數來確定的。現在有四個元素,哈希值分別為35、27、19、43,當“容量 = 8”時,計算所得余數都等於3,所以這4個元素會被放到 table[3] 的位置,如下圖所示:
進行一次擴容后,現在容量 = 16,再次計算“(capacity - 1) & hash”后,這四個元素在newCap中的位置會有所變化:要么在原位置,要么在“oldCap + 原位置”;也就是說這四個元素被分成了兩組。如下圖所示:
下面我們不用 “(capacity - 1) & hash” 的方式來放置元素,而是根據jdk1.8中HashMap.resize()擴容方法來放置元素:先通過 “hash & oldCap” 得到定位值,再根據定位值同樣能將鏈表一分為二(見證奇跡的時候到了):
“定位值 = 0”的為一組,這組元素就是前面將容量從8擴到16后,通過“(newCap - 1) & hash” 計算確定 “放回原位置” 的那些元素;
“定位值 != 0”的為一組,這組元素就是擴容后,確定 “oldCap + 原位置”的那些元素。 如下圖所示:
再將這兩組元素節點分別連接成子鏈表:loHead是 “定位值 == 0” 的子鏈表的頭節點;hiHead是 “定位值 != 0” 的子鏈表的頭節點。如下圖所示:
最后,將子鏈表的頭節點loHead放到newCap中,位置和在oldCap中的原位置一致;將另一個子鏈表的頭節點hiHead放到newCap的“oldCap + 原位置”上。到這里HashMap就完成了擴容后將元素重新放到newCap中的工作了。如下圖所示:
到這里其實我們已經把 “容量一定是2的n次冪是 提高擴容后將元素重新放到newCap中的效率 的前提”解釋完了,現在還有一個小小的問題——通過定位值將鏈表一分為二,會分得均勻嗎?如果分得很不均勻會怎么樣?
眾所周知,要想HashMap的查詢速度快,那就得盡量做到讓元素均勻地散落到每個桶里。將鏈表平均分成兩個子鏈表,就意味着讓元素更均勻地放到桶中了,增加了元素散列性,從而提高了元素的查找效率。那jdk1.8又是如何將鏈表分得更平均的呢?這關系到兩點:①元素的哈希值更隨機、散列;②通過“hash & oldCap”中的oldCap再次增加元素放置位置的隨機性。第①點和哈希算法的實現直接相關,這里不細說;第②點的意思如下:
以 “capacity = 8” 為例,下面這些都是當 “容量 = 8” 時放在table[3]位置上的元素的hash值。擴容時做“hash & oldCap” 運算,通過下圖我們可以發現,oldCap所在的位上(即倒數第4位),元素的hash值在這個位是0還是1具有隨機性。
也就是說,jdk1.8在元素通過哈希算法使hash值已經具有隨機性的前提下,再做了一個增加元素放置位置隨機性的運算。
③如果桶上是紅黑樹:
將紅黑樹重新放到newCap中的邏輯和將鏈表重新放到newCap的的邏輯差不多。不同之處在於,重新放后,會將紅黑樹拆分成兩條由 TreeNode 組成的子鏈表:
此時,如果子鏈表長度 <= UNTREEIFY_THRESHOLD(即 <= 6 ),則將由 TreeNode組成的子鏈表 轉換成 由Node組成的普通子鏈表,然后再根據定位值將子鏈表的頭節點放到newCap中;
否則,根據條件重新將“由 TreeNode 組成的子鏈表”重新樹化,然后再根據定位值將樹的頭節點放到newCap中。
本文不對“HashMap擴容時紅黑樹在newCap中的重新放置”做詳細解釋,后面我會再寫一篇有關《紅黑樹轉回鏈表的具體時機》的博文,在這篇博文中會做詳細的源碼解析。
一言蔽之:jdk1.8 HashMap的容量一定要是2的n次冪,是為了提高“計算元素放哪個桶”的效率,也是為了提高擴容效率(避免了擴容后再重復處理哈希碰撞問題)。
更多技術資訊可關注:itheimaGZ獲取
