問:介紹下 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 還有如下幾個重要屬性:
問: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,在使用時怎麼選擇?
答: