現在很多公司面試都喜歡問java的HashMap原理,特在此整理相關原理及實現,主要還是因為很多開發集合框架都不甚理解,更不要說各種其他數據結構了,所以造成面子造飛機,進去擰螺絲。
1.哈希表結構的優勢?
哈希表作為一種優秀數據結構
本質上存儲結構是一個數組,輔以鏈表和紅黑樹
數組結構在查詢和插入刪除復雜度方面分別為O(1)和O(n)
鏈表結構在查詢和插入刪除復雜度方面分別為O(n)和O(1)
二叉樹做了平衡 兩者都為O(lgn)
而哈希表兩者都為O(1)
2.哈希表簡介
哈希表本質是一種(key,value)結構 由此我們可以聯想到,能不能把哈希表的key映射成數組的索引index呢? 如果這樣做的話那么查詢相當於直接查詢索引,查詢時間復雜度為O(1) 其實這也正是當key為int型時的做法 將key通過某種做法映射成index,從而轉換成數組結構
3.數據結構實現步驟
1.使用hash算法計算key值對應的hash值h(默認用key對應的hashcode進行計算(hashcode默認為key在內存中的地址)),得到hash值
2.計算該(k,v)對應的索引值index
索引值的計算公式為 index = (h % length) length為數組長度
3.儲存對應的(k,v)到數組中去,從而形成a[index] = node<k,v>,如果a[index]已經有了結點
即可能發生碰撞,那么需要通過開放尋址法或拉鏈法(Java默認實現)解決沖突
當然這只是一個簡單的步驟,只實現了數組 實際實現會更復雜
hash表 數組類似下圖
索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
--- | null | null | <10,node1> | <27,node2> | null | null | null | null |
--- |
jdk 1.7以及之前的結構類似如下:
jdk 8中的結構如下:
兩個重要概念
哈希算法
h 通過hash算法計算得到的的一個整型數值
h可以近似看做一個由key的hashcode生成的隨機數,區別在於相同的hashcode生成的h必然相同
而不同的hashcode也可能生成相同h,這種情況叫做hash碰撞,好的hash算法應盡量避免hash碰撞
(ps:hash碰撞只能盡量避免,而無法杜絕,由於h是一個固定長度整型數據,原則上只要有足夠多的輸入,就一定會產生碰撞)
關於hash算法有很多種,這里不展開贅述,只需要記住h是一個由hashcode產生的偽隨機數即可
同時需要滿足key.hashcode -> h 分布盡量均勻(下文會解釋為何需要分布均勻)
可以參考https://blog.csdn.net/tanggao1314/article/details/51457585
解決碰撞沖突
由上我們可以知道,不同的hashcode可能導致相應的h即發生碰撞
那么我們需要把相應的<k,v>放到hashmap的其他存儲地址
解決方法1:Hash沖突的線性探測開放地址法
通過在數組以某種方式尋找數組中空余的結點放置
基本思想是:當關鍵字key的哈希地址p=H(key)出現沖突時
以p為基礎,產生另一個哈希地址p1,如果p1仍然沖突,再以p為基礎,產生另一個哈希地址p2,…,直到找出一個不沖突的哈希地址pi ,
解決方法1:鏈地址法(JDK采用的哈希沖突解決方法及JDK7的源碼,JDK8差異大)
通過引入鏈表 數組中每一個實體存儲為鏈表結構,如果發生碰撞,則把舊結點指針指向新鏈表結點,此時查詢碰撞結點只需要遍歷該鏈表即可
在這種方法下,數據結構如下所示
int類型數據 hashcode 為自身值

在JAVA中幾個細節點
1.為什么需要擴容?擴容因子大還是小好?
由於數組是定長的,當數組儲存過多的結點時,發生碰撞的概率大大增加,此時hash表退化成鏈表
過大的擴容因子會導致碰撞概率大大提升,過小擴容因子會造成存儲浪費,在Java中默認為0.75
2.當從哈希表中查詢數據時,如果key對應一條鏈表,遍歷時如何判斷是否應該覆蓋?
當遍歷鏈表時,如果兩個key.hashcode的h一致會調用equals()方法判斷是否為同一對象,equal的默認實現是比較兩者的內存地址
因此為什么Java強調當重寫equals()時需要同時重寫hashcode()方法,假設兩個不同對象,在內存中的地址不同分別為a和b,那么重寫equals()以后a.equals(b) =true 開發者希望把a,b這兩個key視作完全相等
然而由於內存地址的不同導致hashcode不同,會導致在hashmap中儲存2個本應相同的key值
這里提供一個范例
public class Student { //學號 public int student_no; //姓名 public String name; @Override public boolean equals(Object o) { Student student = (Student) o; return student_no == student.student_no; } }
通常情況下我們像上圖一樣期望通過判斷兩個Student的學號是否是否為同一學生
然而在使用map或set集合時產生出乎意料的結果

當我們重寫hashcode()時
@Override public int hashCode() { return Objects.hash(student_no); }
可以看到現在可以正常使用集合框架中的一些特性

3.為什么在HashMap中數組的長度length = 2^n(初始值為16),即2的n次 ?
當計算索引值index = h % length 由於計算機的取余操作速度很慢,而計算機的按位取余 & 的操作非常快,又因為 h%length = h & (length-1) (需要滿足length = 2^n) 因此規定了length = 2^n 加快index的計算速度,因此是利用了計算機本身的計算特性
4.HashMap的紅黑樹在哪里體現呢?
紅黑樹是JDK8中對hashmap作的一個變更,在JDK8之前,HashMap、HashSet采用數組+鏈表的形式來解決哈希沖突,我們知道優秀的hash算法應避免碰撞的發生,但假如開發者使用了不合適的hash算法,O(1)級別的數組查詢會退化到O(n)級鏈表查詢,因此在JDK8中引入紅黑樹的,當一個結點的鏈表長度大於8時,鏈表會轉換成紅黑樹,提高查詢效率,而鏈表長度小於6時又會退化成鏈表
5.擴容是如何觸發的?
當hashmap中的size > loadFactory * capacity即會發生擴容,size 也是數組結點和鏈表結點的總和,要明確擴容是一個非常耗費性能的操作,因為數組的長度發生改變,需要對所有結點的索引值重新進行計算,而在JDK8中對這部分進行了優化,詳細可以參考https://blog.csdn.net/aichuanwendang/article/details/53317351,在擴容完后減輕了碰撞產生的影響。但是值得注意的是如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那么就死循環了。所以多線程環境要使用ConcurrentHashMap(值得特別注意的是,concurrenthashmap不允許value值為null,其原因是如果可以為null,那么並發判斷的時候就不知道是沒找到值還是值為null,故不允許。如果一定需要怎么辦?見一個無屬性的Null類代替)而不能使用HashMap。
在jdk 8中,對擴容進行了優化,增加了高16位異或低16位,此時當n變為2倍時,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。如果沒有變,意味着很多不需要移動,具體可參見源代碼中hash方法的實現,也可以參考https://my.oschina.net/u/2307589/blog/1800587的示意圖,畫的很清晰。
在正常的Hash算法下,紅黑樹結構基本不可能被構造出來,根據概率論,理想狀態下哈希表的每個箱子中,元素的數量遵守泊松分布,通俗易懂的解釋泊松分布
(即除非hash算法有問題,否則單位時間內發生沖撞的概率是可以估算出來的):
P(X=k) = (λ^k/k!)e^-λ,k=0,1,...
當負載因子為 0.75 時,上述公式中 λ 約等於 0.5,因此箱子中元素個數和概率的關系如下:(參考https://blog.csdn.net/Philm_iOS/article/details/81200601),下述分布說明來自源碼文檔:
> * 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. In * 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
最后和JDK 7不同的是,JDK1.8中新增了一個實現了Entry接口的內部類Node<K,V>,即哈希節點。
參考:
- JDK 8中hashmap的實現解析:https://blog.csdn.net/lch_2016/article/details/81045480
- 相關HashMap相關的面試問題:https://blog.csdn.net/suifeng629/article/details/82179996