hashmap詳解

Java中常用的數據結構封裝在java.util包下的一些類中,util包中有這樣兩個接口:

wKiom1mvwu6xIv3tAAAP0r6n4EA276.png

這裏面都是日常開發所常用的容器,下面以Java7中HashMap源碼爲參考,分析HashMap的實現過程。

一、HashMap介紹

Hashmap是基於哈希表的Map接口的實現,以key-value的形式存在。在HashMap中,key-value總是會當做一個整體來處理,系統會根據hash算法來來計算key-value的存儲位置,我們可以通過key快速地存、取value。

二、JDK中Hashmap的定義:

publicclass HashMap<K,V>

    extends AbstractMap<K,V>

    implements Map<K,V>,Cloneable, Serializable

{

...

}


HashMap實現了Map接口,繼承AbstractMap。其中Map接口定義了鍵映射到值的規則,而AbstractMap類提供 Map 接口的骨幹實現,以最大限度地減少實現此接口所需的工作,其實AbstractMap類已經實現了Map。

三、HashMap的構造函數

HashMap提供了三個構造函數:

  • HashMap():構造一個具有默認初始容量 (16) 和默認加載因子 (0.75) 的空 HashMap。

  • HashMap(int initialCapacity):構造一個帶指定初始容量和默認加載因子 (0.75) 的空 HashMap。

  • HashMap(int initialCapacity, floatloadFactor):構造一個帶指定初始容量和加載因子的空HashMap。

這裏提到了兩個參數:初始容量,加載因子。這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中桶的數量,初始容量是創建哈希表時的容量,加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查找效率的降低;如果負載因子太小,那麼散列表的數據將過於稀疏,對空間造成嚴重浪費。系統默認負載因子爲0.75,一般情況下我們是無需修改的。

四、HashMap的數據結構

我們知道在Java中最常用的兩種結構是數組和模擬指針(引用),幾乎所有的數據結構都可以利用這兩種來組合實現,HashMap也是如此。實際上HashMap是基於Hash table來實現了,而此Hash Table的數據結構由“數組+鏈表”來組成。

wKioL1mvwuXjjWgKAAA03lpfNP0115.jpg

從圖中可以看出HashMap底層實現還是數組,只是數組的每一項都是一條鏈。其中構造函數中第一個參數initialCapacity就代表了該數組的長度。下面爲HashMap構造函數的源碼:

public HashMap(intinitialCapacity, floatloadFactor) {

        //初始容量不能小於0

        if (initialCapacity < 0)

            thrownew IllegalArgumentException("Illegal initialcapacity: " + initialCapacity);

        //初始容量不能大於最大容量(2^30

        if (initialCapacity > MAXIMUM_CAPACITY)

           initialCapacity= MAXIMUM_CAPACITY;

        //負載因子不能小於0

        if (loadFactor <= 0 || Float.isNaN(loadFactor))

            thrownew IllegalArgumentException("Illegal loadfactor: " + loadFactor);

 

        //算出一個最小的大於initialCapacity2次冪,HashMap容量爲這個2的冪

       //initialCapacity默認值是16 2^4

        //爲什麼要這樣做下面再探討

        intcapacity = 1;

        while (capacity < initialCapacity)

            capacity <<= 1;

 

        this.loadFactor = loadFactor;

        intthreshold = (int)(capacity * loadFactor);

       table = new Entry[capacity];

        init();

}

從這段代碼中可以看出,每次新建一個HashMap時,都會初始化一個table數組。table數組的元素爲Entry節點。

Entry定義:

staticclass Entry<K,V>implements Map.Entry<K,V>{

   finalK key;

   V value;

   Entry<K,V> next;

   finalinthash;

   /**

    * Creates new entry.

     */

   Entry(inth, K k, V v, Entry<K,V> n) {

        value = v;

        next = n;

        key = k;

        hash = h;

   }

   .......

}

其中Entry爲HashMap的內部類,它不僅包含了鍵key、值value還有下一個節點next,以及hash值,這是非常重要的,Entry才構成了table數組的鏈表。

五、存儲的實現

Hashmap剛創建出來是空的,往hashmap中存值用到put(key,vlaue)方法。

public V put(K key, V value) {

    /**

     * keynull,調用putForNullKey方法,保存nulltable第一個位置

     * 中,這是HashMap允許爲null的原因

     */

    if (key == null)

        return putForNullKey(value);

    //計算keyhash

   inthash = hash(key.hashCode());

    //計算keyhash值在table數組中的位置

   inti = indexFor(hash, table.length);

    //循環鏈表,找到key的保存位置

   for(Entry<K,V> e = table[i]; e != null; e = e.next) {

        Object k;

        //判斷該條鏈上是否有hash值相同的(key相同)

         //若存在相同,則直接覆蓋value,返回舊value

         if (e.hash == hash && ((k = e.key) ==key || key.equals(k))) {

             VoldValue = e.value;

              e.value= value;

              e.recordAccess(this);

              return oldValue;

        }

   }

   //修改次數增加1

   modCount++;

   //keyvalue添加至i位置處

   addEntry(hash, key, value, i);

   returnnull;

}

首先判斷key是否爲null,若爲null,則直接調用putForNullKey方法。通過這段源碼我們可以清晰看到HashMap保存數據的過程:

  1. 若key不爲空則先計算key的hash值,然後根據hash值搜索在table數組中的索引位置。

  2. 如果table數組在該位置處有元素,則比較是否存在相同的key,若存在則覆蓋原來key的value,否則將該元素保存在鏈頭。

  3. 若table數組在該處沒有元素,則直接保存。

效率問題:

我們知道對於HashMap的table而言,數據分佈的越均勻越好(最好每項都只有一個元素,這樣就可以直接找到),太緊會導致查詢速度慢,太鬆則浪費空間。

來看一下Key在hashmap的table中怎麼實現定位的

int hash = hash(key.hashCode());

inti = indexFor(hash, table.length);

staticintindexFor(inth, intlength) {

    returnh & (length-1);

}

拿到hash值後是跟數組長度-1做了與運算操作,這是因爲在計算機中做二進制位元算效率最高。沒毛病。首先將key的hashcode經過一次散列運算得到一個hash值,得到hash值後調用indexFor方法,拿到的h&(length - 1)就是key的位置。

其次考慮爲什麼返回的這個值會跟其他數返回來值衝突的情況少。

假設初始長度爲默認的16,也就是hash()後的值跟15做與運算,會發現永遠是h的低四位在進行與操作,很容易發生hash衝突。

如果返回的h散列的跟均勻些,那麼衝突的概率就會變小,所以hash方法應該是會使得hash值的位值在高低位上儘量均勻分佈的算法。

通過一個例子來做說明:

兩個key,調用Object的hashCode方法後值分別爲:32,64,然後entry數組大小依舊爲:16,不調hash()方法直接返回hashCode,即在調用indexFor時參數分別爲[32,15],[64,15],這時分別對它們調用indexFor方法:

32計算過程:

        100000 & 1111 =>  000000 =>0

64計算過程如下:

        1000000 & 1111 =>  000000 =>0

可以看到indexFor在Entry數組大小不是很大時只會對低位進行與運算操作,高位值不參與運算,很容易發生hash衝突。

現在讓32和64經過hash()算法:

原始h爲32的二進制:100000

        h>>>20:

        000000

        h>>>12:

        000000

接着運算 h^(h>>>20)^(h>>>12):

結果:100000

然後運算: h^(h>>>7)^(h>>>4),

過程如下:

        h>>>7:    000000

        h>>>4:    000010

最後運算: h^(h>>>7)^(h>>>4),

結果:    100010,即十進制34

再調用indexFor方法:

        100010 & 1111 => 2,即存放在Entry數組下標2的位置上

同樣,64進過hash運算結果爲:1000100,十進制值爲68

再調用indexfor方法:

        1000100 & 1111 => 4,即存放在Entry數組下標4的位置上

由此可見hash()的作用。

 

除此之外我們再假設h得到的爲5、6、7,length爲16(2^n)和15

length=16

        h       

        length-1       

        h&(length - 1)       


        5       

15

0101&1111=00101

        5       

        6       

15

0110&1111=00110

6

        7       

15

0111&1111=00111

7

length=15

5

14

0101&1110=00101

5

6

14

0110&1110=00110

6

7

14

0111&1110=00110

6

這裏做了個對比,因爲在構造器有這樣一步:

//算出一個最小的大於initialCapacity2次冪,HashMap容量爲2的冪

        intcapacity = 1;

        while (capacity < initialCapacity)

           capacity<<= 1;


 它的意義就是當length =2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得數據在table數組中分佈較均勻,查詢速度相應較快。

另外,找到在table中的位置後,會迭代這個Entry,利用key.equals()方法判斷是否存在hash值(key)衝突,如果衝突,用新value替換舊value,這裏並沒有處理key,這就解釋了HashMap中沒有兩個相同的key。

這裏也強調重寫equals方法後要重寫hashCode方法。

六、讀取的實現

相對於HashMap的存而言,取就顯得比較簡單了。通過key的hash值找到在table數組中的索引處的Entry,然後返回該key對應的value即可。

public V get(Object key) {

// 若爲null,調用getForNullKey方法返回相對應的value

        if (key == null)

            return getForNullKey();

// 根據該 key 的 hashCode 值計算它的 hash 碼

        inthash = hash(key.hashCode());

        // 取出 table 數組中指定索引處的值

        for (Entry<K,V> e = table[indexFor(hash, table.length)];

             e != null;

             e = e.next){

            Object k;

            //若搜索的key與查找的key相同,則返回相對應的value

            if (e.hash== hash&& ((k = e.key) == key || key.equals(k)))

                returne.value;

        }

        returnnull;

}

七、HashMap在Java1.7與1.8中的區別

在JDK1.7之前

使用一個Entry數組來存儲數據,用hash()和indexFor(int h, intlength)來決定key會被放到數組裏的位置,如果hashcode相同,或者hashcode取模後的結果相同(hash collision),那麼這些key會被定位到Entry數組的同一個格子裏,這些key會形成一個鏈表。

在hashcode特別差的情況下,比方說所有key的hashcode都相同,這個鏈表可能會很長,那麼put/get操作都可能需要遍歷這個鏈表,也就是說時間複雜度在最差情況下會退化到O(n)。

在JDK1.8中

使用一個Node數組來存儲數據,但這個Node可能是鏈表結構,也可能是紅黑樹結構。如果插入的key的hashcode相同,那麼這些key也會被定位到Node數組的同一個格子裏。

如果同一個格子裏的key不超過8個,使用鏈表結構存儲。

如果超過了8個,那麼會調用treeifyBin函數,將鏈表轉換爲紅黑樹。

那麼即使hashcode完全相同,由於紅黑樹的特點,查找某個特定元素,也只需要O(log n)的開銷,也就是說put/get的操作的時間複雜度最差只有O(log n)

可參考:http://blog.csdn.net/xs521860/article/details/59484291詳解


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章