前言
相信HashMap對於大家來說並不陌生,下面主要從HashMap的一些常見面試題來剖析,結合面試題和HashMap的一些源碼來講解,並不會一上來就一點一點源碼去講,相信大家一直對照着源碼去講解收獲也不是很大,並且容易忘記。
1.HashMap底層數據結構是什么?
我們都知道HashMap是基於hash表實現的,而hash表底層是由數組加鏈表實現的。相信大家這個都能回答上來,我們不僅要知道是由數組加鏈表組成,還需要明白什么由數組加鏈表組成。知其然知其所以然。我們來看一下HashMap的源碼:
我們可以看到有一個Node[] table數組對象,而Node對象有next指針指向下一個對象組成鏈表。
我們知道數組和鏈表各自的特點:
數組:具有隨機訪問的特點,能達到O(1)的時間復雜度,但是插入和刪除比較麻煩,需要移動數組的元素位置
鏈表:鏈表跟數組恰恰相反,插入和刪除不需要移動位置,只需要改變next指針指向的對象引用。但是鏈表的時間復雜度達到O(n),只能順着節點依次的找下去
我們現在想一下,既然鏈表的時間復雜度達到O(n),為什么HashMap還需要加入鏈表了?假設一個場景:如果兩個key被hash(key)成同一個數組下標i,這時數組下標i的位置只能保存一個元素,這時鏈表就發揮作用了,加入鏈表用來解決hash沖突,兩個key會用鏈表next進行鏈接起來。當所有key被鏈接成一個鏈表時,而每次查找只能從第一個Node節點開始尋找,查找的效率就會低下,JDK1.8后對此引入了紅黑樹數據結構來解決這個問題,后面再詳細介紹紅黑樹
2.HashMap的負載因子為什么是0.75?
我們知道在HashMap中有個默認的DEFAULT_LOAD_FACTOR負載因子=0.75,0.75也並不是隨便進行設置的。0.75的含義表達當HashMap的容量達到總容量的75%時HashMap會進行擴容。那HashMap的負載因子為什么要設置0.75呢?是有原因的,主要是從hash沖突和空間利用率兩個方面來考量的
我們假設一下,如果我們將負載因子設置成1,會發生什么情況:
我們將HashMap的負載因子設置成1也就是HashMap的容量必須要全部裝滿,才允許擴容,HashMap的容量如果要全部裝滿必定會伴隨着大量的hash沖突,這時候put和get操作效率就會低下。
如果我們將負載因子設置成0.5,會發生什么情況:
這時HashMap的容量達到50%時就會進行擴容,這樣雖然能減少hash沖突的概率,但是會存在還有一半的空間沒有被使用到,會造成空間的利用率低下。
這時1和0.5都不行,難道我們隨便取個中間值可以嗎。顯然不是的,而是根據一個數學公式推導出來的,被叫做牛頓二項式。下面摘自StackOverFlow一個回答,在牛頓二項式公式下算出來的負載因子~0.69
公式算出來為0.693,而0.75是HashMap為了后面方便計算,0.75*size=整數,方便后面HashMap容量達到這個整數進行擴容。
3.HashMap為什么每次擴容時都是2的指數倍?
我們知道每次HashMap resize()時,擴容后都是之前的容量的2倍。這是為什么呢?我們先來看一段HashMap的源碼
我們可以看到putVal時,會去判斷table[i]的值為不為空,而數組下標i是怎么得出來的呢,是通過key的hash值&(n - 1)得到的數組下標i,n代表數組的長度。在這里提另外一點,我們都知道數組下標i的值可以由hash%length(數組長度)得到,但是HashMap為什么沒有采用這種方式了,而是采用hash&(length - 1)這種方式。&運算速度是要高於%運算速度的,不信的話,網友可以去嘗試一下。
我們假設一下如果擴容不是2的指數倍,假設數組長度是10,則(n - 1)的二進制是01001,就會存在這樣的情況
01001代表的是(n - 1)的二進制數,而下面&上的二進制數01001,01101,01100 ,01111代表的是hash的二進制數,我們發現四個不同的hash值&上(n - 1)卻出現了兩個01001兩個相同的結果,也就是會同時對應一個數組下標i,這時就會造成hash沖突了。
我們假設一下擴容是2的指數倍,則n就會是2,4,8,16,32,64等這些2的指數倍,(n - 1)就會是1,3,7,15,31等這些數對應的二進制就是00001,00011,00111,01111,11111等這些數。我們再來看下這些二進制數&hash值對應的二進制數
我們發現&出來的結果都是由hash值決定,結果取決於hash值。這時能夠更好的減少hash沖突
4.HashMap為什么要引入紅黑樹,而不是完全平衡二叉樹?
我們知道HashMap引入紅黑樹數據結構是為了解決鏈表O(n)的時間復雜度,達到一定條件時,鏈表就會轉變紅黑樹。這里我們可以想一下,為什么HashMap引入的是紅黑樹,而不是完全平衡二叉樹,完全平衡二叉樹也可以解決鏈表O(n)的時間復雜度。
這里就不概述紅黑樹和完全平衡二叉樹的定義了,紅黑樹是一種相對平衡的二叉樹,而完全平衡二叉樹則是絕對平衡的。假設HashMap引入完全平衡二叉樹,每當key插入進來時,完全平衡二叉樹為了保持絕對的平衡,就會對樹進行左旋,右旋操作來保持樹的絕對平衡。這時插入的效率就會低下。而紅黑樹只需保持相對的平衡,並不會有過多的旋轉操作,來使插入效率降低。完全平衡二叉樹適合讀多寫少的場景,也就是get操作多,而put操作少。這時完全平衡二叉樹的效率就會比紅黑樹的效率要高
5.HashMap的轉紅黑樹的閥值為什么是8?
我們首先來看一段HashMap的一段源碼注釋
大概意思就是在負載因子為0.75的基礎上,鏈表長度達到8個元素的概率為0.00000006,這個概率幾乎很小了,這個概率是怎么算出來的呢,是通過一個叫泊松分布概率統計得出來的。並不是隨隨便便定義的這個數字。
6.HashMap達到什么條件下會轉變成紅黑樹結構?
在HashMap中有一個轉變成紅黑樹的閥值TREEIFY_THRESHOLD=8,看一段HashMap的源代碼:
我們可以看到當binCount達到(TREEIFY_THRESHOLD - 1)時,便會執行treeifyBin()方法,因為binCount是從0開始的,所以會是8個節點。剛達到8個節點時,真的會轉變成紅黑樹嗎?我們再看一下treeifyBin()方法里面
我們可以看到如果tab.length 如果小於MIN_TREEIFY_CAPACITY = 64時,HashMap並不會中的鏈表並不會轉變成紅黑樹,而是進行resize()擴容方法。所以可以得出轉變成紅黑樹必須滿足兩個條件:
(1):鏈表節點必須達到8個
(2):數組tab的長度必須大於等於64
7.HashMap為什么會導致CPU飆升?
我相信這種場景在線上是很難出現的,但是也不排除這個可能不會出現,在JDK1.8之前HashMap確實會導致CPU飆升,但是JDK1.8之后官方修復了這個問題。之前為什么會導致CPU飆升了,因為HashMap中的鏈表可能會出現環形鏈表,從而導致死循環。下面來分析一下HashMap中的環形鏈表的形成
我們都知道當HashMap擴容時,需要將舊的數組重新hash填充到新的數組中,當擴容時,存在多個線程一起rehash,這時可能就會出現環形鏈表
假設初始時HashMap是這樣的:
這時經過擴容,需要將A,B,C三個值重新hash到新的數組中
這時假設有兩個線程A,B同時進行擴容操作
線程A進行擴容時,指針情況:
線程B進行擴容時,指針情況:
線程A正准備將指針e的值插入到新的數組中,發現CPU的時間片被搶占,以至於線程A被阻塞,指針情況還是:
這時線程B拿到時間片,進行插入操作,將指針e插入到新的數組中:
這時指針e就會變成指向B對象,這時又將e插入到新數組中:
因為JDK1.8之前是采用頭插法進行插入,B就會插入到A的前面,這時正准備插入最后一個元素C,當時時間片被搶占,輪到線程A執行。這時線程A去插入指針e,也就是元素A.
當插入元素A時,會插入到元素B的前面,也就是A的next = B
這時就是演變成A 和 B形成了一個環形鏈表,因為B之前的next指針是指向A元素的。
因為JDK1.8之前是采用頭插法進行插入的,也就是舊數組之前鏈表元素插入到新數組后順序是顛倒過來的。后面JDK1.8之后官方采用尾插法進行插入,保證插入后的元素順序跟插入前的順序是一樣的。
總結:
HashMap應該是面試中必問的一道面試題,前面總結的都是常被問到的,自己也做一些筆記。當是自己發現有些公司不再問到了HashMap,反而更傾向於線程安全的ConcurrentHashMap。可能是HashMap的面試題都被大家背熟了,有些公司都干脆不問了。哈哈哈!