HashMap的底層工作原理和併發問題

通過源碼分析工作原理

首先來看下HashMap一個典型的構造函數:

transient HashMapEntry<K, V>[] table;

public HashMap(int capacity) {
   if (capacity < 0) {
       throw new IllegalArgumentException("Capacity: " + capacity);
   }

   if (capacity == 0) {
       @SuppressWarnings("unchecked")
       HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;
       table = tab;
       threshold = -1; // Forces first put() to replace EMPTY_TABLE
       return;
   }

   if (capacity < MINIMUM_CAPACITY) {
       capacity = MINIMUM_CAPACITY;
   } else if (capacity > MAXIMUM_CAPACITY) {
       capacity = MAXIMUM_CAPACITY;
   } else {
       capacity = Collections.roundUpToPowerOfTwo(capacity);
   }
   makeTable(capacity);
}

HashMap裏有一個數組table,它存儲的元素類型是HashMapEntry,後面會介紹;capacity指的就是這個數組的長度。如果指定數組長度爲0,會拋出異常;如果爲0,會將table指向EMPTY_TABLE,這個EMPTY_TABLE實際就是長度爲2的數組(MINIMUM_CAPACITY 右移1位):

private static final int MINIMUM_CAPACITY = 4;
private static final int MAXIMUM_CAPACITY = 1 << 30;

private static final Entry[] EMPTY_TABLE = new HashMapEntry[MINIMUM_CAPACITY >>> 1];

從源碼可以看出,如果指定的capacity在MINIMUM_CAPACITY和MAXIMUM_CAPACITY之間,那麼就會調用Collections.roundUpToPowerOfTwo(capacity); 這個方法的作用是將capacity轉化爲比它大而且離它最近的2的某個次方數(比如3就會轉化成4,9就會轉化成16)。由此可見,HashMap中分配的數組大小長度一定是2的次方數。這個HashMapEntry數組裏每個存儲元素的位置稱爲bucket,每個bucket只能存放一個Entry元素,系統可根據bucket的索引迅速訪問其中存儲的元素。

確定數組大小後,就會調用makeTable(capacity);

/**
 * The table is rehashed when its size exceeds this threshold.
 * The value of this field is generally .75 * capacity, except when
 * the capacity is zero, as described in the EMPTY_TABLE declaration
 * above.
 */
private transient int threshold;

private HashMapEntry<K, V>[] makeTable(int newCapacity) {
    @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable
            = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];
    table = newTable;
    threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity
    return newTable;
}

這裏有一個成員變量threshold,它是HashMap中table數組是否要擴容的一個衡量指標:如果已存儲bucket個數已經達到threshold的值,那麼HashMap會重新創建數組並將之前已存儲的元素重新計算插入到新數組的bucket中(這個我們在後面分析HashMap的put方法時可以看到)。threshold的值一般會取capacity的3/4。

接下來我們來看下HashMapEntry的結構(省略了裏面的get/set方法):

static class HashMapEntry<K, V> implements Entry<K, V> {
    final K key;
    V value;
    final int hash;
    HashMapEntry<K, V> next;

    HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) {
        this.key = key;
        this.value = value;
        this.hash = hash;
        this.next = next;
    }

    @Override public final boolean equals(Object o) {
        if (!(o instanceof Entry)) {
            return false;
        }
        Entry<?, ?> e = (Entry<?, ?>) o;
        return Objects.equal(e.getKey(), key)
                && Objects.equal(e.getValue(), value);
    }

    @Override public final int hashCode() {
        return (key == null ? 0 : key.hashCode()) ^
                (value == null ? 0 : value.hashCode());
    }
}

可以看出裏面除了我們所熟悉的key和value以外,還有int hash和HashMapEntry next兩個成員變量。hash是用來驗證某個key經過hash算法計算得到的值是否與當前HashMapEntry的hash值相等;next也是HashMapEntry類型,這就類似於鏈表的結構(HashMapEntry內部還有HashMapEntry),當同一個bucket發生數據碰撞時(兩個及以上Entry對應一個bucket),就會用到next,後面我們再做詳細介紹。

下面來分析put方法:

@Override 
public V put(K key, V value) {
    if (key == null) {
        return putValueForNullKey(value);
    }

    int hash = Collections.secondaryHash(key);
    HashMapEntry<K, V>[] tab = table;
    int index = hash & (tab.length - 1);
    for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {
        if (e.hash == hash && key.equals(e.key)) {
            preModify(e);
            V oldValue = e.value;
            e.value = value;
            return oldValue;
        }
    }

    // No entry for (non-null) key is present; create one
    modCount++;
    if (size++ > threshold) {
        tab = doubleCapacity();
        index = hash & (tab.length - 1);
    }
    addNewEntry(key, value, hash, index);
    return null;
}

如果key爲null,就調用putValueForNullKey方法:

transient HashMapEntry<K, V> entryForNullKey;

private V putValueForNullKey(V value) {
    HashMapEntry<K, V> entry = entryForNullKey;
    if (entry == null) {
        addNewEntryForNullKey(value);
        size++;
        modCount++;
        return null;
    } else {
        preModify(entry);
        V oldValue = entry.value;
        entry.value = value;
        return oldValue;
    }
}

這個entryForNullKey指的就是專門存放key爲null的數據的HashMapEntry。

如果key不爲null,會通過key計算出一個hash值,再利用這個hash值計算出HashMapEntry對應的bucket在數組中的索引:

int index = hash & (tab.length - 1);

這行代碼十分巧妙,由於數組大小一定是2的倍數,所以減1後轉化成二進制就是首位是0,後面全是1;而hash值可能是個比較大的數,這個一“與”,計算出的index絕對不會出現數組越界的情況。

如果找到bucket的位置(hash值相同),然後就沿着裏面存放的HashMapEntry開始進行鏈表遍歷(通過next),直到找到key相同的那個HashMapEntry。圖示:

這裏寫圖片描述

如果沒有找到bucket的位置,或者找到了但沿鏈表遍歷沒找到key相同的元素,證明現在要put的這個數據的key之前沒有出現過,那麼就判斷添加一個HashMapEntry後大小會不會超過threshold,如果超了就先調用doubleCapacity()進行2倍擴容,最後調用addNewEntry:

void addNewEntry(K key, V value, int hash, int index) {
    table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]);
}

這裏創建了一個新的HashMapEntry,並把之前這裏存儲的HashMapEntry作爲它的next元素。get方法基本同理這裏就不在贅述了。

總結

HashMap的數據結構基於數組和鏈表。用數組存儲HashMapEntry元素,當調用put方法去存儲數據時,對key調用hashCode()並可能再做進一步加工,得到一個hash值,通過hash值可以找到bucket的位置,如果bucket位置已經有其他元素了(即hash值相同),那麼就通過鏈表結構把hash相同的元素放到鏈表的下一個節點;當調用get方法去獲取數據時,找到bucket以後,會通過key的equals方法在鏈表中找到目標元素。這裏需要注意hashCode()和equals()方法的區別,它們均需保證計算得到的值在插入HashMap後不會發生改變;並需儘可能保證兩個不同元素的hashCode方法返回值不同,這樣碰撞的機率會小,從而提高HashMap的性能;

當HashMap中已經填充了超過3/4的bucket時,會發生rehash,即會創建原來大小兩倍的bucket數組,並將原來的元素放入新的bucket數組中。這裏的3/4指的是裝填因子(load factor),用戶可以自行指定,默認是0.75。增大裝填因子可以減少 Hash表(Entry 數組)所佔用的內存空間,但會增加查詢數據的時間開銷,而查詢是最頻繁的的操作(HashMap 的 get() 與 put() 方法都要用到查詢);減小裝填因子會提高數據查詢的性能,但會增加 Hash 表所佔用的內存空間。

多線程併發問題

多線程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裏,用了鏈表常用的插入語句,在併發時就可能會出現指針指向混亂的問題從而導致產生環鏈,遍歷時就會出現死循環。詳細的死循環產生過程大家可以參考下面的鏈接,這裏就不贅述了。http://www.cnblogs.com/alexlo/p/4955391.html

解決方案

1.Hashtable替換HashMap
2.Collections.synchronizedMap將HashMap包裝起來

Map m = Collections.synchronizedMap(new HashMap());

synchronized(m) {  
    ......
}

3.ConcurrentHashMap替換HashMap

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