HashMap底層實現原理

JDK1.7中HashMap底層實現原理

一、數據結構

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

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

二、實現原理

成員變量

源碼分析:

    /** 初始容量,默認16 */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /** 最大初始容量,2^30 */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /** 負載因子,默認0.75,負載因子越小,hash衝突機率越低 */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /** 初始化一個Entry的空數組 */
    static final Entry<?,?>[] EMPTY_TABLE = {};

    /** 將初始化好的空數組賦值給table,table數組是HashMap實際存儲數據的地方,並不在EMPTY_TABLE數組中 */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    /** HashMap實際存儲的元素個數 */
    transient int size;

    /** 臨界值(HashMap 實際能存儲的大小),公式爲(threshold = capacity * loadFactor) */
    int threshold;

    /** 負載因子 */
    final float loadFactor;

    /** HashMap的結構被修改的次數,用於迭代器 */
    transient int modCount;

構造方法

源碼分析:

    public HashMap(int initialCapacity, float loadFactor) {
        // 判斷設置的容量和負載因子合不合理
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        // 設置負載因子,臨界值此時爲容量大小,後面第一次put時由inflateTable(int toSize)方法計算設置
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);
        putAllForCreate(m);
    }

put方法

put()源碼分析:

public V put(K key, V value) {  
    // 如果table引用指向成員變量EMPTY_TABLE,那麼初始化HashMap(設置容量、臨界值,新的Entry數組引用)
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 若“key爲null”,則將該鍵值對添加到table[0]處,遍歷該鏈表,如果有key爲null,則將value替換。沒有就創建新Entry對象放在鏈表表頭
    // 所以table[0]的位置上,永遠最多存儲1個Entry對象,形成不了鏈表。key爲null的Entry存在這裏 
    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來調用hash()方法,返回鍵的hash值,計算Index後用於找到bucket(哈希桶)的位置來儲存Entry對象。

如果兩個對象key的hash值相同,那麼它們的bucket位置也相同,但equals()不相同,添加元素時會發生hash碰撞,也叫hash衝突,HashMap使用鏈表來解決碰撞問題。

分析源碼可知,put()時,HashMap會先遍歷table數組,用hash值和equals()判斷數組中是否存在完全相同的key對象, 如果這個key對象在table數組中已經存在,就用新的value代替老的value。如果不存在,就創建一個新的Entry對象添加到table[ i ]處。

如果該table[ i ]已經存在其他元素,那麼新Entry對象將會儲存在bucket鏈表的表頭,通過next指向原有的Entry對象,形成鏈表結構(hash碰撞解決方案)。

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) {
        // 如果數組長度大於等於容量×負載因子,並且要添加的位置爲null
        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]處的鏈表(實際上要麼沒有元素,要麼只有一個Entry對象),取出key爲null的value
        if (key == null)
            return getForNullKey();
        // 若key不爲null,用key獲取Entry對象
        Entry<K,V> entry = getEntry(key);
        // 若鏈表中找到的Entry不爲null,返回該Entry中的value
        return null == entry ? null : entry.getValue();
    }

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        // 計算key的hash值
        int hash = (key == null) ? 0 : hash(key);
        // 計算key在數組中對應位置,遍歷該位置的鏈表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            // 若key完全相同,返回鏈表中對應的Entry對象
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        // 鏈表中沒找到對應的key,返回null
        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有兩個參數影響其性能:初始容量和負載因子。均可以通過構造方法指定大小。

容量capacity是HashMap中bucket哈希桶(Entry的鏈表)的數量,初始容量只是HashMap在創建時的容量,最大設置初始容量是2^30,默認初始容量是16(必須爲2的冪),解釋一下,當數組長度爲2的n次冪的時候,不同的key通過indexFor()方法算得的數組位置相同的機率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的機率小,相對的,get()的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

負載因子loadFactor是HashMap在其容量自動增加之前可以達到多滿的一種尺度,默認值是0.75。

擴容機制:

當HashMapde的長度超出了加載因子與當前容量的乘積(默認16*0.75=12)時,通過調用resize方法重新創建一個原來HashMap大小的兩倍的newTable數組,最大擴容到2^30+1,並將原先table的元素全部移到newTable裏面,重新計算hash,然後再重新根據hash分配位置。這個過程叫作rehash,因爲它調用hash方法找到新的bucket位置。

擴容機制源碼分析:

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 如果之前的HashMap已經擴充打最大了,那麼就將臨界值threshold設置爲最大的int值
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        // 根據新傳入的newCapacity創建新Entry數組
        Entry[] newTable = new Entry[newCapacity];
        // 用來將原先table的元素全部移到newTable裏面,重新計算hash,然後再重新根據hash分配位置
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        // 再將newTable賦值給table
        table = newTable;
        // 重新計算臨界值,擴容公式在這兒(newCapacity * loadFactor)
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

擴容問題:

數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這個操作是極其消耗性能的。所以如果我們已經預知HashMap中元素的個數,那麼預設初始容量能夠有效的提高HashMap的性能。

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

 五、線程安全

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

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

B.使用Map m = Collections.synchronizedMap(new HashMap(...));實現同步(官方參考方案,但不建議使用,使用迭代器遍歷的時候修改映射結構容易出錯)

D.使用java.util.HashTable,效率最低(幾乎被淘汰了)

E.使用java.util.concurrent.ConcurrentHashMap,相對安全,效率高(建議使用)

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

六、關於JDK1.8的問題

JDK1.8的HashMap源碼實現和1.7是不一樣的,有很大不同,其底層數據結構也不一樣,引入了紅黑樹結構。有網友測試過,JDK1.8HashMap的性能要高於JDK1.7 15%以上,在某些size的區域上,甚至高於100%。隨着size的變大,JDK1.7的花費時間是增長的趨勢,而JDK1.8是明顯的降低趨勢,並且呈現對數增長穩定。當一個鏈表長度大於8的時候,HashMap會動態的將它替換成一個紅黑樹(JDK1.8引入紅黑樹大程度優化了HashMap的性能),這會將時間複雜度從O(n)降爲O(logn)。

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