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