Java1.7 HashMap 實現原理和源碼分析
HashMap 源碼分析是面試中常考的一項,下面一篇文章講得很好,特地轉載過來。
本文轉自:https://www.cnblogs.com/chengxiao/p/6059914.html
參考博客:https://liuyanzhao.com/7656.html
哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表,而HashMap的實現原理也常常出現在各類的面試題中,重要性可見一斑。本文會對java集合框架中的對應實現HashMap的實現原理進行講解,然後會對JDK7的HashMap源碼進行分析。
目錄
一、什麽是哈希表
二、HashMap實現原理
三、為何HashMap的數組長度一定是2的次冪?
四、重寫equals方法需同時重寫hashCode方法
一、什麽是哈希表
在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能
數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)
線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。
我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主幹就是數組
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:
查找操作同理,先通過哈希函數計算出實際存儲地址,然後從數組中對應地址取出即可。
哈希沖突
然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎麽辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然後要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那麽哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式。
二、HashMap實現原理
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 * Creates new entry. 8 */ 9 Entry(int h, K k, V v, Entry<K,V> n) { 10 value = v; 11 next = n; 12 key = k; 13 hash = h; 14 } 15 }
所以,HashMap的整體結構如下
簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那麽對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間復雜度依然為O(1),因為最新的Entry會插入鏈表頭部,急需要簡單改變引用鏈即可,而對於查找操作來講,此時就需要遍歷鏈表,然後通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
其他幾個重要字段
1 //實際存儲的key-value鍵值對的個數 2 transient int size; 3 //閾值(臨界點),當table == {}時,該值為初始容量(初始容量默認為16);當table被填充了,也就是為table分配內存空間後,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,後面會詳細談到 4 int threshold; 5 //負載因子,代表了table的填充度有多少,默認是0.75 6 final float loadFactor; 7 //用於快速失敗,由於HashMap非線程安全,在對HashMap進行叠代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException 8 transient int modCount;
這裏稍微提一下這個容量(也叫桶容量),閾值和負載因子,默認的時候容量是 16,負載因子是0.75,所以這時的閾值是 16*0.75 = 12,當 hashmap 的元素數量大於閾值 12 的時候,就會進行擴容 resize(),容量和閾值都會翻倍。
HashMap有4個構造器,其他構造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數,會使用默認值
initialCapacity默認為16,loadFactory默認為0.75
我們看下其中一個
1 public HashMap(int initialCapacity, float loadFactor) { 2 //此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(230) 3 if (initialCapacity < 0) 4 throw new IllegalArgumentException("Illegal initial capacity: " + 5 initialCapacity); 6 if (initialCapacity > MAXIMUM_CAPACITY) 7 initialCapacity = MAXIMUM_CAPACITY; 8 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 9 throw new IllegalArgumentException("Illegal load factor: " + 10 loadFactor); 11 this.loadFactor = loadFactor; 12 threshold = initialCapacity; 13 init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現 14 }
從上面這段代碼我們可以看出,在常規構造器中,沒有為數組table分配內存空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組
OK,接下來我們來看看put操作的實現吧
1 public V put(K key, V value) { 2 //如果table數組為空數組{},進行數組填充(為table分配實際內存空間),入參為threshold,此時threshold為initialCapacity 默認是1<<4(24=16) 3 if (table == EMPTY_TABLE) { 4 inflateTable(threshold); 5 } 6 //如果key為null,存儲位置為table[0]或table[0]的沖突鏈上 7 if (key == null) 8 return putForNullKey(value); 9 int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻 10 int i = indexFor(hash, table.length);//獲取在table中的實際位置 11 for (Entry<K,V> e = table[i]; e != null; e = e.next) { 12 //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value 13 Object k; 14 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 15 V oldValue = e.value; 16 e.value = value; 17 e.recordAccess(this); 18 return oldValue; 19 } 20 } 21 modCount++;//保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗 22 addEntry(hash, key, value, i);//新增一個entry 23 return null; 24 }
先來看看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 h ^= k.hashCode(); 8 h ^= (h >>> 20) ^ (h >>> 12); 9 return h ^ (h >>> 7) ^ (h >>> 4); 10 }
以上hash函數計算出的值,通過indexFor進一步處理來獲取實際的存儲位置
這個我們要重點說下,我們一般對哈希表的散列很自然地會想到用hash值對length取模(即除法散列法),Hashtable中也是這樣實現的,這種方法基本能保證元素在哈希表中散列的比較均勻,但取模會用到除法運算,效率很低,HashMap中則通過h&(length-1)的方法來代替取模,同樣實現了均勻的散列,但效率要高很多,這也是HashMap對Hashtable的一個改進。(摘自蘭亭風雨)
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中有大量位運算)
所以最終存儲位置的確定流程是這樣的:
(h 是哈希值,h & (length-1) 得到索引值)
再來看看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 createEntry(hash, key, value, bucketIndex); 8 }
通過以上代碼能夠得知,當發生哈希沖突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度為之前數組2倍的新的數組,然後將當前的Entry數組中的元素全部傳輸過去,擴容後的新數組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
三、為何HashMap的數組長度一定是2的次冪?
我們來繼續看上面提到的resize方法
1 void resize(int newCapacity) { 2 Entry[] oldTable = table; 3 int oldCapacity = oldTable.length; 4 if (oldCapacity == MAXIMUM_CAPACITY) { 5 threshold = Integer.MAX_VALUE; 6 return; 7 } 8 Entry[] newTable = new Entry[newCapacity]; 9 transfer(newTable, initHashSeedAsNeeded(newCapacity)); 10 table = newTable; 11 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); 12 }
如果數組進行擴容,數組長度發生變化,而存儲位置 index = h&(length-1),index也可能會發生變化,需要重新計算index,我們先來看看transfer這個方法
1 void transfer(Entry[] newTable, boolean rehash) { 2 int newCapacity = newTable.length; 3 //for循環中的代碼,逐個遍歷鏈表,重新計算索引位置,將老數組數據復制到新數組中去(數組不存儲實際數據,所以僅僅是拷貝引用而已) 4 for (Entry<K,V> e : table) { 5 while(null != e) { 6 Entry<K,V> next = e.next; 7 if (rehash) { 8 e.hash = null == e.key ? 0 : hash(e.key); 9 } 10 int i = indexFor(e.hash, newCapacity); 11 //將當前entry的next鏈指向新的索引位置,newTable[i]有可能為空,有可能也是個entry鏈,如果是entry鏈,直接在鏈表頭部插入。 12 e.next = newTable[i]; 13 newTable[i] = e; 14 e = next; 15 } 16 } 17 }
這個方法將老數組中的數據逐個鏈表地遍歷,扔到新的擴容後的數組中,我們的數組索引位置的計算是通過 對key值的hashcode進行hash擾亂運算後,再通過和 length-1進行位運算得到最終數組索引位置。
hashMap的數組長度一定保持2的次冪,比如16的二進制表示為 10000,那麽length-1就是15,二進制為01111,同理擴容後的數組長度為32,二進制表示為100000,length-1為31,二進制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴容後只有一位差異,也就是多出了最左位的1,這樣在通過 h&(length-1)的時候,只要h對應的最左邊的那一個差異位為0,就能保證得到的新的數組索引和老數組索引一致(大大減少了之前已經散列良好的老數組的數據位置重新調換),個人理解。(摘自https://liuyanzhao.com/7656.html)
還有,數組長度保持2的次冪,length-1的低位都為1,會使得獲得的數組索引index更加均勻,比如:
我們看到,上面的&運算,高位是不會對結果產生影響的(hash函數采用各種位運算可能也是為了使得低位更加散列),我們只關註低位bit,如果低位全部為1,那麽對於h低位部分來說,任何一位的變化都會對結果產生影響,也就是說,要得到index=21這個存儲位置,h的低位只有這一種組合。這也是數組長度設計為必須為2的次冪的原因。
如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應的這個bit位無論如何不會等於1了,而對應的那些數組位置也就被白白浪費了。
get方法
1 public V get(Object key) { 2 //如果key為null,則直接去table[0]處去檢索即可。 3 if (key == null) 4 return getForNullKey(); 5 Entry<K,V> entry = getEntry(key); 6 return null == entry ? null : entry.getValue(); 7 }
get方法通過key值返回對應value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法
1 final Entry<K,V> getEntry(Object key) { 2 if (size == 0) { 3 return null; 4 } 5 //通過key的hashcode值計算hash值 6 int hash = (key == null) ? 0 : hash(key); 7 //indexFor (hash&length-1) 獲取最終數組索引,然後遍歷鏈表,通過equals方法比對找出對應記錄 8 for (Entry<K,V> e = table[indexFor(hash, table.length)]; 9 e != null; 10 e = e.next) { 11 Object k; 12 if (e.hash == hash && 13 ((k = e.key) == key || (key != null && key.equals(k)))) 14 return e; 15 } 16 return null; 17 }
可以看出,get方法的實現相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置,找到對應位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應的記錄。要註意的是,有人覺得上面在定位到數組位置之後然後遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前對象不一致,這種情況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null,後面的例子會做出進一步解釋。
四、重寫equals方法需同時重寫hashCode方法
關於HashMap的源碼分析就介紹到這兒了,最後我們再聊聊老生常談的一個問題,各種資料上都會提到,“重寫equals時也要同時覆蓋hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發生什麽樣的問題
1 package hashMap; 2 3 import java.util.HashMap; 4 import java.util.Objects; 5 6 /** 7 * @author zsh 8 * @company wlgzs 9 * @create 2019-02-28 21:00 10 * @Describe 11 * 重寫equals方法而不重寫hashcode方法發生的問題 12 */ 13 public class Main { 14 public static void main(String[] args) { 15 HashMap<Person,String> map = new HashMap<>(); 16 Person p1 = new Person(1, "張三"); 17 map.put(p1,"1號"); 18 System.out.println(map.get(new Person(1, "張三"))); 19 } 20 } 21 22 class Person{ 23 private Integer id; 24 private String name; 25 26 public Person(Integer id, String name) { 27 this.id = id; 28 this.name = name; 29 } 30 31 @Override 32 public boolean equals(Object o) { 33 if (this == o) return true; 34 if (!(o instanceof Person)) return false; 35 Person person = (Person) o; 36 return Objects.equals(id, person.id) && 37 Objects.equals(name, person.name); 38 } 39 40 /*@Override 41 public int hashCode() { 42 return Objects.hash(id, name); 43 }*/ 44 }
實際輸出結果:null (如果把上面註釋拿掉,能輸出1號)
如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。盡管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode2)-->hash-->indexFor-->最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)
所以,在重寫equals的方法的時候,必須註意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個對象,調用hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(只不過會發生哈希沖突,應盡量避免)。
Java1.7 HashMap 實現原理和源碼分析