HashMap暴露了你的java基礎功底

(新建的羣1039047324,歡迎對技術感興趣的朋友加入,羣內只聊技術,分享工作中容易踩的坑,以及如何避免踩坑,分享最新架構視頻)

1.HashMap的底層數據結構是什麼?
底層數據結構是哈希表結構(鏈表散列:數組+單向鏈表),結合了數組和鏈表的優點,當鏈表長度超過8時,鏈表會轉爲紅黑樹。數組中的每一個元素都是鏈表。總結來說就是HashMap在JDK1.8之前底層是由數組+鏈表實現的,從JDK1.8開始底層是由數組+鏈表或者數組+紅黑樹實現的。

追問:爲什麼在1.8中增加紅黑樹?
當需要查找某個元素的時候,線性探索是最直白的方式,它會把所有數據遍歷一遍直到找到你所查找的數據,對於數組和鏈表這種線性結構來說,當鏈表長度過長(數據有成百上千)的時候,會造成鏈表過深的問題,這種查找方式效率極低,時間複雜度是O(n)。簡單來說紅黑樹的出現就是爲了提高數據檢索的速度,時間複雜度爲O(logn)。比如,存儲數據個數爲256,使用紅黑樹遍歷只需要8次。

繼續追問:鏈表過深問題爲什麼不用二叉查找樹代替,而選擇紅黑樹?爲什麼不一直使用紅黑樹?
二叉樹在特殊情況下會變成一條線性結構,這就跟原來的鏈表結構一樣了,選擇紅黑樹就是爲了解決二叉樹的缺陷。
紅黑樹在插入數據的時候需要通過左旋、右旋、變色這些操作來保持平衡,爲了保持這種平衡是需要付出代價的。當鏈表很短的時候,沒必要使用紅黑樹,否則會導致效率更低,當鏈表很長的時候,使用紅黑樹,保持平衡的操作所消耗的資源要遠小於遍歷鏈表鎖消耗的效率,所以纔會設定一個閾值,去判斷什麼時候使用鏈表,什麼時候使用紅黑樹。

2.講一下HashMap的工作原理,put()和get()的過程分別是怎麼樣的?
1 存儲對象時,將key和vaule傳給put()方法:

  1. 判斷數組是否爲空,爲空進行初始化;
  2. 不爲空,計算 k 的 hash 值,通過(n - 1) & hash計算應當存放在數組中的下標 index;
  3. 查看 table[index] 是否存在數據,沒有數據就構造一個Node節點存放在 table[index] 中;
  4. 存在數據,說明發生了hash衝突(存在二個節點key的hash值一樣), 繼續判斷key是否相等,相等,用新的value替換原數據(onlyIfAbsent爲false);
  5. 如果不相等,判斷當前節點類型是不是樹型節點,如果是樹型節點,創造樹型節點插入紅黑樹中;(如果當前節點是樹型節點證明當前已經是紅黑樹了)
  6. 如果不是樹型節點,創建普通Node加入鏈表中;判斷鏈表長度是否大於8並且數組長度大於64,大於的話鏈表轉換爲紅黑樹;
  7. 插入完成之後判斷當前節點數是否大於閾值(capacity*loadFactor),如果大於開始擴容爲原數組的二倍。

下面以流程圖方式更加直觀的看一下插入流程:

2 獲取對象時,將key傳給get()方法:

  1. 調用hash(key)方法獲取key對應的hash值從而獲取該鍵值對在數組中的下標。
  2. 對鏈表進行順序遍歷,使用equals()方法查找鏈表中相等的key對應的value值。

追問:說一下數組是怎麼擴容的?
創建一個新數組,新數組初始化容量大小是舊數組的兩倍,對原數組中元素重新進行一次hash從而定位在新數組中的存儲位置,元素在新數組中的位置只有兩種,原下標位置或原下標+舊數組的大小。
追問:爲什麼要對原數組中元素再重新進行一次hash?直接複製到新數組不行嗎?
因爲數組長度擴大以後Hash規則也會隨之變化。
Hash的公式—> index = HashCode(Key) & (Length - 1)
追問:在插入元素的時候,JDK1.7與JDK1.8有什麼不同?
1.7是先判斷是否需要擴容,再進行插入操作。1.8是先插入,插入完成之後再判斷是否需要擴容。
注:hashcode是用來定位的,定鍵值對在數組中的存儲位置。equals()方法是用來定性的,比較兩個對象是否相等。 

 3.你說JDK1.8之前使用頭插法將Entry節點插入鏈表,那麼頭插法具體是怎麼做的?設計頭插法的目的是什麼?

新值會作爲鏈表的頭部替換原來的值,原來的值會被順推到鏈表當中。下面以圖解方式說明一下:

設計者認爲後來插入的值被查找的概率比較高,使用頭插法可以提高查找的效率。

追問:那麼,在HashMap中,到底是怎樣形成環形鏈表的?

JDK7 中 HashMap 成環原因

成環的時機

1:HashMap 擴容時。

2:多線程環境下。

當多個線程同時對這個HashMap進行put操作,而察覺到內存容量不夠,需要進行擴容時,多個線程會同時執行resize操作,而這就出現問題了,問題的原因分析如下:

首先,在HashMap擴容時,會改變鏈表中的元素的順序,將元素從鏈表頭部插入。

重點就在這個transfer()中:

  經過這幾步,我們會發現轉移的時候是逆序的。假如轉移前鏈表順序是1->2,那麼轉移後就會變成2->1。這時候就有點頭緒了,死鎖問題不就是因爲1->2的同時2->1造成的嗎?所以,HashMap 的死鎖問題就出在這個transfer()函數上。

假設原來oldTable裏存放a,b的hash值是一樣的,那麼entry鏈表順序是:
       P1:oldTable[i]->a->b->null                 P2:oldTable[i]->a->b->null
 線程P1運行到上面595行時,e=a(a.next=b),繼續運行到597行時,next=b。這個時候切換到線程P2,線程P2執行完這個鏈表的循環。如果恰a,b在新的table中的hash值又是一樣的,那麼此時的鏈表順序是: 
       主存:newTable[i]->b->a->null
       注意這個時候,a1,a2連接順序已經反了。現在cpu重新切回P1,在第602行以後:e.next = newTable[i];即:              a1.next=newTable[i];
       newTable[i]=a1;
       e=a2;
       開始第二次while循環(e=a2,next=a1):
       a2.next=newTable[i];//也就是a2.next=a1
       newTable[i]=a2
       e=a1
       開始第三次while循環(e=a1,next=null)
       a1.next=newTable[i];//也就是a1.next=a2
       這個時候a1.next=a2,a2.next=a1,形成迴環了,這樣就造成了死循環,在get操作的時候next永遠不爲null,造成死循環。
       put()過程造成了環形鏈表,但是它沒有發生錯誤。一旦再調用get()就悲劇了。
       可以看到很偶然的情況下會出現死循環,不過一旦出現後果是非常嚴重的,多線程的環境還是應該用ConcurrentHashMap。
4.HashMap是怎麼設定初始化容量大小的?
使用new HashMap()不傳值,默認大小是16,負載因子是0.75。如果傳入參數K,那麼初始化容量大小爲大於K的2的最小整數冪。比如傳入的是10,那麼初始化容量大小就是16(2的4次方)。
追問:爲什麼HashMap的數組長度要取2的整數冪?
因爲這樣數組長度-1正好相當於一個“低位掩碼”。“與”操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問。以初始長度16爲例,16-1=15。2進製表示是00000000 00000000 00001111。和某散列值做“與”操作如下,結果就是截取了最低的四位值。

5.講一下HashMap中的哈希函數時怎麼實現的?
key的hashcode是一個32位的int類型值,hash函數就是將hashcode的高16位和低16位進行異或運算。
追問:哈希函數爲什麼這麼設計?
這是一個擾動函數,這樣設計的原因主要有兩點:

可以最大程度的降低hash碰撞的概率(hash值越分散越好);
因爲是高頻操作,所以採用位運算,讓算法更加高效;

6.HashMap是線程安全的嗎?
不是,在多線程的情況下,1.7的HashMap會導致死循環、數據丟失、數據覆蓋。在1.8中如果有多個線程同時put()元素還是會存在數據覆蓋的問題。以1.8位例,A線程判斷index位置爲空後正好掛起,B線程開始向index位置寫入節點數據,這時A線程恢復現場,執行賦值操作,就把A線程的數據給覆蓋了。
追問:如何解決這個線程不安全的問題?
可以使用HashTable、Collections.synchronizedMap、以及ConcurrentHashMap這些線程安全的Map。
追問:分別講一下這幾種Map都是如何實現線程安全的?
HashTable是直接在操作方法上加synchronized關鍵字,鎖住整個數組,粒度比較大;
Collections.synchronizedMap是使用Collections集合工具的內部類,通過傳入Map封裝出一個SynchronizedMap對象,內部定義了一個對象鎖,方法內通過對象鎖實現;
ConcurrentHashMap在JDK1.7中使用分段鎖,降低了鎖粒度,讓併發度大大提高,在JDK 1.8 中直接採用了CAS(無鎖算法)+ synchronized的方式來實現線程安全。

7.說一下HashMap在JDK1.8中都有哪些改變?

  1. 底層數據結構:1.7中是數組+鏈表。1.8中是數組+鏈表或數組+紅黑樹;
  2. 元素插入方式:1.7是頭插法插入鏈表。1.7是尾插法插入鏈表;
  3. 節點類型:1.7中數組中節點類型是Entry節點,1.8中數組中節點類型是Node節點;
  4. 元素插入流程:1.7中是先判斷是否需要擴容,再插入。1.8中是先插入,插入成功之後再判斷是否需要擴容;
  5. 擴容方式:1.7中需要對原數組中元素重新進行hash定位在新數組中的位置。1.8中採用更簡單的邏輯判斷,原下標位置或原下標+舊數組的大小。

8.HashMap的內部節點是有序的嗎?
是無序的,根據hash值隨機插入。
追問:你知道哪些有序的Map?
LinkedHashMap和TreeMap。
追問:說一下這兩種Map分別是怎麼實現有序的
LinkedHashMap: LinkedHashMap內部維護了一個單鏈表,有頭尾節點,同時LinkedHashMap節點Entry內部除了繼承HashMap的Node屬性,還有before 和 after用於標識前置節點和後置節點。可以實現按插入的順序或訪問順序排序。
TreeHashMap: TreeMap是按照Key的自然順序或者Comprator的順序進行排序,內部是通過紅黑樹來實現。所以要麼key所屬的類實現Comparable接口,或者自定義一個實現了Comparator接口的比較器,傳給TreeMap用於key的比較。


9 .HashMap,LinkedHashMap,TreeMap 有什麼區別?
LinkedHashMap 保存了記錄的插入順序,在用 Iterator 遍歷時,先取到的記錄肯定是先插入的;遍歷比 HashMap 慢。TreeMap 實現 SortMap 接口,能夠把它保存的記錄根據鍵排序(默認按鍵值升序排序,也可以指定排序的比較器)
追問:講一下這三種Map的使用場景
一般情況下,使用最多的是 HashMap。
HashMap:在 Map 中插入、刪除和定位元素時;
TreeMap:在需要按自然順序或自定義順序遍歷鍵的情況下;
LinkedHashMap:在需要輸出的順序和輸入的順序相同的情況下。

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