先聲明一下,本文有點標題黨了,像我這樣的菜雞何德何能去面試阿里的P7崗啊,不過,這確實是阿里p7級崗位的面試題,固然,參加面試的人不是我,而是我部門的一個大佬。他把本身的面試經驗分享給了我,也讓我間接體會下阿里級別的面試難度,這樣算起來,我也勉強算是經歷面試過阿里P7的崗位的人吧,頓時感受信心暴漲。node
對於HashMap,咱們再熟悉不過了,平常開發最經常使用的Java集合類就是它了,並且面試的時候對於HashMap知識點基本是必問的,就拿我以前的面試經從來看,問的最多的無非是這麼幾個:面試
一、HashMap的底層存儲結構是怎樣的啊?算法
二、線程安全嗎?爲何不安全?數組
三、1.7和1.8版本的HashMap有什麼區別?1.7的有什麼隱患,什麼緣由致使的?安全
四、hashcode是惟一的嗎?插入元素的時候怎麼比較的?數據結構
五、跟HashTable,ConcurrentHashMap有什麼區別?less
對於這些問題,若是你看過一些博客,或者大概的瀏覽過源碼的話,基本都能答出來,我以前參加過不少面試,也不多在HashMap這塊失過手。dom
事實證實,我仍是年輕了點(怎麼說也是90後的)。有時候,你答的好不是由於你懂得多,而是人家問的不深,若是你沒有對源碼作深刻的瞭解和思考的話,別人稍微換個角度考察,你也許就會犯難了。ide
就好像標題上的題目,爲何HashMap鏈表樹化的標準是8個?說實話,儘管我以前也知道是樹化的閾值是8,可是爲何是這個數目我還真沒仔細的思考過,藉着這個機會,我也從新梳理了遍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個,在源碼中,上面那段筆記後面還有一段較長的註釋,咱們能夠從那一段註釋中找到答案,原文是這樣:
\* 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設計鏈表轉紅黑樹的緣由,能夠有效防止用戶本身實現了很差的哈希算法時致使鏈表過長的狀況。
說到哈希算法,咱們再來擴充一個知識點,這也是我以爲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又有可能在擴容時再次均勻地散佈開,達到較好的分佈離散效果,不得不感嘆,設計功底真是太牛逼了,幾句看似簡單的代碼裏面竟然包含了這麼多的學問。
上面說到,當鏈表長度達到閾值8的時候會轉爲紅黑樹,可是紅黑樹退化爲鏈表的閾值倒是6,爲何不是小於8就退化呢?好比說7的時候就退化,恰恰要小於或等於6?
主要是一個過渡,避免鏈表和紅黑樹之間頻繁的轉換。若是閾值是7的話,刪除一個元素紅黑樹就必須退化爲鏈表,增長一個元素就必須樹化,來回不斷的轉換結構無疑會下降性能,因此閾值纔不設置的那麼臨界。
HashMap的知識點還有不少,這裏我也強烈你們去多看幾遍源碼,不光是爲了應付面試,也是對本身能如何更好的使用HashMap能有更清晰的認知,畢竟它實在是太常見了,用的很差很容易就產生bug。並且,我以爲JDK的源碼真的有不少值得咱們開發者深刻研究的地方,就好比這個HashMap,它的真實代碼量不算多,但很是的高效,最重要的是,它每一個版本都在不停的優化,每一行代碼都是精雕細琢,看源碼的時候我也一直在內心感嘆,我要是也能寫出那麼牛逼的代碼,那進阿里什麼的還算是事嗎?
我是鄙人薛某,一個不拘於技術的互聯網人,歡迎關注個人公衆號,咱們一塊兒探討技術,一塊兒吹水人生。
原創不易,看官們的【三連】將是我創做的最大動力,感謝各位的支持!