1. 程式人生 > >HashMap底層實現原理

HashMap底層實現原理

cati 是我 次數 max turn 索引 線程安全 出現 獲取

一、數據結構

HashMap中的數據結構是數組+單鏈表的組合,以鍵值對(key-value)的形式存儲元素的,通過put()和get()方法儲存和獲取對象。

技術分享圖片

(方塊表示Entry對象,橫排表示數組table[],縱排表示哈希桶bucket【實際上是一個由Entry組成的鏈表,新加入的Entry放在鏈頭,最先加入的放在鏈尾】,)

二、實現原理

put方法

put()源碼分析:

public V put(K key, V value) {  
    // 若“key為null”,則將該鍵值對添加到table[0]中  
    if (key == null
) return putForNullKey(value); // 若“key不為null”,則計算該key的哈希值 int hash = hash(key); // 搜索指定hash值在對應table中的索引 int i = indexFor(hash, table.length); // 循環遍歷table數組上的Entry對象,判斷該位置上key是否已存在 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k;
// 哈希值相同並且對象相同 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { // 如果這個key對應的鍵值對已經存在,就用新的value代替老的value,然後退出! V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }
// 修改次數+1 modCount++; // table數組中沒有key對應的鍵值對,就將key-value添加到table[i]處 addEntry(hash, key, value, i); return null; }

可以看到,當我們給put()方法傳遞鍵和值時,HashMap會由key來調用hashCode()方法,返回鍵的hash值,計算Index後用於找到bucket(哈希桶)來儲存Entry對象。

put()時,如果兩個對象key的hashcode相同,那麽它們的bucket位置也相同,‘碰撞’會發生,HashMap使用鏈表來解決碰撞問題。HashMap會先遍歷table數組,用equals()和hash值判斷數組中是否存在key對應的鍵值對, 如果這個key對應的鍵值對在Entry數組中已經存在,就用新的value代替老的value。如果不存在,就將鍵值對添加到table[ i ]處。

如果該table[ i ]已經存在其他元素,但是equals()並不相同,那麽新元素將會儲存在bucket鏈表的表頭,通過next指向原有的元素,形成鏈表結構。

Entry數據結構源碼如下(HashMap內部類):

 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        /** 指向下一個節點 */
        Entry<K,V> next;
        int hash;

        /**
         * 構造方法為Entry賦值
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        ...
        ...
 } 

形成單鏈表的核心代碼如下:

    /**
     * 將Entry添加到數組bucketIndex位置對應的哈希桶中
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

    /**
     * 在鏈表中添加一個新的Entry對象在鏈表的表頭
     */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

技術分享圖片

(put方法執行過程)

get方法

如果兩個不同的key的hashcode相同,兩個值對象儲存在同一個bucket位置,要獲取value,我們調用get()方法,HashMap會使用key的hashcode找到bucket位置,因為HashMap在鏈表中存儲的是Entry鍵值對,所以找到bucket位置之後,會調用key的equals()方法,按順序遍歷鏈表的每個 Entry,直到找到想獲取的 Entry 為止——如果恰好要搜索的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最早放入該 bucket 中),那HashMap必須循環到最後才能找到該元素。

get()方法源碼如下:

    public V get(Object key) {
        // 若key為null,取table[0]返回
        if (key == null) {
            return getForNullKey();
        }
        // 獲取key的hash值  
        int hash = hash(key.hashCode());
        // 在“該hash值對應的鏈表”上查找“鍵值等於key”的元素  
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
            Object k;
            // 哈希碼相同並且對象相同
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                return e.value;
            }
        }
        return null;
    }

三、hash算法

我們可以看到在HashMap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過HashMap的數據結構是數組和鏈表的結合,所以我們當然希望這個HashMap裏面的元素位置盡量的分布均勻些,盡量使得每個位置上的元素數量只有一個,那麽當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。

源碼分析:

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

四、性能問題

HashMap有兩個參數影響其性能:初始容量和加載因子。默認初始容量是16(必須為2的冪),解釋一下,當數組長度為2的n次冪的時候,不同的key通過indexFor()方法算得的數組位置相同的幾率較小,那麽數據在數組上分布就比較均勻,也就是說碰撞的幾率小,相對的,get()的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

加載因子是0.75,極限容量是2的30次方。容量是HashMap中bucket哈希桶(Entry的鏈表)的數量,初始容量只是HashMap在創建時的容量。加載因子是HashMap在其容量自動增加之前可以達到多滿的一種尺度。

擴容機制:

當HashMapde的長度超出了加載因子與當前容量的乘積(默認16*0.75=12)時,通過調用rehash方法重新創建一個原來HashMap大小的兩倍Entry數組,並將原來的bucket桶放入新的Entry數組中。這個過程叫作rehashing,因為它調用hash方法找到新的bucket位置。數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。所以如果我們已經預知HashMap中元素的個數,那麽預設元素的個數能夠有效的提高HashMap的性能。

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        // 重新創建一個Entry數組
        Entry[] newTable = new Entry[newCapacity];
        // 用來將原先table的元素全部移到newTable裏面
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        // 再將newTable賦值給table
        table = newTable;
        // 重新計算臨界值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

擴容問題:

重新調整HashMap大小,當多線程的情況下可能產生條件競爭。因為如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試著調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麽就死循環了。

五、線程安全

HashMap是線程不安全的,在多線程情況下直接使用HashMap會出現一些莫名其妙不可預知的問題。在多線程下使用HashMap,有幾種方案:

A.在外部包裝HashMap,實現同步機制

B.使用Map m = Collections.synchronizedMap(new HashMap(...));實現同步,這裏就是對HashMap做了一次包裝

D.使用java.util.HashTable,效率最低

E.使用java.util.concurrent.ConcurrentHashMap,相對安全,效率較高

註意一個小問題,HashMap所有集合類視圖所返回叠代器都是快速失敗的(fail-fast),在叠代器創建之後,如果從結構上對映射進行修改,除非通過叠代器自身的 remove 或 add 方法,其他任何時間任何方式的修改,叠代器都將拋出 ConcurrentModificationException。。因此,面對並發的修改,叠代器很快就會完全失敗。

HashMap底層實現原理