一、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獲取