面試官Q1:你用過HashMap,你能跟我說說它的數據結構嗎?程序員
HashMap做爲一種容器類型,不管你是否瞭解過其內部的實現原理,它的大名已經頻頻出如今各類互聯網Java面試題中了。從基本的使用角度來講,它很簡單,但從其內部的實現來看,它又並不是想象中那麼容易。若是你必定要問了解其內部實現與否對於寫程序究竟有多大影響,我不能給出一個確切的答案。可是做爲一名合格程序員,對於這種遍地都在談論的技術不該該不爲所動。下面咱們將本身實現一個簡易版HashMap,而後經過閱讀HashMap的源碼逐步來認識HashMap的底層數據結構。面試
簡易HashMap V1.0版本算法
V1.0版本咱們須要實現Map的幾個重要的功能:數組
能夠存放鍵值對安全
能夠根據鍵查找到值數據結構
鍵不能重複app
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版本須要處理問題以下:this
減小遍歷次數,提高存取數據效率
在作改進以前,咱們先思考一下,有沒有什麼方式能夠在咱們放數據的時候,經過一次定位,就能將這個數放到某個位置,而再咱們獲取數據的時候,直接經過一次定位就能找到咱們想要的數據,那樣咱們就減小了不少迭代遍歷次數。
接下來,咱們須要介紹一下哈希表的相關知識
在討論哈希表以前,咱們先大概瞭解下其餘數據結構在新增,查找等基礎操做執行性能
數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲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是線程安全的嗎,如何實現線程安全?