面試官Q1:你用過HashMap,你能跟我說說它的數據結構嗎?
HashMap作為一種容器類型,無論你是否了解過其內部的實現原理,它的大名已經頻頻出現在各種互聯網Java面試題中了。從基本的使用角度來說,它很簡單,但從其內部的實現來看,它又並非想象中那么容易。如果你一定要問了解其內部實現與否對於寫程序究竟有多大影響,我不能給出一個確切的答案。但是作為一名合格程序員,對於這種遍地都在談論的技術不應該不為所動。下面我們將自己實現一個簡易版HashMap,然后通過閱讀HashMap的源碼逐步來認識HashMap的底層數據結構。
簡易HashMap V1.0版本
V1.0版本我們需要實現Map的幾個重要的功能:
-
可以存放鍵值對
-
可以根據鍵查找到值
-
鍵不能重復
1public class CustomHashMap {
2 CustomEntry[] arr = new CustomEntry[990];
3 int size;
4
5 public void put(Object key, Object value) {
6 CustomEntry e = new CustomEntry(key, value);
7 for (int i = 0; i < size; i++) {
8 if (arr[i].key.equals(key)) {
9 //如果有key值相等,直接覆蓋value
10 arr[i].value = value;
11 return;
12 }
13 }
14 arr[size++] = e;
15 }
16
17 public Object get(Object key) {
18 for (int i = 0; i < size; i++) {
19 if (arr[i].key.equals(key)) {
20 return arr[i].value;
21 }
22 }
23 return null;
24 }
25
26 public boolean containsKey(Object key) {
27 for (int i = 0; i < size; i++) {
28 if (arr[i].key.equals(key)) {
29 return true;
30 }
31 }
32 return false;
33 }
34
35 public static void main(String[] args) {
36 CustomHashMap map = new CustomHashMap();
37 map.put("k1", "v1");
38 map.put("k2", "v2");
39 map.put("k2", "v4");
40 System.out.println(map.get("k2"));
41 }
42
43}
44
45class CustomEntry {
46 Object key;
47 Object value;
48
49 public CustomEntry(Object key, Object value) {
50 super();
51 this.key = key;
52 this.value = value;
53 }
54
55 public Object getKey() {
56 return key;
57 }
58
59 public void setKey(Object key) {
60 this.key = key;
61 }
62
63 public Object getValue() {
64 return value;
65 }
66
67 public void setValue(Object value) {
68 this.value = value;
69 }
70
71}
上面就是我們自定義的簡單Map實現,可以完成V1.0提出的幾個功能點,但是大家有木有發現,這個Map是基於數組實現的,不管是put還是get方法,每次都要循環去做數據的對比,可想而知效率會很低,現在數組長度只有990,那如果數組的長度很長了,豈不是要循環很多次。既然問題出現了,我們有沒有更好的辦法做改進,使得效率提升,答案是肯定,下面就是V2.0版本升級。
簡易HashMap V2.0版本
V2.0版本需要處理問題如下:
-
減少遍歷次數,提升存取數據效率
在做改進之前,我們先思考一下,有沒有什么方式可以在我們放數據的時候,通過一次定位,就能將這個數放到某個位置,而再我們獲取數據的時候,直接通過一次定位就能找到我們想要的數據,那樣我們就減少了很多迭代遍歷次數。
接下來,我們需要介紹一下哈希表的相關知識
在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能
數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)
線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。
我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數組。
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:
查找操作同理,先通過哈希函數計算出實際存儲地址,然后從數組中對應地址取出即可。既然思路有了,那我們繼續改進唄!
1public class CustomHashMap {
2 CustomEntry[] arr = new CustomEntry[999];
3
4 public void put(Object key, Object value) {
5 CustomEntry entry = new CustomEntry(key, value);
6 //使用Hash碼對999取余數,那么余數的范圍肯定在0到998之間
7 //你可能也發現了,不管怎么取余數,余數也會有沖突的時候(暫時先不考慮,后面慢慢道來)
8 //至少現在我們存數據的效率明顯提升了,key.hashCode() % 999 相同的key算出來的結果肯定是一樣的
9 int a = key.hashCode() % 999;
10 arr[a] = entry;
11 }
12
13 public Object get(Object key) {
14 //取數的時候也通過一次定位就找到了數據,效率明顯得到提升
15 return arr[key.hashCode() % 999].value;
16 }
17
18 public static void main(String[] args) {
19 CustomHashMap map = new CustomHashMap();
20 map.put("k1", "v1");
21 map.put("k2", "v2");
22 System.out.println(map.get("k2"));
23 }
24
25}
26
27class CustomEntry {
28 Object key;
29 Object value;
30
31 public CustomEntry(Object key, Object value) {
32 super();
33 this.key = key;
34 this.value = value;
35 }
36
37 public Object getKey() {
38 return key;
39 }
40
41 public void setKey(Object key) {
42 this.key = key;
43 }
44
45 public Object getValue() {
46 return value;
47 }
48
49 public void setValue(Object value) {
50 this.value = value;
51 }
52}
通過上面的代碼,我們知道余數也有沖突的時候,不一樣的key計算出相同的地址,那么這個時候我們又要怎么處理呢?
哈希沖突
如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式。
通過上面的說明知道,HashMap的底層是基於數組+鏈表的方式,此時,我們需要再對V2.0的Map再次升級
簡易HashMap V3.0版本
V3.0版本需要處理問題如下:
-
存取數據的結構改進
代碼如下:
1public class CustomHashMap {
2 LinkedList[] arr = new LinkedList[999];
3
4 public void put(Object key, Object value) {
5 CustomEntry entry = new CustomEntry(key, value);
6 int a = key.hashCode() % arr.length;
7 if (arr[a] == null) {
8 LinkedList list = new LinkedList();
9 list.add(entry);
10 arr[a] = list;
11 } else {
12 LinkedList list = arr[a];
13 for (int i = 0; i < list.size(); i++) {
14 CustomEntry e = (CustomEntry) list.get(i);
15 if (entry.key.equals(key)) {
16 e.value = value;// 鍵值重復需要覆蓋
17 return;
18 }
19 }
20 arr[a].add(entry);
21 }
22 }
23
24 public Object get(Object key) {
25 int a = key.hashCode() % arr.length;
26 if (arr[a] != null) {
27 LinkedList list = arr[a];
28 for (int i = 0; i < list.size(); i++) {
29 CustomEntry entry = (CustomEntry) list.get(i);
30 if (entry.key.equals(key)) {
31 return entry.value;
32 }
33 }
34 }
35 return null;
36 }
37
38 public static void main(String[] args) {
39 CustomHashMap map = new CustomHashMap();
40 map.put("k1", "v1");
41 map.put("k2", "v2");
42 map.put("k2", "v3");
43 System.out.println(map.get("k2"));
44 }
45
46}
47
48class CustomEntry {
49 Object key;
50 Object value;
51
52 public CustomEntry(Object key, Object value) {
53 super();
54 this.key = key;
55 this.value = value;
56 }
57
58 public Object getKey() {
59 return key;
60 }
61
62 public void setKey(Object key) {
63 this.key = key;
64 }
65
66 public Object getValue() {
67 return value;
68 }
69
70 public void setValue(Object value) {
71 this.value = value;
72 }
73
74}
最終的數據結構如下:
簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那么對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
HashMap源碼
從上面的推導過程,我們逐漸清晰的認識了HashMap的實現原理,下面我們通過閱讀部分源碼,來看看HashMap(基於JDK1.7版本)
1transient Entry[] table;
2
3static class Entry<K,V> implements Map.Entry<K,V> {
4 final K key;
5 V value;
6 Entry<K,V> next;
7 final int hash;
8 ...
9}
可以看出,HashMap中維護了一個Entry為元素的table,transient修飾表示不參與序列化。每個Entry元素存儲了指向下一個元素的引用,構成了鏈表。
put方法實現
1public V put(K key, V value) {
2 // HashMap允許存放null鍵和null值。
3 // 當key為null時,調用putForNullKey方法,將value放置在數組第一個位置。
4 if (key == null)
5 return putForNullKey(value);
6 // 根據key的keyCode重新計算hash值。
7 int hash = hash(key.hashCode());
8 // 搜索指定hash值在對應table中的索引。
9 int i = indexFor(hash, table.length);
10 // 如果 i 索引處的 Entry 不為 null,通過循環不斷遍歷 e 元素的下一個元素。
11 for (Entry<K,V> e = table[i]; e != null; e = e.next) {
12 Object k;
13 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
14 V oldValue = e.value;
15 e.value = value;
16 e.recordAccess(this);
17 return oldValue;
18 }
19 }
20 // 如果i索引處的Entry為null,表明此處還沒有Entry。
21 modCount++;
22 // 將key、value添加到i索引處。
23 addEntry(hash, key, value, i);
24 return null;
25}
從源碼可以看出,大致過程是,當我們向HashMap中put一個元素時,首先判斷key是否為null,不為null則根據key的hashCode,重新獲得hash值,根據hash值通過indexFor方法獲取元素對應哈希桶的索引,遍歷哈希桶中的元素,如果存在元素與key的hash值相同以及key相同,則更新原entry的value值;如果不存在相同的key,則將新元素從頭部插入。如果數組該位置上沒有元素,就直接將該元素放到此數組中的該位置上。
看一下重hash的方法:
1static int hash(int h) {
2 h ^= (h >>> 20) ^ (h >>> 12);
3 return h ^ (h >>> 7) ^ (h >>> 4);
4}
此算法加入了高位計算,防止低位不變,高位變化時,造成的hash沖突。在HashMap中,我們希望元素盡可能的離散均勻的分布到每一個hash桶中,因此,這邊給出了一個indexFor方法:
1static int indexFor(int h, int length) {
2 return h & (length-1);
3}
這段代碼使用 & 運算代替取模(上面我們自己實現的方式就是取模),效率更高。
再來看一眼addEntry方法:
1void addEntry(int hash, K key, V value, int bucketIndex) {
2 // 獲取指定 bucketIndex 索引處的 Entry
3 Entry<K,V> e = table[bucketIndex];
4 // 將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry
5 table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
6 // 如果 Map 中的 key-value 對的數量超過了極限
7 if (size++ >= threshold)
8 // 把 table 對象的長度擴充到原來的2倍。
9 resize(2 * table.length);
10}
很明顯,這邊代碼做的事情就是從頭插入新元素;如果size超過了閾值threshold,就調用resize方法擴容兩倍,至於,為什么要擴容成原來的2倍,請參考,此節不是我們要說的重點。
get方法實現
1public V get(Object key) {
2 if (key == null)
3 return getForNullKey();
4 int hash = hash(key.hashCode());
5 for (Entry<K,V> e = table[indexFor(hash, table.length)];
6 e != null;
7 e = e.next) {
8 Object k;
9 if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
10 return e.value;
11 }
12 return null;
13}
這段代碼很容易理解,首先根據key的hashCode計算hash值,根據hash值確定桶的位置,然后遍歷。
現在,大家都應該對HashMap的底層結構有了更深刻的認識吧,下面筆者對於面試時可能出現的關於HashMap相關的面試題,做了一下梳理,大致如下:
-
你了解HashMap的底層數據結構嗎?(本文已做梳理)
-
為何HashMap的數組長度一定是2的次冪?
-
HashMap何時擴容以及它的擴容機制?
-
HashMap的鍵一般使用的String類型,還可以用別的對象嗎?
-
HashMap是線程安全的嗎,如何實現線程安全?