【Java面試題】HashMap原理深入理解
HashMap原理
HashMap內部是基於雜湊表實現的鍵值對儲存,繼承 AbstractMap 並且實現了 Map 介面。
HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取物件。
當我們將鍵值對傳遞給put()方法時,它呼叫鍵物件的hashCode()方法來計算hashcode,讓後找到bucket位置來儲存值物件。
當獲取物件時,通過鍵物件的equals()方法找到正確的鍵值對,然後返回值物件。
HashMap使用LinkedList來解決碰撞問題,當發生碰撞了,物件將會儲存在LinkedList的下一個節點中。
什麼是雜湊表
在討論雜湊表之前,我們先大概瞭解下其他資料結構在新增,查詢等基礎操作執行效能
陣列:採用一段連續的儲存單元來儲存資料。對於指定下標的查詢,時間複雜度為O(1);通過給定值進行查詢,需要遍歷陣列,逐一比對給定關鍵字和陣列元素,時間複雜度為O(n),當然,對於有序陣列,則可採用二分查詢,插值查詢,斐波那契查詢等方式,可將查詢複雜度提高為O(logn);對於一般的插入刪除操作,涉及到陣列元素的移動,其平均複雜度也為O(n)
連結串列:是一種物理儲存單元上非連續、非順序的儲存結構,資料元素的邏輯順序是通過連結串列中的指標連結次序實現的。對於連結串列的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間複雜度為O(1),而查詢操作需要遍歷連結串列逐一進行比對,複雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查詢,刪除等操作,平均複雜度均為O(logn)。
雜湊表:相比上述幾種資料結構,在雜湊表中進行新增,刪除,查詢等操作,效能十分之高,不考慮雜湊衝突的情況下,僅需一次定位即可完成,時間複雜度為O(1),接下來我們就來看看雜湊表是如何實現達到驚豔的常數階O(1)的。
我們知道,資料結構的物理儲存結構只有兩種:順序儲存結構和鏈式儲存結構(像棧,佇列,樹,圖等是從邏輯結構去抽象的,對映到記憶體中)。
而在上面我們提到過,在陣列中根據下標查詢某個元素,一次定位就可以達到,雜湊表利用了這種特性,雜湊表的主幹就是陣列。
比如我們要新增或查詢某個元素,我們通過把當前元素的關鍵字 通過某個函式對映到陣列中的某個位置,通過陣列下標一次定位就可完成操作。
儲存位置 = f(關鍵字)
其中,這個函式f一般稱為雜湊函式,這個函式的設計好壞會直接影響到雜湊表的優劣。舉個例子,比如我們要在雜湊表中執行插入操作:
查詢操作同理,先通過雜湊函式計算出實際儲存地址,然後從陣列中對應地址取出即可。
雜湊衝突
然而萬事無完美,如果兩個不同的元素,通過雜湊函式得出的實際儲存地址相同怎麼辦?也就是說,當我們對某個元素進行雜湊運算,得到一個儲存地址,然後要進行插入的時候,發現已經被其他元素佔用了,其實這就是所謂的雜湊衝突,也叫雜湊碰撞。前面我們提到過,雜湊函式的設計至關重要,好的雜湊函式會盡可能地保證計算簡單和雜湊地址分佈均勻,但是,我們需要清楚的是,陣列是一塊連續的固定長度的記憶體空間,再好的雜湊函式也不能保證得到的儲存地址絕對不發生衝突。那麼雜湊衝突如何解決呢?
a. 鏈地址法:將雜湊表的每個單元作為連結串列的頭結點,所有雜湊地址為 i 的元素構成一個同義詞連結串列。即發生衝突時就把該關鍵字鏈在以該單元為頭結點的連結串列的尾部。
b. 開放定址法:即發生衝突時,去尋找下一個空的雜湊地址。只要雜湊表足夠大,總能找到空的雜湊地址。
c. 再雜湊法:即發生衝突時,由其他的函式再計算一次雜湊值。
d. 建立公共溢位區:將雜湊表分為基本表和溢位表,發生衝突時,將衝突的元素放入溢位表
HashMap實現原理
以下是 HashMap 原始碼裡面的一些關鍵成員變數以及知識點。在後面的原始碼解析中會遇到,所以我們有必要先了解下。
initialCapacity:初始容量。指的是 HashMap 集合初始化的時候自身的容量。可以在構造方法中指定;如果不指定的話,總容量預設值是 16 。需要注意的是初始容量必須是 2 的冪次方。
size:當前 HashMap 中已經儲存著的鍵值對數量,即 HashMap.size()
loadFactor:載入因子。所謂的載入因子就是 HashMap (當前的容量/總容量) 到達一定值的時候,HashMap 會實施擴容。載入因子也可以通過構造方法中指定,預設的值是 0.75 。舉個例子,假設有一個 HashMap 的初始容量為 16 ,那麼擴容的閥值就是 0.75 * 16 = 12 。也就是說,在你打算存入第 13 個值的時候,HashMap 會先執行擴容。
threshold:擴容閥值。即 擴容閥值 = HashMap 總容量 * 載入因子。當前 HashMap 的容量大於或等於擴容閥值的時候就會去執行擴容。擴容的容量為當前 HashMap 總容量的兩倍。比如,當前 HashMap 的總容量為 16 ,那麼擴容之後為 32 。
table:Entry 陣列。我們都知道 HashMap 內部儲存 key/value 是通過 Entry 這個介質來實現的。而 table 就是 Entry 陣列。
在 Java 1.7 中,HashMap 的實現方法是陣列 + 連結串列的形式。上面的 table 就是陣列,而陣列中的每個元素,都是連結串列的第一個結點。即如下圖所示:
HashMap的主幹是一個Entry陣列。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。
1 //HashMap的主幹陣列,可以看到就是一個Entry陣列,初始值為空陣列{},主幹陣列的長度一定是2的次冪,至於為什麼這麼做,後面會有詳細分析。 2 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一個靜態內部類。程式碼如下
1 static class Entry<K,V> implements Map.Entry<K,V> { 2 final K key; 3 V value; 4 Entry<K,V> next;//儲存指向下一個Entry的引用,單鏈表結構 5 int hash;//對key的hashcode值進行hash運算後得到的值,儲存在Entry,避免重複計算 6 7 /** 8 * Creates new entry. 9 */ 10 Entry(int h, K k, V v, Entry<K,V> n) { 11 value = v; 12 next = n; 13 key = k; 14 hash = h; 15 }
所以,HashMap的整體結構如下
簡單來說,HashMap由陣列+連結串列組成的,陣列是HashMap的主體,連結串列則是主要為了解決雜湊衝突而存在的。
如果定位到的陣列位置不含連結串列(當前entry的next指向null):那麼對於查詢,新增等操作很快,僅需一次定址即可;
如果定位到的陣列包含連結串列:對於新增操作,其時間複雜度為O(n),首先遍歷連結串列,存在即覆蓋,否則新增;對於查詢操作來講,仍需遍歷連結串列,然後通過key物件的equals方法逐一比對查詢。
所以,效能考慮,HashMap中的連結串列出現越少,效能才會越好。
我們看下其中一個
1 public HashMap(int initialCapacity, float loadFactor) { //此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(2 2 30 3 ) 4 if (initialCapacity < 0) 5 throw new IllegalArgumentException("Illegal initial capacity: " + 6 initialCapacity); 7 if (initialCapacity > MAXIMUM_CAPACITY) 8 initialCapacity = MAXIMUM_CAPACITY; 9 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 10 throw new IllegalArgumentException("Illegal load factor: " + 11 loadFactor); 12 13 this.loadFactor = loadFactor; 14 threshold = initialCapacity; 15 init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現 16 }
從上面這段程式碼我們可以看出,在常規構造器中,沒有為陣列table分配記憶體空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table陣列
OK,接下來我們來看看put操作的實現吧
1 public V put(K key, V value) { 2 //如果table陣列為空陣列{},進行陣列填充(為table分配實際記憶體空間),入參為threshold,此時threshold為initialCapacity 預設是1<<4(2 3 4 4 =16) 5 if (table == EMPTY_TABLE) { 6 inflateTable(threshold); 7 } 8 //如果key為null,儲存位置為table[0]或table[0]的衝突鏈上 9 if (key == null) 10 return putForNullKey(value); 11 int hash = hash(key);//對key的hashcode進一步計算,確保雜湊均勻 12 int i = indexFor(hash, table.length);//獲取在table中的實際位置 13 for (Entry<K,V> e = table[i]; e != null; e = e.next) { 14 //如果該對應資料已存在,執行覆蓋操作。用新value替換舊value,並返回舊value 15 Object k; 16 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 17 V oldValue = e.value; 18 e.value = value; 19 e.recordAccess(this); 20 return oldValue; 21 } 22 } 23 modCount++;//保證併發訪問時,若HashMap內部結構發生變化,快速響應失敗 24 addEntry(hash, key, value, i);//新增一個entry 25 return null; 26 }
先來看看inflateTable這個方法
1 private void inflateTable(int toSize) { 2 int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪 3 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1 4 table = new Entry[capacity]; 5 initHashSeedAsNeeded(capacity); 6 }
inflateTable這個方法用於為主幹陣列table在記憶體中分配儲存空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
1 private static int roundUpToPowerOf2(int number) { 2 // assert number >= 0 : "number must be non-negative"; 3 return number >= MAXIMUM_CAPACITY 4 ? MAXIMUM_CAPACITY 5 : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; 6 }
roundUpToPowerOf2中的這段處理使得陣列長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數值.
hash函式
1 //這是一個神奇的函式,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進位制位的調整等來保證最終獲取的儲存位置儘量分佈均勻 2 final int hash(Object k) { 3 int h = hashSeed; 4 if (0 != h && k instanceof String) { 5 return sun.misc.Hashing.stringHash32((String) k); 6 } 7 8 h ^= k.hashCode(); 9 10 h ^= (h >>> 20) ^ (h >>> 12); 11 return h ^ (h >>> 7) ^ (h >>> 4); 12 }
以上hash函式計算出的值,通過indexFor進一步處理來獲取實際的儲存位置
1 /** 2 * 返回陣列下標 3 */ 4 static int indexFor(int h, int length) { 5 return h & (length-1); 6 }
h&(length-1)保證獲取的index一定在陣列範圍內,舉個例子,預設容量16,length-1=15,h=18,轉換成二進位制計算為
1 0 0 1 0 & 0 1 1 1 1 __________________ 0 0 0 1 0 = 2
最終計算出的index=2。有些版本的對於此處的計算會使用 取模運算,也能保證index一定在陣列範圍內,不過位運算對計算機來說,效能更高一些(HashMap中有大量位運算)
所以最終儲存位置的確定流程是這樣的:
再來看看addEntry的實現:
1 void addEntry(int hash, K key, V value, int bucketIndex) { 2 if ((size >= threshold) && (null != table[bucketIndex])) { 3 resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生雜湊衝突時進行擴容 4 hash = (null != key) ? hash(key) : 0; 5 bucketIndex = indexFor(hash, table.length); 6 } 7 8 createEntry(hash, key, value, bucketIndex); 9 }
通過以上程式碼能夠得知,當發生雜湊衝突並且size大於閾值的時候,需要進行陣列擴容,擴容時,需要新建一個長度為之前陣列2倍的新的陣列,然後將當前的Entry陣列中的元素全部傳輸過去,擴容後的新陣列長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
Java 1.8 中 HashMap 的不同
在 Java 1.8 中,如果連結串列的長度超過了 8 ,那麼連結串列將轉化為紅黑樹;
發生 hash 碰撞時,Java 1.7 會在連結串列頭部插入,而 Java 1.8 會在連結串列尾部插入;
在 Java 1.8 中,Entry 被 Node 代替(換了一個馬甲)。
手寫一個簡單的HashMap
參考上面程式碼,簡化程式碼如下:
1 package com.test.springboot.starter.bean; 2 3 import java.util.Collection; 4 import java.util.Map; 5 import java.util.Set; 6 7 public class MyHashMap<K, V> { 8 9 // 儲存陣列 10 private Entry<K, V>[] table; 11 // 容積 12 private static Integer CAPACITY = 8; 13 // 大小 14 private Integer size = 0; 15 16 // 構造方法 初始化table的值 17 public MyHashMap() { 18 this.table = new Entry[CAPACITY]; 19 } 20 21 // 三個方法 size、get、put 22 public int size() { 23 return size; 24 } 25 26 public V get(K key) { 27 // hash值 28 Integer hash = key.hashCode(); 29 // 得到資料下表 30 Integer index = hash % table.length; 31 32 // 覆蓋 33 for(Entry<K, V> entry = table[index]; entry != null; entry = entry.next){ 34 if(key.equals(entry.k)){ 35 return entry.v; 36 } 37 } 38 return null; 39 } 40 41 public V put(K key, V value) { 42 43 // hash值 44 Integer hash = key.hashCode(); 45 // 得到資料下表 46 Integer index = hash % table.length; 47 48 // 覆蓋 49 for(Entry<K, V> entry = table[index]; entry != null; entry = entry.next){ 50 if(key.equals(entry.k)){ 51 V oldValue = entry.v; 52 entry.v = value; 53 return oldValue; 54 } 55 } 56 57 // 新增節點 58 addEntry(key, value, index); 59 60 return null; 61 } 62 63 // 新增到對應節點位置前面 64 private void addEntry(K key, V value, Integer index){ 65 table[index] = new Entry(key, value, table[index]); 66 // size + 1 67 size++; 68 } 69 70 // 節點物件 71 class Entry<K, V> { 72 private K k; 73 private V v; 74 private Entry<K, V> next; 75 76 public Entry(K k, V v, Entry<K, V> next){ 77 this.k = k; 78 this.v = v; 79 this.next = next; 80 } 81 } 82 83 84 public static void main(String[] args) { 85 MyHashMap<String, String> myHashMap = new MyHashMap<>(); 86 for(int i = 0; i < 10; i++) { 87 myHashMap.put("1" + i, "小明" + i); 88 } 89 90 System.out.println(myHashMap.get("1")); 91 } 92 }