天天看點

HashMap實作原理及源碼分析

  哈希表(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鍵值對。

 Entry是HashMap中的一個靜态内部類。代碼如下

 是以,HashMap的整體結構如下

HashMap實作原理及源碼分析

  簡單來說,HashMap由數組+連結清單組成的,數組是HashMap的主體,連結清單則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含連結清單(目前entry的next指向null),那麼對于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含連結清單,對于添加操作,其時間複雜度為O(n),首先周遊連結清單,存在即覆寫,否則新增;對于查找操作來講,仍需周遊連結清單,然後通過key對象的equals方法逐一比對查找。是以,性能考慮,HashMap中的連結清單出現越少,性能才會越好。

其他幾個重要字段

HashMap有4個構造器,其他構造器如果使用者沒有傳入initialCapacity 和loadFactor這兩個參數,會使用預設值

initialCapacity預設為16,loadFactory預設為0.75

我們看下其中一個

  從上面這段代碼我們可以看出,在正常構造器中,沒有為數組table配置設定記憶體空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正建構table數組

  OK,接下來我們來看看put操作的實作吧

 先來看看inflateTable這個方法

  inflateTable這個方法用于為主幹數組table在記憶體中配置設定存儲空間,通過roundUpToPowerOf2(toSize)可以確定capacity為大于或等于toSize的最接近toSize的二次幂,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

roundUpToPowerOf2中的這段處理使得數組長度一定為2的次幂,Integer.highestOneBit是用來擷取最左邊的bit(其他bit位為0)所代表的數值.

hash函數

以上hash函數計算出的值,通過indexFor進一步處理來擷取實際的存儲位置

h&(length-1)保證擷取的index一定在數組範圍内,舉個例子,預設容量16,length-1=15,h=18,轉換成二進制計算為

  最終計算出的index=2。有些版本的對于此處的計算會使用 取模運算,也能保證index一定在數組範圍内,不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)

是以最終存儲位置的确定流程是這樣的:

HashMap實作原理及源碼分析

再來看看addEntry的實作:

  通過以上代碼能夠得知,當發生哈希沖突并且size大于門檻值的時候,需要進行數組擴容,擴容時,需要建立一個長度為之前數組2倍的新的數組,然後将目前的Entry數組中的元素全部傳輸過去,擴容後的新數組長度為之前的2倍,是以擴容相對來說是個耗資源的操作。

我們來繼續看上面提到的resize方法

如果數組進行擴容,數組長度發生變化,而存儲位置 index = h&(length-1),index也可能會發生變化,需要重新計算index,我們先來看看transfer這個方法

  這個方法将老數組中的資料逐個連結清單地周遊,扔到新的擴容後的數組中,我們的數組索引位置的計算是通過 對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,就能保證得到的新的數組索引和老數組索引一緻(大大減少了之前已經散列良好的老數組的資料位置重新調換),個人了解。

HashMap實作原理及源碼分析

 還有,數組長度保持2的次幂,length-1的低位都為1,會使得獲得的數組索引index更加均勻,比如:

HashMap實作原理及源碼分析

  我們看到,上面的&運算,高位是不會對結果産生影響的(hash函數采用各種位運算可能也是為了使得低位更加散列),我們隻關注低位bit,如果低位全部為1,那麼對于h低位部分來說,任何一位的變化都會對結果産生影響,也就是說,要得到index=21這個存儲位置,h的低位隻有這一種組合。這也是數組長度設計為必須為2的次幂的原因。

HashMap實作原理及源碼分析

  如果不是2的次幂,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應的這個bit位無論如何不會等于1了,而對應的那些數組位置也就被白白浪費了。

get方法

get方法通過key值傳回對應value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法

  可以看出,get方法的實作相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置,找到對應位置table[i],再檢視是否有連結清單,周遊連結清單,通過key的equals方法比對查找對應的記錄。要注意的是,有人覺得上面在定位到數組位置之後然後周遊連結清單的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和目前對象不一緻,這種情況,根據Object的hashCode的約定,不能傳回目前對象,而應該傳回null,後面的例子會做出進一步解釋。

  關于HashMap的源碼分析就介紹到這兒了,最後我們再聊聊老生常談的一個問題,各種資料上都會提到,“重寫equals時也要同時覆寫hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發生什麼樣的問題

實際輸出結果:

  如果我們已經對HashMap的原理有了一定了解,這個結果就不難了解了。盡管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由于沒有重寫hashCode方法,是以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode1)-->hash-->indexFor-->最終索引位置,由于hashcode1不等于hashcode2,導緻沒有定位到一個數組位置而傳回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)

  是以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個對象,調用hashCode方法要傳回同樣的整數值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(隻不過會發生哈希沖突,應盡量避免)。

  本文描述了HashMap的實作原理,并結合源碼做了進一步的分析,也涉及到一些源碼細節設計緣由,最後簡單介紹了為什麼重寫equals的時候需要重寫hashCode方法。希望本篇文章能幫助到大家,同時也歡迎讨論指正,謝謝支援!

作者:

dreamcatcher-cx

出處:

<http://www.cnblogs.com/chengxiao/>

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在頁面明顯位置給出原文連結。