Java中的集合,從上層接口上看分爲了兩類,Map和Collection。也就是說,我們平時接觸到的常用的集合,包括HashMap,ArrayList和HashSet等都直接或者間接的實現了這兩個接口之一。而Collection接口的子接口又包括了Set和List接口。這樣我們常見的Map,Set和List三大集合接口就出來了。接口類圖如下所示:
Map是和Collection並列的集合上層接口,沒有繼承關係;List和Set是Collection的子接口。
(1)Java中常見的集合
Java中的常見集合可以概括如下。
-
Map接口和Collection接口是所有集合框架的父接口
-
Collection接口的子接口包括:Set接口和List接口
-
Map接口的實現類主要有:HashMap、TreeMap、Hashtable、LinkedHashMap、ConcurrentHashMap以及Properties等
-
Set接口的實現類主要有:HashSet、TreeSet、LinkedHashSet等
-
List接口的實現類主要有:ArrayList、LinkedList、Stack以及Vector等
(2)HashMap和Hashtable的區別有哪些?
HashMap和Hashtable之間的區別可以總結如下。
-
HashMap沒有考慮同步,是線程不安全的;Hashtable使用了synchronized關鍵字,是線程安全的;
-
HashMap允許null作爲Key;Hashtable不允許null作爲Key,Hashtable的value也不可以爲null
解析:
這個算是針對HashMap的一個開胃小菜,既然說出了線程安全和不安全的區別,會接着考察線程安全的具體含義,如下所示:
HashMap是線程不安全的,可以舉一個例子
-
HashMap線程不安全主要是考慮到了多線程環境下進行擴容可能會出現HashMap死循環
-
Hashtable線程安全是由於其內部實現在put和remove等方法上使用synchronized進行了同步,所以對單個方法的使用是線程安全的。但是對多個方法進行複合操作時,線程安全性無法保證。 比如一個線程在進行get操作,一個線程在進行remove操作,往往會導致下標越界等異常。
既然說到了這裏,那麼我們來看看大家一直想說的Java集合快速失敗(fast-fail)機制是怎麼回事兒吧~
Java集合中的快速失敗(fast-fail)機制
快速失敗是Java集合的一種錯誤檢測機制,當多個線程對集合進行結構上的改變的操作時,有可能會產生fail-fast。
例如:
假設存在兩個線程(線程1、線程2),線程1通過Iterator在遍歷集合A中的元素,在某個時候線程2修改了集合A的結構(是結構上面的修改,而不是簡單的修改集合元素的內容),那麼這個時候程序就可能會拋出 ConcurrentModificationException異常,從而產生fast-fail快速失敗。
那麼快速失敗機制底層是怎麼實現的呢?
迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變量。集合在被遍歷期間如果內容發生變化,就會改變modCount的值。當迭代器使用hashNext()/next()遍歷下一個元素之前,都會檢測modCount變量是否爲expectedModCount值,是的話就返回遍歷;否則拋出異常,終止遍歷。JDK源碼中的判斷大概是這樣的:
HashMap底層實現數據結構爲數組+鏈表的形式,JDK8及其以後的版本中使用了數組+鏈表+紅黑樹實現,解決了鏈表太長導致的查詢速度變慢的問題。大概結構如下圖所示:
HashMap的初始容量16,加載因子爲0.75,擴容增量是原容量的1倍。如果HashMap的容量爲16,一次擴容後容量爲32。HashMap擴容是指元素個數(包括數組和鏈表+紅黑樹中)過了16*0.75=12之後開始擴容。
HashMap的長度爲什麼是2的冪次方?
-
我們將一個鍵值對插入HashMap中,通過將Key的hash值與length-1進行&運算,實現了當前Key的定位,2的冪次方可以減少衝突(碰撞)的次數,提高HashMap查詢效率
-
如果length爲2的冪次方,則length-1 轉化爲二進制必定是11111……的形式,在與h的二進制與操作效率會非常的快,而且空間不浪費
-
如果length不是2的冪次方,比如length爲15,則length-1爲14,對應的二進制爲1110,在與h與操作,最後一位都爲0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的機率,減慢了查詢的效率!這樣就會造成空間的浪費。
接下來,我們來做一個簡單的總結:
總結:
也就是說2的N次冪有助於減少碰撞的機率,空間利用率比較大。這樣你就明白爲什麼第一次擴容會從16 ->32了吧?總不會再說32+1=33或者其餘答案了吧?至於加載因子,如果設置太小不利於空間利用,設置太大則會導致碰撞增多,降低了查詢效率,所以設置了0.75。
上邊介紹了HashMap在存儲空間不足的時候會進行擴容操作。那麼,我們接着來看HashMap中的存儲和擴容等相關知識點吧。
HasMap的存儲和獲取原理:
當調用put()方法傳遞鍵和值來存儲時,先對鍵調用hashCode()方法,返回的hashCode用於找到bucket位置來儲存Entry對象,也就是找到了該元素應該被存儲的桶中(數組)。當兩個鍵的hashCode值相同時,bucket位置發生了衝突,也就是發生了Hash衝突,這個時候,會在每一個bucket後邊接上一個鏈表(JDK8及以後的版本中還會加上紅黑樹)來解決,將新存儲的鍵值對放在表頭(也就是bucket中)。
當調用get方法獲取存儲的值時,首先根據鍵的hashCode找到對應的bucket,然後根據equals方法來在鏈表和紅黑樹中找到對應的值。
HasMap的擴容步驟:
HashMap裏面默認的負載因子大小爲0.75,也就是說,當Map中的元素個數(包括數組,鏈表和紅黑樹中)超過了16*0.75=12之後開始擴容。將會創建原來HashMap大小的兩倍的bucket數組,來重新調整map的大小,並將原來的對象放入新的bucket數組中。這個過程叫作rehashing,因爲它調用hash方法找到新的bucket位置。
但是,需要注意的是在多線程環境下,HashMap擴容可能會導致死循環。
前面我們介紹了在HashMap存儲的時候,會發生Hash衝突,那麼我們一起來看Hash衝突的解決辦法吧。
解決Hash衝突的方法有哪些?
-
拉鍊法 (HashMap使用的方法)
-
線性探測再散列法
-
二次探測再散列法
-
僞隨機探測再散列法
哪些類適合作爲HashMap的鍵?
String和Interger這樣的包裝類很適合做爲HashMap的鍵,因爲他們是final類型的類,而且重寫了equals和hashCode方法,避免了鍵值對改寫,有效提高HashMap性能。爲了計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashCode的話,那麼就不能從HashMap中找到你想要的對象。
擴展知識點:
在高級的算法中,還有一個一致性Hash算法,有能力和精力的同學可以去研究下“一致性Hash算法”,有所瞭解一致性Hash算法對於面試是一個很好的加分點。
(4)ConcurrentHashMap和Hashtable的區別?
ConcurrentHashMap結合了HashMap和Hashtable二者的優勢。HashMap沒有考慮同步,Hashtable考慮了同步的問題。但是Hashtable在每次同步執行時都要鎖住整個結構。
ConcurrentHashMap鎖的方式是稍微細粒度的,ConcurrentHashMap將hash表分爲16個桶(默認值),諸如get,put,remove等常用操作只鎖上當前需要用到的桶。
ConcurrentHashMap的具體實現方式(分段鎖):
-
該類包含兩個靜態內部類MapEntry和Segment,前者用來封裝映射表的鍵值對,後者用來充當鎖的
-
總結:
本小節中,我們交流學習了Java基礎中的三大集合,重點闡述了HashMap相關的知識點。這裏鄭重提示,本小節所涉及到的內容幾乎是面試中的必現考察點。有能力的同學,最好是打開JDK的源碼,好好研究HashMap以及ConcurentHashMap的實現方式。當然如果你遇到問題,可以在評論區留言,我們可以一起探討學習,一起進步。
限於作者水平,文章中難免會有不妥之處。大家在學習過程中遇到我沒有表達清楚或者表述有誤的地方,歡迎隨時在文章下邊指出,我會及時關注,隨時改正。另外,大家有任何話題都可以在下邊留言,我們一起交流探討。
附圖:
集合的類圖:
HashMap的類圖結構:
Hashtable的類圖結構: