HashMap理解與總結

HashMap運作原理

HashMap數據結構

HashMap是基於哈希表的Map接口的非同步實現。Hash Map中的key可以爲null,但不能是可變對象,如果是可變對象的話,對象中的屬性改變,則對象HashCode也進行相應的改變,導致下次無法查找到已存在Map中的數據。
這裏寫圖片描述

從上圖中可以看出,HashMap底層就是一個數組結構,數組中的每一項又是一個鏈表。當新建一個HashMap的時候,就會初始化一個數組。

/**
* The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry[] table;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;
    ……
}

可以看出,Entry就是數組中的元素,每個 Map.Entry 其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了鏈表。
簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那麼對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間複雜度依然爲O(1),因爲最新的Entry會插入鏈表頭部,急需要簡單改變引用鏈即可,而對於查找操作來講,此時就需要遍歷鏈表,然後通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能纔會越好。

HashMap的存取實現

以put爲例:

public V put(K key, V value) {
        //如果table數組爲空數組{},進行數組填充(爲table分配實際內存空間),入參爲threshold,此時threshold爲initialCapacity 默認是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key爲null,存儲位置爲table[0]或table[0]的衝突鏈上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻
        int i = indexFor(hash, table.length);//獲取在table中的實際位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保證併發訪問時,若HashMap內部結構發生變化,快速響應失敗
        addEntry(hash, key, value, i);//新增一個entry
        return null;
    }

先來看看inflateTable這個方法

private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處爲threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

inflateTable這個方法用於爲主幹數組table在內存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity爲大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

再來看看addEntry的實現:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生哈希衝突時進行擴容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

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

根據上面 put 方法的源代碼可以看出,當程序試圖將一個key-value對放入HashMap中時,程序首先根據該 key 的 hashCode() 返回值決定該 Entry 的存儲位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的存儲位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新添加 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但key不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新添加的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新添加的 Entry 位於 Entry 鏈的頭部。

HashMap的resize(rehash)

當HashMap中的元素越來越多的時候,hash衝突的機率也就越來越高,因爲數組的長度是固定的。所以爲了提高查詢的效率,就要對HashMap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。

那麼HashMap什麼時候進行擴容呢?當HashMap中的元素個數超過數組大小loadFactor時,就會進行數組擴容,loadFactor的默認值爲0.75,這是一個折中的取值。也就是說,默認情況下,數組大小爲16,那麼當HashMap中元素個數超過160.75=12的時候,就把數組的大小擴展爲 2*16=32,即擴大一倍,然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的性能。

HashMap的併發問題

多線程put時可能導致元素丟失

addNewEntry時,調用table[index] = new HashMapEntry< K, V >(key, value, hash, table[index]);
如果兩個線程同時取得了舊的table[index],然後賦值給新的table[index]時會有一個成功一個丟失。

Rehash時可能出現環鏈導致死循環

Rehash時,元素存儲位置可能發生更換,代碼如下:

for (int j = 0; j < oldCapacity; j++) {
    /*
     * Rehash the bucket using the minimum number of field writes.
     * This is the most subtle and delicate code in the class.
     */
    HashMapEntry<K, V> e = oldTable[j];
    if (e == null) {
        continue;
    }
    int highBit = e.hash & oldCapacity;
    HashMapEntry<K, V> broken = null;
    newTable[j | highBit] = e;
    for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
        int nextHighBit = n.hash & oldCapacity;
        if (nextHighBit != highBit) {
            if (broken == null)
                newTable[j | nextHighBit] = n;
            else
                broken.next = n;
            broken = e;
            highBit = nextHighBit;
        }
    }
    if (broken != null)
        broken.next = null;
}

這裏面要將oldTable裏的元素移動到newTable裏,用了鏈表常用的插入語句,在併發時就可能會出現指針指向混亂的問題從而導致產生環鏈,遍歷時就會出現死循環。

解決辦法

ConcurrentHashMap替換HashMap

ConcurrentHashMap讓鎖的粒度更精細一些,併發性能更好,ConcurrentHashMap將整個Hash桶進行了分段segment,也就是將這個大的數組分成了幾個小的片段segment,而且每個小的片段segment上面都有鎖存在,那麼在插入元素的時候就需要先找到應該插入到哪一個片段segment,然後再在這個片段上面進行插入,而且這裏還需要獲取segment鎖。

Concurrent HashMap如何保證 線程安全的:

  1. HashTable容器在競爭激烈的併發環境下表現出效率低下的原因是所有訪問HashTable的線程都必須競爭同一把鎖,那假如容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高併發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
  2. get操作的高效之處在於整個get過程不需要加鎖,除非讀到的值是空的纔會加鎖重讀。get方法裏將要使用的共享變量都定義成volatile,如用於統計當前Segement大小的count字段和用於存儲值的HashEntry的value。定義成volatile的變量,能夠在線程之間保持可見性,能夠被多線程同時讀,並且保證不會讀到過期的值,但是隻能被單線程寫(有一種情況可以被多線程寫,就是寫入的值不依賴於原值),在get操作裏只需要讀不需要寫共享變量count和value,所以可以不用加鎖。
  3. Put方法首先定位到Segment,然後在Segment裏進行插入操作。插入操作需要經歷兩個步驟,第一步判斷是否需要對Segment裏的HashEntry數組進行擴容,第二步定位添加元素的位置然後放在HashEntry數組裏。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章