如何計算,一對key/value應該放在哪個雜湊桶
大家都知道,hashmap底層是陣列+連結串列(不討論紅黑樹的情況),其中,這個陣列,我們一般叫做雜湊桶,大家如果去看jdk的原始碼,會發現裡面有一些變數,叫做bin,這個bin,就是桶的意思,結合語境,就是雜湊桶。
這裡舉個例子,假設一個hashmap的陣列長度為4(0000 0100),那麼該hashmap就有4個雜湊桶,分別為bucket[0]、bucket[1]、bucket[2]、bucket[3]。
現在有兩個node,hashcode分別是1(0000 0001),5(0000 0101). 我們當然知道,這兩個node,都應該放入第一個桶,畢竟1 mod 4,5 mod 4的結果,都是1。
但是,在程式碼裡,可不是用取模的方法來計算的,而是使用下面的方式:
int entryNodeIndex = (tableLength - 1) & hash;
應該說,在tableLength的值,為2的n次冪的時候,兩者是等價的,但是因為位運算的效率更高,因此,程式碼一般都使用位運算,替代取模運算。
下面我們看看具體怎麼計算:
此處,tableLength即為雜湊表的長度,此處為4. 4 - 1為3,3的二進位制表示為:
0000 0011
那麼,和我們的1(0000 0001)相與:
0000 0001 -------- 1
0000 0011 -------- 3(tableLength - 1)
相與(同為1,則為1;否則為0)
0000 0001 -------- 1
結果為1,所以,應該放在第1個雜湊桶,即陣列下標為1的node。
接下來,看看5這個hashcode的節點要放在什麼位置,是怎麼計算:
0000 0101 -------- 5
0000 0011 -------- 3(tableLength - 1)
相與(同為1,則為1;否則為0)後結果:
0000 0001 -------- 1
擴容時,是怎麼對一個hash桶進行transfer的
此處,具體的整個transfer的細節,我們本講不會涉及太多,不過,大體的邏輯,我們可以來想一想。
以前面為例,雜湊表一共4個桶,其中bucket[1]裡面,存放了兩個元素,假設是a、b,其hashcode分別是1,5.
現在,假設我們要擴容,一般來說,擴容的時候,都是新建一個bucket陣列,其容量為舊錶的一倍,這裡舊錶為4,那新表就是8.
那,新表建立起來了,舊錶裡的元素,就得搬到新表裡面去,等所有元素都搬到新表了,就會把新表和舊錶的指標交換。如下:
java.util.concurrent.ConcurrentHashMap#transfer
private transient volatile Node<K,V>[] nextTable;
transient volatile Node<K,V>[] table;
if (finishing) {
// 1
nextTable = null;
// 2
table = nextTab;
// 3
sizeCtl = (tabLength << 1) - (tabLength >>> 1);
return;
}
-
1處,將field:nextTable(也就是新表)設為null,擴容完了,這個field就會設為null
-
2處,將區域性變數nextTab,賦值給table,這個區域性變數nextTab裡,就是當前已經擴容完畢的新表
-
3處,修改表的sizeCtl為:假設此處tabLength為4,tabLength << 1 左移1位,就是8;tabLength >>> 1,右移一位,就是2,。8 - 2 = 6,正好就等於 8(新表容量) * 0.75。
所以,這裡的sizeCtl就是,新表容量 * 負載因子,超過這個容量,基本就會觸發擴容。
ok,接著說,我們要怎麼從舊錶往新表搬呢? 那以前面的bucket[1]舉例,遍歷這個連結串列,計算各個node,應該放到新表的什麼位置,不就完了嗎?是的,理論上這麼寫就完事了。
但是,我們會怎麼寫呢?
用hashcode對新bucket陣列的長度取餘嗎?
jdk對效率的追求那麼高,肯定不會這麼寫的,我們看看,它怎麼寫的:
java.util.concurrent.ConcurrentHashMap#transfer
// 1
for (Node<K,V> p = entryNode; p != null; p = p.next) {
// 2
int ph = p.hash;
K pk = p.key;
V pv = p.val;
// 3
if ((ph & tabLength) == 0){
lowEntryNode = new Node<K,V>(ph, pk, pv, lowEntryNode);
}
else{
highEntryNode = new Node<K,V>(ph, pk, pv, highEntryNode);
}
}
-
1處,即遍歷舊的雜湊表的某個雜湊桶,假設就是遍歷前面的bucket[1],裡面有a/b兩個元素,hashcode分別為1,5那個。
-
2處,獲取該節點的hashcode,此處分別為1,5
-
3處,如果hashcode 和 舊錶長度相與,結果為0,則,將該節點使用頭插法,插入新表的低位;如果結果不為0,則放入高位。
ok,什麼是高位,什麼是低位。擴容後,新的bucket陣列,長度為8,那麼,前面bucket[1]中的兩個元素,將分別放入bucket[1]和bucket[5].
ok,這裡的bucket[1]就是低位,bucket[5]為高位。
首先,大家要知道,hashmap中,容量總是2的n次方,請牢牢記住這句話。
為什麼要這麼做?你想想,這樣是不是擴容很方便?
以前,hashcode 為1,5的,都在bucket[1];而現在,擴容為8後,hashcode為1的,還是在newbucket[1],hashcode為5的,則在newbucket[5];這樣的話,是不是有一半的元素,根本不用動?
這就是我覺得的,最大的好處;另外呢,運算也比較方便,都可以使用位運算代替,效率更高。
好的,那我們現在問題來了,下面這句的原理是什麼?
if ((ph & tabLength) == 0){
lowEntryNode = new Node<K,V>(ph, pk, pv, lowEntryNode);
} else{
highEntryNode = new Node<K,V>(ph, pk, pv, highEntryNode);
}
為啥,hashcode & 舊雜湊表的容量, 結果為0的,擴容後,就會在低位,也就是維持位置不變呢?而結果不為0的,擴容後,位置在高位呢?
背後的位運算原理(大白話)
程式碼裡用的如下判斷,滿足這個條件,去低位;否則,去高位。
if ((ph & tabLength) == 0)
還是用前面的例子,假設當前元素為a,hashcode為1,和雜湊桶大小4,去進行與
運算。
0000 0001 ---- 1
0000 0100 ---- 舊雜湊表容量4
&運算(同為1則為1,否則為0)
結果:
0000 0000 ---- 結果為0
ok,這裡算出來,結果為0;什麼情況下,結果會為0呢?
那我們現在開始倒推,什麼樣的數,和 0000 0100 相與,結果會為0?
???? ???? ----
0000 0100 ---- 舊雜湊表容量
&運算(同為1則為1,否則為0)
結果:
0000 0000 ---- 結果為0
因為與運算的規則是,同為1,則為1;否則都為0。那麼,我們這個例子裡,舊雜湊表容量為 0000 0100,假設表示為2的n次方,此處n為2,我們僅有第三位(第n+1)為1,那如果對方這一位為0,那結果中的這一位,就會為0,那麼,整個數,就為0.
所以,我們的結論是:假設雜湊表容量,為2的n次方,表示為二進位制後,第n+1位為1;那麼,只要我們節點的hashcode,在第n+1位上為0,則最終結果是0.
反之,如果我們節點的hashcode,在第n+1位為1,則最終結果不會是0.
比如,hashcode為5的時候,會是什麼樣子?
0000 0101 ---- 5
0000 0100 ---- 舊雜湊表容量
&運算(同為1則為1,否則為0)
結果:
0000 0100 ---- 結果為4
此時,5這個hashcode,在第n+1位上為1,所以結果不為0。
至此,我們離答案好像還很遠。ok,不慌,繼續。
假設現在擴容了,新bucket陣列,長度為8.
a元素,hashcode依然是1,a元素應該放到新bucket陣列的哪個bucket裡呢?
我們用前面說的這個演算法來計算:
int entryNodeIndex = (tableLength - 1) & hash;
0000 0001 ---- 1
0000 0111 ---- 8 - 1 = 7
&運算(同為1則為1,否則為0)
結果:
0000 0001 ---- 結果為1
結果沒錯,確實應該放到新bucket[1],但怎麼推論出來呢?
// 1
if ((ph & tabLength) == 0){
// 2
lowEntryNode = new Node<K,V>(ph, pk, pv, lowEntryNode);
}
也就是說,假設一個數,滿足1處的條件:(ph & tabLength) == 0,那怎麼推論出2呢,即應該在低位呢?
ok,條件1,前面分析了,可以得出:
這個數,第n+1位為0.
接下來,看看陣列長度 - 1這個數。
陣列長度 | 2的n次方 | 二進位制表示 | 1出現的位置 | 陣列長度-1 | 陣列長度-1的二進位制 |
---|---|---|---|---|---|
2 | 2的1次方 | 0000 0010 | 第2位 | 1 | 0000 0001 |
4 | 2的2次方 | 0000 0100 | 第3位 | 3 | 0000 0011 |
8 | 2的3次方 | 0000 1000 | 第4位 | 7 | 0000 0111 |
好了,兩個數都有了,
???????0??????? -- 1 節點的hashcode,第n + 1位為0
000000010000000 -- 2 老陣列
000000100000000 -- 3 新陣列的長度,等於老陣列長度 * 2
000000011111111 -- 4 新陣列的長度 - 1
運算:1和4相與
大家注意看紅字部分,還有框出來的那一列,這一列為0,導致,最終結果,肯定是比2那一行的數字小,2這行,不就是老陣列的長度嗎,那你比老陣列小;你比這一行小,在新陣列裡,就只能在低位了。
反之,如果節點的hashcode,這一位為1,那麼,最終結果,至少是大於等於2這一行的數字,所以,會放在高位。