京東二面:為什麼HashMap底層樹化標準的元素個數是8?
一般的面試題
對於HashMap,我們再熟悉不過了,日常開發最常用的Java集合類就是它了,而且面試的時候對於HashMap知識點基本是必問的,就拿我之前的面試經歷來看,問的最多的無非是這麼幾個:
- HashMap的底層儲存結構是怎樣的啊?
- 執行緒安全嗎?為什麼不安全?
- 1.7和1.8版本的HashMap有什麼區別?1.7的有什麼隱患,什麼原因導致的?
- hashcode是唯一的嗎?插入元素的時候怎麼比較的?
- 跟HashTable,ConcurrentHashMap有什麼區別?
對於這些問題,如果你看過一些部落格,或者大概的瀏覽過原始碼的話,基本都能答出來,我之前參加過很多面試,也很少在HashMap這塊失過手。
事實證明,我還是年輕了點。有時候,你答的好不是因為你懂得多,而是人家問的不深,如果你沒有對原始碼做深入的瞭解和思考的話,別人稍微換個角度考察,你也許就會犯難了。
就好像標題上的題目,為什麼HashMap連結串列樹化的標準是8個?說實話,儘管我之前也知道是樹化的閾值是8,但是為什麼是這個數目我還真沒仔細的思考過,藉著這個機會,我也重新梳理了遍HashMap的原始碼,本文也算是一些新的思考點的總結吧。
HashMap的基本知識點
HashMap可以說是Java專案裡最常用的集合類了,作為一種典型的K-V儲存的資料結構,它的底層是由陣列 - 連結串列組成,當新增新元素時,它會根據元素的hash值找到對應的"桶",也就是HashMap原始碼中Node<K, V> 裡的元素,並插入到對應位置的連結串列中,連結串列元素個數過長時會轉化為紅黑樹(JDK1.8後的版本),
為什麼要轉成紅黑樹呢?
我們都知道,連結串列取元素是從頭結點一直遍歷到對應的結點,這個過程的複雜度是O(N) ,而紅黑樹基於二叉樹的結構,查詢元素的複雜度為O(logN) ,所以,當元素個數過多時,用紅黑樹儲存可以提高搜尋的效率。
既然紅黑樹的效率高,那怎麼不一開始就用紅黑樹儲存呢?JDK的原始碼裡已經對這個問題做了解釋:
* Because TreeNodes are about twice the size of regular nodes, we * use them only when bins contain enough nodes to warrant use * (see TREEIFY_THRESHOLD). And when they become too small (due to * removal or resizing) they are converted back to plain bins.
看註釋裡的前面四行就不難理解,單個 TreeNode 需要佔用的空間大約是普通 Node 的兩倍,所以只有當包含足夠多的 Nodes 時才會轉成 TreeNodes,這個足夠多的標準就是由 TREEIFY_THRESHOLD 的值(預設值8)決定的。而當桶中節點數由於移除或者 resize (擴容) 變少後,紅黑樹會轉變為普通的連結串列,這個閾值是 UNTREEIFY_THRESHOLD(預設值6)。
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
看到這裡就不難明白了,紅黑樹雖然查詢效率比連結串列高,但是結點佔用的空間大,只有達到一定的數目才有樹化的意義,這是基於時間和空間的平衡考慮。
為什麼樹化標準是8個
至於為什麼樹化標準的數量是8個,在原始碼中,上面那段筆記後面還有一段較長的註釋,我們可以從那一段註釋中找到答案,原文是這樣:
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
大概意思就是:如果 hashCode的分佈離散良好的話,那麼紅黑樹是很少會被用到的,因為各個值都均勻分佈,很少出現連結串列很長的情況。在理想情況下,連結串列長度符合泊松分佈,各個長度的命中概率依次遞減,註釋中給我們展示了1-8長度的具體命中概率,當長度為8的時候,概率概率僅為0.00000006,這麼小的概率,HashMap的紅黑樹轉換幾乎不會發生,因為我們日常使用不會儲存那麼多的資料,你會存上千萬個數據到HashMap中嗎?
當然,這是理想的演算法,但不妨某些使用者使用HashMap過程導致hashCode分佈離散很差的場景,這個時候再轉換為紅黑樹就是一種很好的退讓策略。
至於什麼情況下會導致這樣的場景,大家可以自己思考或網上找一下答案,我就不再贅述了,省點力氣。
首先說明一下,在HashMap中,決定某個物件落在哪一個 “桶“,是由該物件的hashCode決定的,JDK無法阻止使用者實現自己的雜湊演算法,如果使用者重寫了hashCode,並且演算法實現比較差的話,就很可能會使HashMap的連結串列變得很長,就比如這樣:
public class HashMapTest {
public static void main(String[] args) {
Map<User, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
map.put(new User("鄙人薛某" + i), i);
}
}
static class User{
private String name;
public User(String name) {
this.name = name;
}
@Override
public int hashCode() {
return 1;
}
}
}
我們設計了一個hashCode永遠為1的類User,這樣一來儲存到HashMap的所有User物件都會存放到同一個“桶”裡,查詢效率無疑會非常的低下,而這也是HashMap設計連結串列轉紅黑樹的原因之一,可以有效防止使用者自己實現了不好的雜湊演算法時導致連結串列過長的情況。
hash方法
說到雜湊演算法,我們再來擴充一個知識點,這也是我覺得HashMap中非常牛逼的設計之一。
在HashMap的原始碼中,儲存物件hashCode的計算是由hash() 方法決定的,hash() 是HashMap 中的核心函式,在儲存資料時,將key傳入中進行運算,得出key的雜湊值,通過這個雜湊值運算才能獲取key應該放置在 “桶” 的哪個位置,下面是方法的原始碼:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
從程式碼中可以看出,傳入key之後,hash() 會獲取key的hashCode進行無符號右移 16 位,然後進行按位異或,並把運算後的值返回,這個值就是key的雜湊值。這樣運算是為了減少碰撞衝突,因為大部分元素的hashCode在低位是相同的,不做處理的話很容易造成衝突。
除了做16位位移的處理,在新增元素的方法中,HashMap還把該hash值與table.length - 1,也就是“桶”陣列的大小做與運算,得到的結果就是對應的“桶”陣列的下標,從而找到該元素所屬的連結串列。原始碼裡這樣的:
// n的值是table.length
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
當查詢不到對應的索引時,就會新建一個新的結點作為連結串列的頭結點。那麼這裡為什麼要用 i = (n - 1) & hash 作為索引運算呢?
這其實是一種優化手段,由於陣列的大小永遠是一個2次冪,在擴容之後,一個元素的新索引要麼是在原位置,要麼就是在原位置加上擴容前的容量。這個方法的巧妙之處全在於&運算,之前提到過&運算只會關注n – 1(n =陣列長度)的有效位,當擴容之後,n的有效位相比之前會多增加一位(n會變成之前的二倍,所以確保陣列長度永遠是2次冪很重要),然後只需要判斷hash在新增的有效位的位置是0還是1就可以算出新的索引位置,如果是0,那麼索引沒有發生變化,如果是1,索引就為原索引加上擴容前的容量。
用一張效果圖來表示就是:
通過位運算,在每次擴容時都不用重新計算hash,省去了不少時間,而且新增有效位是0還是1是帶有隨機性的,之前兩個碰撞的Entry又有可能在擴容時再次均勻地散佈開,達到較好的分佈離散效果,不得不感嘆,設計者的功底真是太牛逼了,幾句看似簡單的程式碼裡面居然包含了這麼多的學問。
為什麼退化為連結串列的閾值是6
上面說到,當連結串列長度達到閾值8的時候會轉為紅黑樹,但是紅黑樹退化為連結串列的閾值卻是6,為什麼不是小於8就退化呢?比如說7的時候就退化,偏偏要小於或等於6?
主要是一個過渡,避免連結串列和紅黑樹之間頻繁的轉換。如果閾值是7的話,刪除一個元素紅黑樹就必須退化為連結串列,增加一個元素就必須樹化,來回不斷的轉換結構無疑會降低效能,所以閾值才不設定的那麼臨界。
最後
HashMap的知識點還有很多,這裡我也強烈大家去多看幾遍原始碼,不光是為了應付面試,也是對自己能如何更好的使用HashMap能有更清晰的認知,畢竟它實在是太常見了,用的不好很容易就產生bug。而且,我覺得JDK的原始碼真的有很多值得我們開發者深入研究的地方,就比如這個HashMap,它的真實程式碼量不算多,但非常的高效,最重要的是,它每個版本都在不停的優化,每一行程式碼都是精雕細琢,看原始碼的時候我也一直在心裡感嘆,我要是也能寫出那麼牛逼的程式碼,那進京東什麼的還算是事嗎?
寫在最後
歡迎大家關注我的公眾號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裡面更新,整理的資料也會放在裡面。
覺得寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!