關注 安琪拉的博客
1.回覆面試領取面試資料
2.回覆書籍領取技術電子書
3.回覆交流領取技術電子書html
HashMap應該算是Java後端工程師面試的必問題,由於其中的知識點太多,很適合用來考察面試者的Java基礎。java
面試官: 你先自我介紹一下吧!c++
安琪拉: 我是安琪拉,草叢三婊之一,最強中單(鍾馗不服)!哦,不對,串場了,我是**,目前在--公司作--系統開發。面試
面試官: 看你簡歷上寫熟悉Java集合,HashMap用過的吧?算法
安琪拉: 用過的。(仍是熟悉的味道)後端
面試官: 那你跟我講講HashMap的內部數據結構?數組
安琪拉: 目前我用的是JDK1.8版本的,內部使用數組 + 鏈表紅黑樹;安全
安琪拉: 方便我給您畫個數據結構圖吧:bash
面試官: 那你清楚HashMap的數據插入原理嗎?數據結構
安琪拉: 呃[作沉思狀]。我以爲仍是應該畫個圖比較清楚,以下:
(n - 1) & hash
計算應當存放在數組中的下標 index;面試官: 剛纔你提到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,111111 + 1 = 1000000 =
(符合大於50而且是2的整數次冪 )
![]()
面試官: 你提到hash函數,你知道HashMap的哈希函數怎麼設計的嗎?
安琪拉: [問的還挺細] hash函數是先拿到經過key 的hashcode,是32位的int值,而後讓hashcode的高16位和低16位進行異或操做。
面試官: 那你知道爲何這麼設計嗎?
安琪拉: [這也要問],這個也叫擾動函數,這麼設計有二點緣由:
面試官: 爲何採用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的時候(),也就是用掩碼取低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還有三點主要的優化:
面試官: 你分別跟我講講爲何要作這幾點優化;
安琪拉: 【咳咳,果真是連環炮】
防止發生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?
面試官: 好吧,回去等通知吧!
參考資料
後續會更新一些大廠高頻面試題,主要是Java後端的。也能夠公衆號後臺回覆關鍵字面試提早領取面試的資料。
1.回覆面試領取面試資料
2.回覆書籍領取技術電子書
3.回覆交流領取技術電子書