一個HashMap能跟面試官扯上半個小時
《安琪拉與面試官二三事》系列文章
一個HashMap能跟面試官扯上半個小時
一個synchronized跟面試官扯了半個小時
一個volatile跟面試官扯了半個小時
《安琪拉教魯班學演算法》系列文章
前言
HashMap應該算是Java後端工程師面試的必問題,因為其中的知識點太多,很適合用來考察面試者的Java基礎。
開場
面試官: 你先自我介紹一下吧!
安琪拉: 我是安琪拉,草叢三婊之一,最強中單(鍾馗不服)!哦,不對,串場了,我是**,目前在--公司做--系統開發。
面試官: 看你簡歷上寫熟悉Java集合,HashMap用過的吧?
安琪拉: 用過的。(還是熟悉的味道)
面試官: 那你跟我講講HashMap的內部資料結構?
安琪拉: 目前我用的是JDK1.8版本的,內部使用陣列 + 連結串列紅黑樹;
安琪拉: 方便我給您畫個資料結構圖吧:
面試官: 那你清楚HashMap的資料插入原理嗎?
安琪拉: 呃[做沉思狀]。我覺得還是應該畫個圖比較清楚,如下:
- 判斷陣列是否為空,為空進行初始化;
- 不為空,計算 k 的 hash 值,通過
(n - 1) & hash
計算應當存放在陣列中的下標 index; - 檢視 table[index] 是否存在資料,沒有資料就構造一個Node節點存放在 table[index] 中;
- 存在資料,說明發生了hash衝突(存在二個節點key的hash值一樣), 繼續判斷key是否相等,相等,用新的value替換原資料(onlyIfAbsent為false);
- 如果不相等,判斷當前節點型別是不是樹型節點,如果是樹型節點,創造樹型節點插入紅黑樹中;(如果當前節點是樹型節點證明當前已經是紅黑樹了)
- 如果不是樹型節點,建立普通Node加入連結串列中;判斷連結串列長度是否大於 8並且陣列長度大於64, 大於的話連結串列轉換為紅黑樹;
- 插入完成之後判斷當前節點數是否大於閾值,如果大於開始擴容為原陣列的二倍。
面試官: 陷入沉默,講的這麼清楚,難道是也關注了微信公眾號【安琪拉的部落格】,我繼續按照套路問,剛才你提到HashMap的初始化,那HashMap怎麼設定初始容量大小的嗎?
安琪拉: [這也算問題??] 一般如果new HashMap()
不傳值,預設大小是16,負載因子是0.75, 如果自己傳入初始大小k,初始化大小為 大於k的 2的整數次方,例如如果傳10,大小為16。(補充說明:實現程式碼如下)
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;
}
補充說明:下圖是詳細過程,演算法就是讓初始二進位制右移1,2,4,8,16位,分別與自己位或,把高位第一個為1的數通過不斷右移,把高位為1的後面全變為1,最後再進行+1操作,111111 + 1 = 1000000 = $2^6$ (符合大於50並且是2的整數次冪 )
面試官: 你提到hash函式,你知道HashMap的雜湊函式怎麼設計的嗎?
安琪拉: [問的還挺細] hash函式是先拿到 key 的hashcode,是一個32位的int值,然後讓hashcode的高16位和低16位進行異或操作。
面試官: 那你知道為什麼這麼設計嗎?
安琪拉: [這也要問],這個也叫擾動函式,這麼設計有二點原因:
- 一定要儘可能降低hash碰撞,越分散越好;
- 演算法一定要儘可能高效,因為這是高頻操作, 因此採用位運算;
面試官: 為什麼採用hashcode的高16位和低16位異或能降低hash碰撞?hash函式能不能直接用key的hashcode?
[這問題有點刁鑽], 安琪拉差點原地?了,恨不得出biubiubiu 二一三連招。
安琪拉: 因為key.hashCode()函式呼叫的是key鍵值型別自帶的雜湊函式,返回int型雜湊值。int值範圍為-2147483648~2147483647,前後加起來大概40億的對映空間。只要雜湊函式對映得比較均勻鬆散,一般應用是很難出現碰撞的。但問題是一個40億長度的陣列,記憶體是放不下的。你想,如果HashMap陣列的初始大小才16,用之前需要對陣列的長度取模運算,得到的餘數才能用來訪問陣列下標。(來自知乎-胖君)
原始碼中模運算就是把雜湊值和陣列長度-1做一個"與"操作,位運算比取餘%運算要快。
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
順便說一下,這也正好解釋了為什麼HashMap的陣列長度要取2的整數冪。因為這樣(陣列長度-1)正好相當於一個“低位掩碼”。“與”操作的結果就是雜湊值的高位全部歸零,只保留低位值,用來做陣列下標訪問。以初始長度16為例,16-1=15。2進製表示是00000000 00000000 00001111。和某雜湊值做“與”操作如下,結果就是擷取了最低的四位值。
10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101 //高位全部歸零,只保留末四位
但這時候問題就來了,這樣就算我的雜湊值分佈再鬆散,要是隻取最後幾位的話,碰撞也會很嚴重。更要命的是如果雜湊本身做得不好,分佈上成等差數列的漏洞,如果正好讓最後幾個低位呈現規律性重複,就無比蛋疼。
時候“擾動函式”的價值就體現出來了,說到這裡大家應該猜出來了。看下面這個圖,
右移16位,正好是32bit的一半,自己的高半區和低半區做異或,就是為了混合原始雜湊碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特徵,這樣高位的資訊也被變相保留下來。
最後我們來看一下Peter Lawley的一篇專欄文章《An introduction to optimising a hashing strategy》裡的的一個實驗:他隨機選取了352個字串,在他們雜湊值完全沒有衝突的前提下,對它們做低位掩碼,取陣列下標。
結果顯示,當HashMap陣列長度為512的時候($2^9$),也就是用掩碼取低9位的時候,在沒有擾動函式的情況下,發生了103次碰撞,接近30%。而在使用了擾動函式之後只有92次碰撞。碰撞減少了將近10%。看來擾動函式確實還是有功效的。
另外Java1.8相比1.7做了調整,1.7做了四次移位和四次異或,但明顯Java 8覺得擾動做一次就夠了,做4次的話,多了可能邊際效用也不大,所謂為了效率考慮就改成一次了。
下面是1.7的hash程式碼:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
面試官: 看來做過功課,有點料啊!是不是偷偷看了公眾號安琪拉的部落格, 你剛剛說到1.8對hash函式做了優化,1.8還有別的優化嗎?
安琪拉: 1.8還有三點主要的優化:
- 陣列+連結串列改成了陣列+連結串列或紅黑樹;
- 連結串列的插入方式從頭插法改成了尾插法,簡單說就是插入時,如果陣列位置上已經有元素,1.7將新元素放到陣列中,原始節點作為新節點的後繼節點,1.8遍歷連結串列,將元素放置到連結串列的最後;
- 擴容的時候1.7需要對原陣列中的元素進行重新hash定位在新陣列的位置,1.8採用更簡單的判斷邏輯,位置不變或索引+舊容量大小;
- 在插入時,1.7先判斷是否需要擴容,再插入,1.8先進行插入,插入完成再判斷是否需要擴容;
面試官: 你分別跟我講講為什麼要做這幾點優化;
安琪拉: 【咳咳,果然是連環炮】
-
防止發生hash衝突,連結串列長度過長,將時間複雜度由
O(n)
降為O(logn)
; -
因為1.7頭插法擴容時,頭插法會使連結串列發生反轉,多執行緒環境下會產生環;
A執行緒在插入節點B,B執行緒也在插入,遇到容量不夠開始擴容,重新hash,放置元素,採用頭插法,後遍歷到的B節點放入了頭部,這樣形成了環,如下圖所示:
1.7的擴容呼叫transfer程式碼,如下所示:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; //A執行緒如果執行到這一行掛起,B執行緒開始進行擴容 newTable[i] = e; e = next; } } }
-
擴容的時候為什麼1.8 不用重新hash就可以直接定位原節點在新資料的位置呢?
這是由於擴容是擴大為原陣列大小的2倍,用於計算陣列位置的掩碼僅僅只是高位多了一個1,怎麼理解呢?
擴容前長度為16,用於計算(n-1) & hash 的二進位制n-1為0000 1111,擴容為32後的二進位制就高位多了1,為0001 1111。
因為是& 運算,1和任何數 & 都是它本身,那就分二種情況,如下圖:原資料hashcode高位第4位為0和高位為1的情況;
第四位高位為0,重新hash數值不變,第四位為1,重新hash數值比原來大16(舊陣列的容量)
面試官: 那HashMap是執行緒安全的嗎?
安琪拉: 不是,在多執行緒環境下,1.7 會產生死迴圈、資料丟失、資料覆蓋的問題,1.8 中會有資料覆蓋的問題,以1.8為例,當A執行緒判斷index位置為空後正好掛起,B執行緒開始往index位置的寫入節點資料,這時A執行緒恢復現場,執行賦值操作,就把A執行緒的資料給覆蓋了;還有++size這個地方也會造成多執行緒同時擴容等問題。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //多執行緒執行到這裡
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 這裡很重要
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 多個執行緒走到這,可能重複resize()
resize();
afterNodeInsertion(evict);
return null;
}
面試官: 那你平常怎麼解決這個執行緒不安全的問題?
安琪拉: Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以實現執行緒安全的Map。
HashTable是直接在操作方法上加synchronized關鍵字,鎖住整個陣列,粒度比較大,Collections.synchronizedMap是使用Collections集合工具的內部類,通過傳入Map封裝出一個SynchronizedMap物件,內部定義了一個物件鎖,方法內通過物件鎖實現;ConcurrentHashMap使用分段鎖,降低了鎖粒度,讓併發度大大提高。
面試官: 那你知道ConcurrentHashMap的分段鎖的實現原理嗎?
安琪拉: 【天啦擼! 俄羅斯套娃,一個套一個】ConcurrentHashMap成員變數使用volatile 修飾,免除了指令重排序,同時保證記憶體可見性,另外使用CAS操作和synchronized結合實現賦值操作,多執行緒操作只會鎖住當前操作索引的節點。
如下圖,執行緒A鎖住A節點所在連結串列,執行緒B鎖住B節點所在連結串列,操作互不干涉。
面試官: 你前面提到連結串列轉紅黑樹是連結串列長度達到閾值,這個閾值是多少?
安琪拉: 閾值是8,紅黑樹轉連結串列閾值為6
面試官: 為什麼是8,不是16,32甚至是7 ?又為什麼紅黑樹轉連結串列的閾值是6,不是8了呢?
安琪拉: 【你去問作者啊!天啦擼,biubiubiu 真想213連招】因為作者就這麼設計的,哦,不對,因為經過計算,在hash函式設計合理的情況下,發生hash碰撞8次的機率為百萬分之6,概率說話。。因為8夠用了,至於為什麼轉回來是6,因為如果hash碰撞次數在8附近徘徊,會一直髮生連結串列和紅黑樹的互相轉化,為了預防這種情況的發生。
面試官: HashMap內部節點是有序的嗎?
安琪拉: 是無序的,根據hash值隨機插入
面試官: 那有沒有有序的Map?
安琪拉: LinkedHashMap 和 TreeMap
面試官: 跟我講講LinkedHashMap怎麼實現有序的?
安琪拉: LinkedHashMap內部維護了一個單連結串列,有頭尾節點,同時LinkedHashMap節點Entry內部除了繼承HashMap的Node屬性,還有before 和 after用於標識前置節點和後置節點。可以實現按插入的順序或訪問順序排序。
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
//連結新加入的p節點到連結串列後端
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
//LinkedHashMap的節點類
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
示例程式碼:
public static void main(String[] args) {
Map<String, String> map = new LinkedHashMap<String, String>();
map.put("1", "安琪拉");
map.put("2", "的");
map.put("3", "部落格");
for(Map.Entry<String,String> item: map.entrySet()){
System.out.println(item.getKey() + ":" + item.getValue());
}
}
//console輸出
1:安琪拉
2:的
3:部落格
面試官: 跟我講講TreeMap怎麼實現有序的?
安琪拉:TreeMap是按照Key的自然順序或者Comprator的順序進行排序,內部是通過紅黑樹來實現。所以要麼key所屬的類實現Comparable介面,或者自定義一個實現了Comparator介面的比較器,傳給TreeMap用於key的比較。
面試官: 前面提到通過CAS 和 synchronized結合實現鎖粒度的降低,你能給我講講CAS 的實現以及synchronized的實現原理嗎?
安琪拉: 下一期咋們再約時間,OK?
面試官: 好吧,回去等通知吧!
回覆評論區的幾個問題:
- @掌心一點微笑: put方法時候,指定位置存在資料->否->存放節點 -> 放入紅黑樹節點嗎?不應該是存放節點->節點數是否大於閾值?這裡不懂,求大佬解釋
這個地方圖畫的確實有問題,感謝指正,已更新。 - @海淀好男孩:初始容量不是2的冪會自動改成2的冪那裡有些錯誤吧,50的二進位制和下面的無符號右移4位不對啊
這裡是以50做初始值演示的,先進行-1 操作然後開始二進位制運算的。
參考資料
- An introduction to optimising a hashing strategy
- JDK 原始碼中 HashMap 的 hash 方法原理是什麼?
- 淡騰的楓-HashMap中的hash函式
關注Wx公眾號:【安琪拉的部落格】 —揭祕Java後端技術,還原技術背後的本質
《安琪拉與面試官二三事》系列文章 持續更新中
一個HashMap能跟面試官扯上半個小時
一個synchronized跟面試官扯了半個小時