HashMap常見問題梳理

目錄

 

前言

正文

總結


前言

作爲一個在互聯網公司面一次拿一次Offer的麪霸,打敗了無數競爭對手,每次都只能看到無數落寞的身影失望的離開,略感愧疚(請允許我使用一下誇張的修辭手法)。

於是在一個寂寞難耐的夜晚,我痛定思痛,決定開始寫互聯網技術棧面試相關的文章,希望能幫助各位讀者以後面試勢如破竹,對面試官進行360°的反擊,吊打問你的面試官,讓一同面試的同僚瞠目結舌,瘋狂收割大廠Offer!

所有文章的名字只是我的噱頭,我們應該有一顆謙遜的心,所以希望大家懷着空杯心態好好學,一起進步。

正文

一個婀娜多姿,穿着襯衣的小姐姐,拿着一個精緻的小筆記本,徑直走過來坐在我的面前。

看着眼前這個美麗的女人,心想這不會就是Java基礎系列的面試官吧,真香。

不過看樣子這麼年輕應該問不出什麼深度的吧,嘻嘻。(哦?是麼😏)

小夥子,聽前面的面試官說了,你Redis和消息隊列都回答得不錯,看來還是有點東西。

美麗迷人的面試官您好,您見笑了,全靠看了敖丙的《吊打面試官》系列,不然我還真的回答不上很多原本的知識盲區,他真的有點東西。

面試官心想:哦,吊打面試官是麼,那今天我就讓你知道,吊打這兩個字怎麼寫的吧,年輕人啊,提前爲你感到惋惜。

嗯嗯小帥比,雖然前面的技術棧沒啥太大的瑕疵,不過未來很長的一段時間我會用一期期的基礎教你做人的,你要準備好喲!

好了我們開始今天的面試吧,小夥子你瞭解數據結構中的HashMap麼?能跟我聊聊他的結構和底層原理麼?

切,這也太看不起我了吧,居然問這種低級問題,不過還是要好好回答。

嗯嗯面試官,我知道HashMap是我們非常常用的數據結構,由數組和鏈表組合構成的數據結構。

大概如下,數組裏面每個地方都存了Key-Value這樣的實例,在Java7叫Entry在Java8中叫Node。

因爲他本身所有的位置都爲null,在put插入的時候會根據key的hash去計算一個index值。

就比如我put(”帥丙“,520),我插入了爲”帥丙“的元素,這個時候我們會通過哈希函數計算出插入的位置,計算出來index是2那結果如下。

hash(“帥丙”)= 2

你提到了還有鏈表,爲啥需要鏈表,鏈表又是怎麼樣子的呢?

我們都知道數組長度是有限的,在有限的長度裏面我們使用哈希,哈希本身就存在概率性,就是”帥丙“和”丙帥“我們都去hash有一定的概率會一樣,就像上面的情況我再次哈希”丙帥“極端情況也會hash到一個值上,那就形成了鏈表。

每一個節點都會保存自身的hash、key、value、以及下個節點,我看看Node的源碼。

說到鏈表我想問一下,你知道新的Entry節點在插入鏈表的時候,是怎麼插入的麼?

java8之前是頭插法,就是說新來的值會取代原有的值,原有的值就順推到鏈表中去,就像上面的例子一樣,因爲寫這個代碼的作者認爲後來的值被查找的可能性更大一點,提升查找的效率。

但是,在java8之後,都是所用尾部插入了。

爲啥改爲尾部插入呢?

這!!!這個問題,面試官可真會問!!!還好我飽讀詩書,不然死定了!

有人認爲是作者隨性而爲,沒啥luan用,其實不然,其中暗藏玄機

首先我們看下HashMap的擴容機制:

帥丙提到過了,數組容量是有限的,數據多次插入的,到達一定的數量就會進行擴容,也就是resize。

什麼時候resize呢?

有兩個因素:

  • Capacity:HashMap當前長度。
  • LoadFactor:負載因子,默認值0.75f。

怎麼理解呢,就比如當前的容量大小爲100,當你存進第76個的時候,判斷髮現需要進行resize了,那就進行擴容,但是HashMap的擴容也不是簡單的擴大點容量這麼簡單的。

擴容?它是怎麼擴容的呢?

分爲兩步

  • 擴容:創建一個新的Entry空數組,長度是原數組的2倍。
  • ReHash:遍歷原Entry數組,把所有的Entry重新Hash到新數組。

爲什麼要重新Hash呢,直接複製過去不香麼?

臥槽這個問題!有點知識盲區呀!

1x1得 1 1x2 得 2 .... 有了,我想起來敖丙那天晚上在我耳邊的話了:假如我年少有爲不自卑,懂得什麼是珍貴,那些美夢沒給你,我一生有愧....什麼鬼!

小姐姐:是因爲長度擴大以後,Hash的規則也隨之改變。

Hash的公式---> index = HashCode(Key) & (Length - 1)

原來長度(Length)是8你位運算出來的值是2 ,新的長度是16你位運算出來的值明顯不一樣了。

擴容前:

擴容後: 

說完擴容機制我們言歸正傳,爲啥之前用頭插法,java8之後改成尾插了呢?

臥槽,我以爲她忘記了!居然還是被問到了!

我先舉個例子吧,我們現在往一個容量大小爲2的put兩個值,負載因子是0.75是不是我們在put第二個的時候就會進行resize?

2*0.75 = 1 所以插入第二個就要resize了

現在我們要在容量爲2的容器裏面用不同線程插入A,B,C,假如我們在resize之前打個短點,那意味着數據都插入了但是還沒resize那擴容前可能是這樣的。

我們可以看到鏈表的指向A->B->C

Tip:A的下一個指針是指向B的

因爲resize的賦值方式,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置,在舊數組中同一條Entry鏈上的元素,通過重新計算索引位置後,有可能被放到了新數組的不同位置上。

就可能出現下面的情況,大家發現問題沒有?

B的下一個指針指向了A

一旦幾個線程都調整完成,就可能出現環形鏈表

如果這個時候去取值,悲劇就出現了——Infinite Loop。

誒臥槽,小夥子難不倒他呀!

小夥子有點東西呀,但是你都都說了頭插是JDK1.7的那1.8的尾插是怎麼樣的呢?

因爲java8之後鏈表有紅黑樹的部分,大家可以看到代碼已經多了很多if else的邏輯判斷了,紅黑樹的引入巧妙的將原本O(n)的時間複雜度降低到了O(logn)。

Tip:紅黑樹的知識點同樣很重要,還是那句話不打沒把握的仗,限於篇幅原因,我就不在這裏過多描述了,以後寫到數據結構再說吧,不過要面試的仔,還是要準備好,反正我是經常問到的。

使用頭插會改變鏈表的上的順序,但是如果使用尾插,在擴容時會保持鏈表元素原本的順序,就不會出現鏈表成環的問題了。

就是說原本是A->B,在擴容後那個鏈表還是A->B

Java7在多線程操作HashMap時可能引起死循環,原因是擴容轉移後前後鏈表順序倒置,在轉移過程中修改了原來鏈表中節點的引用關係。

Java8在同樣的前提下並不會引起死循環,原因是擴容轉移後前後鏈表順序不變,保持之前節點的引用關係。

那是不是意味着Java8就可以把HashMap用在多線程中呢?

我認爲即使不會出現死循環,但是通過源碼看到put/get方法都沒有加同步鎖,多線程情況最容易出現的就是:無法保證上一秒put的值,下一秒get的時候還是原值,所以線程安全還是無法保證。

小夥子回答得很好嘛,這都被你回答道了,面試這麼多人都不知道頭插和尾插,還是被你說出來了,可以可以。

面試官謬讚啊,要不是你這樣美若天仙的面試官面試我,我估計是想不起來了。

我*,你套近乎?

小姐姐抿嘴一笑,小子你offer有了,耶穌都帶不走你,我說的!

那我問你HashMap的默認初始化長度是多少?

我記得我在看源碼的時候初始化大小是16

你那知道爲啥是16麼?

臥*,這叫什麼問題啊?他爲啥是16我怎麼知道???你確定你沒逗我?

我努力回憶源碼,不知道有沒有漏掉什麼細節,以前在學校熬夜看源碼的一幕幕在腦海裏閃過,想起那個晚上在操場上,跟我好了半個月的小綠拉着我的手說:你就要當爸爸了。

等等,這都是什麼鬼,哦哦哦,想起來了!!!

在JDK1.8的 236 行有1<<4就是16,爲啥用位運算呢?直接寫16不好麼?

我再次陷入沉思,瘋狂腦暴,叮!

有了!

面試官您好,我們在創建HashMap的時候,阿里巴巴規範插件會提醒我們最好賦初值,而且最好是2的冪。

這樣是爲了位運算的方便,位與運算比算數計算的效率高了很多,之所以選擇16,是爲了服務將Key映射到index的算法。

我前面說了所有的key我們都會拿到他的hash,但是我們怎麼儘可能的得到一個均勻分佈的hash呢?

是的我們通過Key的HashCode值去做位運算。

我打個比方,key爲”帥丙“的十進制爲766132那二進制就是 10111011000010110100

我們再看下index的計算公式:index = HashCode(Key) & (Length- 1)

15的的二進制是1111,那10111011000010110100 &1111 十進制就是4

之所以用位與運算效果與取模一樣,性能也提高了不少!

那爲啥用16不用別的呢?

因爲在使用不是2的冪的數字的時候,Length-1的值是所有二進制位全爲1,這種情況下,index的結果等同於HashCode後幾位的值。

只要輸入的HashCode本身分佈均勻,Hash算法的結果就是均勻的。

這是爲了實現均勻分佈

喲小傢伙,知道的確實很多,那我問你個問題,爲啥我們重寫equals方法的時候需要重寫hashCode方法呢?

你能用HashMap給我舉個例子麼?

這都能被他問到,還好我看了敖丙的系列呀,不然真的完了!!!

但是我想拖延點時間,只能故做沉思,仰望天空片刻,45°仰望天空的樣子,說實話,我看到面試官都流口水了!可惜我是他永遠得不到的男人,好了不裝逼了。

我想起來了面試官!

因爲在java中,所有的對象都是繼承於Object類。Ojbect類中有兩個方法equals、hashCode,這兩個方法都是用來比較兩個對象是否相等的。

在未重寫equals方法我們是繼承了object的equals方法,那裏的 equals是比較兩個對象的內存地址,顯然我們new了2個對象內存地址肯定不一樣

  • 對於值對象,==比較的是兩個對象的值
  • 對於引用對象,比較的是兩個對象的地址

大家是否還記得我說的HashMap是通過key的hashCode去尋找index的,那index一樣就形成鏈表了,也就是說”帥丙“和”丙帥“的index都可能是2,在一個鏈表上的。

我們去get的時候,他就是根據key去hash然後計算出index,找到了2,那我怎麼找到具體的”帥丙“還是”丙帥“呢?

equals!是的,所以如果我們對equals方法進行了重寫,建議一定要對hashCode方法重寫,以保證相同的對象返回相同的hash值,不同的對象返回不同的hash值。

不然一個鏈表的對象,你哪裏知道你要找的是哪個,到時候發現hashCode都一樣,這不是完犢子嘛。

可以可以小夥子,我記得你上面說過他是線程不安全的,那你能跟我聊聊你們是怎麼處理HashMap在線程安全的場景麼?

面試官,在這樣的場景,我們一般都會使用HashTable或者ConcurrentHashMap,但是因爲前者的併發度的原因基本上沒啥使用場景了,所以存在線程不安全的場景我們都使用的是ConcurrentHashMap。

HashTable我看過他的源碼,很簡單粗暴,直接在方法上鎖,併發度很低,最多同時允許一個線程訪問,ConcurrentHashMap就好很多了,1.7和1.8有較大的不同,不過併發度都比前者好太多了。

那你能跟我聊聊ConcurrentHashMap麼?

好呀,不過今天天色已晚,我覺得我們要不改天再約?

再說最近敖丙好像雙十二比較忙,一次怎麼能懟這麼多呢?

好吧好吧,小夥子還挺會爲別人着想,而且還喜歡這麼優秀的作者,你我覺得來日可期,那我們改日再約,今天表現很好,希望下次能保持住!

總結

HashMap絕對是最常問的集合之一,基本上所有點都要爛熟於心的那種,篇幅和時間的關係,我就不多介紹了,核心的點我基本上都講到了,不過像紅黑樹這樣的就沒怎麼聊了,但是不代表不重要。

篇幅和精力的原因我就介紹到了一部分的主要知識點,我總結了一些關於HashMap常見的面試題,大家問下自己能不能回答上來,不能的話要去查清楚喲。

HashMap常見面試題:

  • HashMap的底層數據結構?

  • HashMap的存取原理?

  • Java7和Java8的區別?

  • 爲啥會線程不安全?

  • 有什麼線程安全的類代替麼?

  • 默認初始化大小是多少?爲啥是這麼多?爲啥大小都是2的冪?

  • HashMap的擴容方式?負載因子是多少?爲什是這麼多?

  • HashMap的主要參數都有哪些?

  • HashMap是怎麼處理hash碰撞的?

  • hash的計算規則?

  • https://github.com/AobingJava/JavaFamily/blob/master/docs/basics/HashMap.md

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章