HashMap 是面試的釘子戶了,網上分析的文章也有不少,相信你們對於原理已經爛俗於心了。但最近在看源碼時,發現其中一些實現細節其實不太好理解,因此決定以問答的形式在這裏記錄一下,寫的時候儘可能把緣由說明白。面試
容量並非指 HashMap 所能存儲的鍵值對數量,而是其內部的 table 數組的大小,而 size 是指目前已存儲的鍵值對的數量。table 是一個 Entry 數組。 table 的每個節點都連着一個鏈表或者紅黑樹。數組
能夠,可是 HashMap 內部會你設置的 initialCapacity 轉換爲大於等於它的最小的2的n次方。好比 20 轉爲 32,32 轉爲 32等。若是不設置,則爲默認值16。須要注意的是,在 Java 8的源碼中,並無在構造方法直接新建數組。而是先將處理後的容量值賦給 threshold,在第一次存儲鍵值對時再根據這個值建立數組。bash
這樣能夠提升取餘的效率。爲了防止鏈表過長,要保證鍵值對在數組中儘量均勻分佈,因此在計算出 key 的 hash 值以後,須要對數組的容量進行取餘,餘數即爲鍵值對在 table 中的 index。 對於計算機而言,二進制位運算的效率高於取餘(%)操做。而若是容量是 2 的 n 次方的話,hash 值對其取餘就等同於 hash 值和容量值減1進行按位與(&)操做:函數
// capacity 爲 2 的 n 次方的話,下面兩個操做結果相同
hash & (capacity -1)
等同於
hash % capacity
複製代碼
那爲何兩種操做等同呢? 咱們以2進制的思惟想一下,若是一個數是 2 的 n 次方,那它的二進制就是從右往左 n 位都爲0,n+1 位爲1。好比2的3次方就是 1000。這個數的倍數也知足從右往左 n 位都爲0,取餘的時候拋棄倍數,就等同於將 n+1 位及其往左的全部位歸0,剩下的 n 位就表明餘數。換句話說,一個數對2的 n 次方取餘,就是要取這個數二進制的最低 n 位。 2 的 n 次方減1的結果就是 n 位1,進行與操做後就獲得了最低 n 位。ui
咱們先假設一個二進制數 cap,cap 的二進制有 a 位(不算前面高位的0),那麼,大於它的最小的2的次方就是2的 a 次方。2 的 a 次方減一的結果就是 n 位1,那咱們只要將 cap 的所有 2 進制位變爲1,再加1就能獲得結果。而爲了防止 cap 自己就是2的 n 次方,咱們在計算以前先將 cap 自減。this
如何將二進制位都變成1呢?下面是源碼:spa
static final int tableSizeFor(int cap) {
int n = cap - 1; //這一步是爲了不 cap 恰好爲2的 n 次方
n |= n >>> 1; //保證前2位是1
n |= n >>> 2; //保證前4位是1
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製代碼
下面的描述中 n 的位數都不包括前面補位的0。code
|= 這個符號不常見,a |= b 就是 a = a|b 的意思。代碼中先將 n 無符號右移1位,因爲n 的第1位確定是1,移位後第2位是1,|
操做後前2位就保證是1了。第二步右移了2位再進行|
操做,保證了前4位是1,後面的計算相似,因爲 n 最多有32位,因此一直操做到右移16爲止。這樣就將 n 的全部2進制位都變成了1,最後自增返回。ci
好比一個數 10010,完整過程以下:源碼
//右移一位
00010010 -> 00001001
//進行或操做
00010010 |= 00001001 -> 00011011 //保證前面2位是1
//右移兩位
00011011 -> 00000110
//進行或操做
00011011 |= 00000110 -> 00011111 //保證了前面4位是1
//依此類推
...
複製代碼
hash 值並無直接返回 hashcode 的返回值,而是進行了一些處理。 前面提到過,hash 值算出來後須要進行取餘操做,影響取餘結果的是 hash 值的低 n 位。若是低 n 位是固定的或者集中在幾個值,那麼取餘的結果容易相同,致使 hash 碰撞的發生。因爲 hashcode 函數能夠被重寫,重寫者可能無心識地就寫了一個不合理的 hash 函數,致使上面這種狀況發生。
爲了不這種狀況,HashMap 將 hash 值先向右移位,再進行或操做,這樣就使高位的值和低位的值融合成一個新的值,保證取餘結果受每個二進制位的影響。Java 7和 Java 8的原理都同樣,但源碼有細微出入,多是由於 Java 通過統計發現移位一次就夠了吧。
//Java 7
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//Java 8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
爲了防止元素增多後,鏈表愈來愈長,HashMap 會在元素個數達到閾值後進行擴容,新的容量爲舊容量的2倍。 容量變化後,每一個元素用 hash 值取餘的結果也會隨之變化,須要在數組中從新排列。之前同一條鏈表上的元素,擴容後可能存在不一樣的鏈表上。
在 Java 7 中,從新排列實現得簡單粗暴,直接用 hash 根據新容量算出下標,而後設置到新數組中,即至關於將元素從新 put 了一次。但在 Java 8中,做者發現不必從新插入,由於從新計算後,新的下標只可能有兩種狀況,要麼是原來的值,要麼是原來的值加舊容量。好比容量爲16的數組擴容到32,下標爲1的元素從新計算後,下標只可能爲1或17。
這個怎麼理解呢?重提一下以前的一句話,一個數對2的 n 次方取餘,就是要取這個數二進制的最低 n 位。當容量爲16時,取餘是取最後4位的值,而擴容到32後,取餘變成取最後5位的值。這裏增長的1位若是爲0,那麼餘數就沒變,若是爲1,那麼餘數就增長了16。如何取增長的這一位的值呢?直接和16進行與操做便可。16的二進制是10000,與操做後若是結果爲0,即表示高位爲0,不然爲1。
根據這個原理,咱們只須要將原來的鏈表分紅兩條新鏈放到對應的位置便可,下面是具體步驟:
理解原理後看代碼就很簡單了:
if (oldTab != null) {
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 { // 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,表示下標沒變,放入 lo 鏈
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //結果爲0,表示下標要加上舊容量,放入 hi 鏈
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //lo 鏈放在原來的下標處
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //hi 鏈放在原來的下標 加舊容量處
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
複製代碼