1.HashMap的底層數據結構是什麼?
底層數據結構是哈希表結構(鏈表散列:數組+單向鏈表),結合了數組和鏈表的優點,當鏈表長度超過8時,鏈表會轉爲紅黑樹。數組中的每一個元素都是鏈表。總結來說就是HashMap在JDK1.8之前底層是由數組+鏈表實現的,在JDK1.8開始底層是由數組+鏈表或者數組+紅黑樹實現的。
追問:爲什麼在1.8中增加紅黑樹?
當需要查找某個元素的時候,線性探索是最直白的方式,它會把所有數據遍歷一遍直到找到你所查找的數據,對於數組和鏈表這種線性結構來說,當鏈表長度過長(數據有成百上千)的時候,會造成鏈表過深的問題,這種查找方式效率極低,時間複雜度是O(n)。簡單來說紅黑樹的出現就是爲了提高數據檢索的速度。
追問:鏈表過深問題爲什麼不用二叉查找樹代替,而選擇紅黑樹?爲什麼不一直使用紅黑樹?
二叉樹在特殊情況下會變成一條線性結構,這就跟原來的鏈表結構一樣了,選擇紅黑樹就是爲了解決二叉樹的缺陷。
紅黑樹在插入數據的時候需要通過左旋、右旋、變色這些操作來保持平衡,爲了保持這種平衡是需要付出代價的。當鏈表很短的時候,沒必要使用紅黑樹,否則會導致效率更低,當鏈表很長的時候,使用紅黑樹,保持平衡的操作所消耗的資源要遠小於遍歷鏈表鎖消耗的效率,所以纔會設定一個閾值,去判斷什麼時候使用鏈表,什麼時候使用紅黑樹。
追問:講一下你對紅黑樹的認識
- 每個節點非紅即黑
- 根節點總是黑色的
- 如果節點是紅色,則它的子節點必須是黑色(反之不一定)
- 每個葉子節點都是黑色的空節點
- 從根節點到葉子節點或者空節點的每條路徑必須包含相同數量的黑色節點(黑色節點的深度相同)
2.講一下HashMap的工作原理,put()和get()的過程分別是怎麼樣的?
存儲對象時,將key和vaule傳給put()方法:
- 判斷數組是否爲空,爲空進行初始化;
- 不爲空,計算 k 的 hash 值,通過(n - 1) & hash計算應當存放在數組中的下標 index;
- 查看 table[index] 是否存在數據,沒有數據就構造一個Node節點存放在 table[index] 中;
- 存在數據,說明發生了hash衝突(存在二個節點key的hash值一樣), 繼續判斷key是否相等,相等,用新的value替換原數據(onlyIfAbsent爲false);
- 如果不相等,判斷當前節點類型是不是樹型節點,如果是樹型節點,創造樹型節點插入紅黑樹中;(如果當前節點是樹型節點證明當前已經是紅黑樹了)
- 如果不是樹型節點,創建普通Node加入鏈表中;判斷鏈表長度是否大於8並且數組長度大於64,大於的話鏈表轉換爲紅黑樹;
- 插入完成之後判斷當前節點數是否大於閾值(capacity*loadFactor),如果大於開始擴容爲原數組的二倍。
下面以流程圖方式更加直觀的看一下插入流程:
獲取對象時,將key傳給get()方法:
- 調用hash(key)方法獲取key對應的hash值從而獲取該鍵值對在數組中的下標。
- 對鏈表進行順序遍歷,使用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節點插入鏈表,那麼頭插法具體是怎麼做的?設計頭插法的目的是什麼?
新值會作爲鏈表的頭部替換原來的值,原來的值會被順推到鏈表當中。下面以圖解方式說明一下:
設計者認爲後來插入的值被查找的概率比較高,使用頭插法可以提高查找的效率。
4.之前是頭插法,爲什麼JDK1.8之後要改成尾插法?
JDK1.8之前擴容的時候,頭插法會導致鏈表反轉,在多線程情況下會出現環形鏈表,導致取值的時候出現死循環,JDK1.8開始在同樣的前提下就不會導致死循環,因爲在擴容轉移前後鏈表的順序不變,保持之前節點的引用關係。
例: A線程和B線程同時向同一個下標位置插入節點,遇到容量不夠開始擴容,重新hash,放置元素,採用頭插法,後遍歷到的B節點放入了頭部,這樣形成了環,如下圖所示:
5.HashMap是怎麼設定初始化容量大小的?
使用new HashMap()不傳值,默認大小是16,負載因子是0.75。如果傳入參數K,那麼初始化容量大小爲大於K的2的最小整數冪。比如傳入的是10,那麼初始化容量大小就是16(2的4次方)。
追問:爲什麼HashMap的數組長度要取2的整數冪?
因爲這樣數組長度-1正好相當於一個“低位掩碼”。“與”操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問。以初始長度16爲例,16-1=15。2進製表示是00000000 00000000 00001111。和某散列值做“與”操作如下,結果就是截取了最低的四位值。
6.講一下HashMap中的哈希函數時怎麼實現的?
key的hashcode是一個32位的int類型值,hash函數就是將hashcode的高16位和低16位進行異或運算。
追問:哈希函數爲什麼這麼設計?
這是一個擾動函數,這樣設計的原因主要有兩點:
- 可以最大程度的降低hash碰撞的概率(hash值越分散越好);
- 因爲是高頻操作,所以採用位運算,讓算法更加高效;
7.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的方式來實現線程安全。
8.說一下HashMap在JDK1.8中都有哪些改變?
- 底層數據結構:1.7中是數組+鏈表。1.8中是數組+鏈表或數組+紅黑樹;
- 元素插入方式:1.7是頭插法插入鏈表。1.7是尾插法插入鏈表;
- 節點類型:1.7中數組中節點類型是Entry節點,1.8中數組中節點類型是Node節點;
- 元素插入流程:1.7中是先判斷是否需要擴容,再插入。1.8中是先插入,插入成功之後再判斷是否需要擴容;
- 擴容方式:1.7中需要對原數組中元素重新進行hash定位在新數組中的位置。1.8中採用更簡單的邏輯判斷,原下標位置或原下標+舊數組的大小。
9.HashMap的內部節點是有序的嗎?
是無序的,根據hash值隨機插入。
追問:你知道哪些有序的Map?
LinkedHashMap和TreeMap。
追問:說一下這兩種Map分別是怎麼實現有序的
LinkedHashMap: LinkedHashMap內部維護了一個單鏈表,有頭尾節點,同時LinkedHashMap節點Entry內部除了繼承HashMap的Node屬性,還有before 和 after用於標識前置節點和後置節點。可以實現按插入的順序或訪問順序排序。
TreeHashMap: TreeMap是按照Key的自然順序或者Comprator的順序進行排序,內部是通過紅黑樹來實現。所以要麼key所屬的類實現Comparable接口,或者自定義一個實現了Comparator接口的比較器,傳給TreeMap用於key的比較。
10.HashMap,LinkedHashMap,TreeMap 有什麼區別?
LinkedHashMap 保存了記錄的插入順序,在用 Iterator 遍歷時,先取到的記錄肯定是先插入的;遍歷比 HashMap 慢。TreeMap 實現 SortMap 接口,能夠把它保存的記錄根據鍵排序(默認按鍵值升序排序,也可以指定排序的比較器)
追問:講一下這三種Map的使用場景
一般情況下,使用最多的是 HashMap。
HashMap:在 Map 中插入、刪除和定位元素時;
TreeMap:在需要按自然順序或自定義順序遍歷鍵的情況下;
LinkedHashMap:在需要輸出的順序和輸入的順序相同的情況下。