HashMap 這一篇就夠了

二狗:先來點簡單的,介紹下 HashMap 的底層數據結構吧。
囧輝:我們現在用的都是 JDK 1.8,底層是由“數組+鏈表+紅黑樹”組成,如下圖,而在 JDK 1.8 之前是由“數組+鏈表”組成。

在這裏插入圖片描述
二狗:爲什麼要改成“數組+鏈表+紅黑樹”?
囧輝:主要是爲了提升在 hash 衝突嚴重時(鏈表過長)的查找性能,使用鏈表的查找性能是 O(n),而使用紅黑樹是 O(logn)。

二狗:那在什麼時候用鏈表?什麼時候用紅黑樹?
囧輝:對於插入,默認情況下是使用鏈表節點。當同一個索引位置的節點在新增後達到9個(閾值8):如果此時數組長度大於等於 64,則會觸發鏈表節點轉紅黑樹節點(treeifyBin);而如果數組長度小於64,則不會觸發鏈表轉紅黑樹,而是會進行擴容,因爲此時的數據量還比較小。

對於移除,當同一個索引位置的節點在移除後達到 6 個,並且該索引位置的節點爲紅黑樹節點,會觸發紅黑樹節點轉鏈表節點(untreeify)。

二狗:爲什麼鏈表轉紅黑樹的閾值是8?
囧輝:我們平時在進行方案設計時,必須考慮的兩個很重要的因素是:時間和空間。對於 HashMap 也是同樣的道理,簡單來說,閾值爲8是在時間和空間上權衡的結果(這 B 我裝定了)。

紅黑樹節點大小約爲鏈表節點的2倍,在節點太少時,紅黑樹的查找性能優勢並不明顯,付出2倍空間的代價作者覺得不值得。

理想情況下,使用隨機的哈希碼,節點分佈在 hash 桶中的頻率遵循泊松分佈,按照泊松分佈的公式計算,鏈表中節點個數爲8時的概率爲 0.00000006(跟大樂透一等獎差不多,中大樂透?不存在的),這個概率足夠低了,並且到8個節點時,紅黑樹的性能優勢也會開始展現出來,因此8是一個較合理的數字。

二狗:(呦呦呦,時間和空間上權衡的結果,還裝起B來了)那爲什麼轉回鏈表節點是用的6而不是複用8?
囧輝:如果我們設置節點多於8個轉紅黑樹,少於8個就馬上轉鏈表,當節點個數在8徘徊時,就會頻繁進行紅黑樹和鏈表的轉換,造成性能的損耗。

二狗:(小菜雞,懂得還不少)那 HashMap 有哪些重要屬性?分別用於做什麼的?
囧輝:除了用來存儲我們的節點 table 數組外,HashMap 還有以下幾個重要屬性:1)size:HashMap 已經存儲的節點個數;2)threshold:擴容閾值,當 HashMap 的個數達到該值,觸發擴容。3)loadFactor:負載因子,擴容閾值 = 容量 * 負載因子。

二狗:threshold 除了用於存放擴容閾值還有其他作用嗎?
囧輝:在我們新建 HashMap 對象時, threshold 還會被用來存初始化時的容量。HashMap 直到我們第一次插入節點時,纔會對 table 進行初始化,避免不必要的空間浪費。

二狗:HashMap 的默認初始容量是多少?HashMap 的容量有什麼限制嗎?
囧輝:默認初始容量是16。HashMap 的容量必須是2的N次方,HashMap 會根據我們傳入的容量計算一個大於等於該容量的最小的2的N次方,例如傳 9,容量爲16。

二狗:(你他孃的是在繞口令吧)你這個*@%¥#&的N次方是怎麼算的?
囧輝:Talk is cheap. Show you the code。

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;
}

二狗:臥槽,還彪英文,來來來,這代碼你給我解釋下。
囧輝:我們先不看第一行“int n = cap - 1”,先看下面的5行計算。
|=(或等於):這個符號比較少見,但是“+=”應該都見過,看到這你應該明白了。例如:a |= b ,可以轉成:a = a | b。

在這裏插入圖片描述
>>>(無符號右移):例如 a >>> b 指的是將 a 向右移動 b 指定的位數,右移後左邊空出的位用零來填充,移出右邊的位被丟棄。
在這裏插入圖片描述
假設 n 的值爲 0010 0001,則該計算如下圖:
在這裏插入圖片描述
相信你應該看出來,這5個公式會通過最高位的1,拿到2個1、4個1、8個1、16個1、32個1。當然,有多少個1,取決於我們的入參有多大,但我們肯定的是經過這5個計算,得到的值是一個低位全是1的值,最後返回的時候 +1,則會得到1個比n 大的 2 的N次方。
這時再看開頭的 cap - 1 就很簡單了,這是爲了處理 cap 本身就是 2 的N次方的情況。
計算機底層是二進制的,移位和或運算是非常快的,所以這個方法的效率很高。
PS:這是 HashMap 中我個人最喜歡的設計,非常巧妙,真想給作者一個麼麼噠(不小心暴露了)。

二狗:(這叼毛講的還湊合啊,連我都聽懂了)你說 HashMap 的容量必須是 2 的 N 次方,這是爲什麼?
囧輝:計算索引位置的公式爲:(n - 1) & hash,當 n 爲 2 的 N 次方時,n - 1 爲低位全是 1 的值,此時任何值跟 n - 1 進行 & 運算會等於其本身,達到了和取模同樣的效果,實現了均勻分佈。實際上,這個設計就是基於公式:x mod 2^n = x & (2^n - 1),因爲 & 運算比 mod 具有更高的效率。
如下圖,當 n 不爲 2 的 N 次方時,hash 衝突的概率明顯增大。

在這裏插入圖片描述
二狗:你說 HashMap 的默認初始容量是 16,爲什麼是16而不是其他的?
囧輝:(這是什麼煞筆問題)我認爲是16的原因主要是:16是2的N次方,並且是一個較合理的大小。如果用8或32,我覺得也是OK的。實際上,我們在新建 HashMap 時,最好是根據自己使用情況設置初始容量,這纔是最合理的方案。

二狗:剛纔說的負載因子默認初始值又是多少?
囧輝:負載因子默認值是0.75。

二狗:爲什麼是0.75而不是其他的?
囧輝:(又問這種憨逼問題)這個也是在時間和空間上權衡的結果。如果值較高,例如1,此時會減少空間開銷,但是 hash 衝突的概率會增大,增加查找成本;而如果值較低,例如 0.5 ,此時 hash 衝突會降低,但是有一半的空間會被浪費,所以折衷考慮 0.75 似乎是一個合理的值。

二狗:爲什麼不是 0.74 或 0.76?
囧輝:
二狗:那我們換個問題問吧,HashMap 的插入流程是怎麼樣的?
囧輝:Talk is cheap. Show you the picture。
在這裏插入圖片描述
二狗:圖裏剛開始有個計算 key 的 hash 值,是怎麼設計的?
囧輝:拿到 key 的 hashCode,並將 hashCode 的高16位和 hashCode 進行異或(XOR)運算,得到最終的 hash 值。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

二狗:爲什麼要將 hashCode 的高16位參與運算?
囧輝:主要是爲了在 table 的長度較小的時候,讓高位也參與運算,並且不會有太大的開銷。
例如下圖,如果不加入高位運算,由於 n - 1 是 0000 0111,所以結果只取決於 hash 值的低3位,無論高位怎麼變化,結果都是一樣的。

在這裏插入圖片描述
如果我們將高位參與運算,則索引計算結果就不會僅取決於低位。
在這裏插入圖片描述

二狗:擴容(resize)流程介紹下?
囧輝:

在這裏插入圖片描述
二狗:紅黑樹和鏈表都是通過 e.hash & oldCap == 0 來定位在新表的索引位置,這是爲什麼?
囧輝:請看對下面的例子。
擴容前 table 的容量爲16,a 節點和 b 節點在擴容前處於同一索引位置。
在這裏插入圖片描述
擴容後,table 長度爲32,新表的 n - 1 只比老表的 n - 1 在高位多了一個1(圖中標紅)。

在這裏插入圖片描述
因爲 2 個節點在老表是同一個索引位置,因此計算新表的索引位置時,只取決於新表在高位多出來的這一位(圖中標紅),而這一位的值剛好等於 oldCap。

因爲只取決於這一位,所以只會存在兩種情況:1) (e.hash & oldCap) == 0 ,則新表索引位置爲“原索引位置” ;2)(e.hash & oldCap) == 1,則新表索引位置爲“原索引 + oldCap 位置”。

二狗:HashMap 是線程安全的嗎?
囧輝:不是。HashMap 在併發下存在數據覆蓋、遍歷的同時進行修改會拋出 ConcurrentModificationException 異常等問題,JDK 1.8 之前還存在死循環問題。

二狗:介紹一下死循環問題?
囧輝:導致死循環的根本原因是 JDK 1.7 擴容採用的是“頭插法”,會導致同一索引位置的節點在擴容後順序反掉。而 JDK 1.8 之後採用的是“尾插法”,擴容後節點順序不會反掉,不存在死循環問題。

JDK 1.7.0 的擴容代碼如下,用例子來看會好理解點。

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

PS:這個流程較難理解,建議對着代碼自己模擬走一遍。
例子:我們有1個容量爲2的 HashMap,loadFactor=0.75,此時線程1和線程2 同時往該 HashMap 插入一個數據,都觸發了擴容流程,接着有以下流程。

1)在2個線程都插入節點,觸發擴容流程之前,此時的結構如下圖。

在這裏插入圖片描述
2)線程1進行擴容,執行到代碼:Entry<K,V> next = e.next 後被調度掛起,此時的結構如下圖。

在這裏插入圖片描述
3)線程1被掛起後,線程2進入擴容流程,並走完整個擴容流程,此時的結構如下圖。
在這裏插入圖片描述
由於兩個線程操作的是同一個 table,所以該圖又可以畫成如下圖。
在這裏插入圖片描述

4)線程1恢復後,繼續走完第一次的循環流程,此時的結構如下圖。
在這裏插入圖片描述

5)線程1繼續走完第二次循環,此時的結構如下圖。

在這裏插入圖片描述
6)線程1繼續執行第三次循環,執行到 e.next = newTable[i] 時形成環,執行完第三次循環的結構如下圖。
在這裏插入圖片描述
如果此時線程1調用 map.get(11) ,悲劇就出現了——Infinite Loop。

二狗:(尼瑪,沒聽懂,尷尬了)那總結下 JDK 1.8 主要進行了哪些優化?
囧輝:JDK 1.8 的主要優化剛纔我們都聊過了,主要有以下幾點:

1)底層數據結構從“數組+鏈表”改成“數組+鏈表+紅黑樹”,主要是優化了 hash 衝突較嚴重時,鏈表過長的查找性能:O(n) -> O(logn)。

2)計算 table 初始容量的方式發生了改變,老的方式是從1開始不斷向左進行移位運算,直到找到大於等於入參容量的值;新的方式則是通過“5個移位+或等於運算”來計算。

// JDK 1.7.0
public HashMap(int initialCapacity, float loadFactor) {
    // 省略
    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;
    // ... 省略
}
// JDK 1.8.0_191
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;
}

3)優化了 hash 值的計算方式,老的通過一頓瞎JB操作,新的只是簡單的讓高16位參與了運算。

// JDK 1.7.0
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8.0_191
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

4)擴容時插入方式從“頭插法”改成“尾插法”,避免了併發下的死循環。

5)擴容時計算節點在新表的索引位置方式從“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但設計更巧妙、更優雅。

二狗:除了 HashMap,還用過哪些 Map,在使用時怎麼選擇?
囧輝:
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章