【轉載】面試阿里,HashMap 這一篇就夠了

問:介紹下 HashMap 的底層數據結構吧數組

答:JDK 1.8中底層是由「數組+鏈表+紅黑樹」組成,以下圖,而在 JDK 1.8 以前是由「數組+鏈表」組成。安全

問:爲何要改爲「數組+鏈表+紅黑樹」?數據結構

答:主要是爲了提高在 hash 衝突嚴重時(鏈表過長)的查找性能,使用鏈表的查找性能是 O(n),而使用紅黑樹是 O(logn)。併發

問:在何時用鏈表?何時用紅黑樹?oop

答:對於插入,默認狀況下是使用鏈表節點。當同一個索引位置的節點在新增後達到9個(閾值8):若是此時數組長度大於等於 64,則會觸發鏈表節點轉紅黑樹節點(treeifyBin);而若是數組長度小於64,則不會觸發鏈表轉紅黑樹,而是會進行擴容,由於此時的數據量還比較小。性能

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

問:爲何鏈表轉紅黑樹的閾值是8?spa

答:在進行方案設計時,必須考慮的兩個很重要的因素是:時間和空間。對於 HashMap 也是一樣的道理,簡單來講,閾值爲8是在時間和空間上權衡的結果。線程

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

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

問:爲何轉回鏈表節點是用的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。

問:這個2的N次方是怎麼算的?

答:

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個一、4個一、8個一、16個一、32個1。固然,有多少個1,取決於咱們的入參有多大,但咱們確定的是通過這5個計算,獲得的值是一個低位全是1的值,最後返回的時候 +1,則會獲得1個比n 大的 2 的N次方。

這時再看開頭的 cap - 1 就很簡單了,這是爲了處理 cap 自己就是 2 的N次方的狀況。

計算機底層是二進制的,移位和或運算是很是快的,因此這個方法的效率很高。

問: 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 彷佛是一個合理的值。

問:HashMap 的插入流程是怎麼樣的?

答:

問:計算 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,在使用時怎麼選擇?

答:

相關文章
相關標籤/搜索