HashMap併發導致死循環 CurrentHashMap

爲何出現死循環簡要說明

HashMap閉環的詳細原因

cocurrentHashMap的底層機制

 

爲何出現死循環簡要說明

  HashMap是非線程安全的,在併發場景中如果不保持足夠的同步,就有可能在執行HashMap.get時進入死循環,將CPU的消耗到100%。

  HashMap採用鏈表解決Hash衝突。因爲是鏈表結構,那麼就很容易形成閉合的鏈路,這樣在循環的時候只要有線程對這個HashMap進行get操作就會產生死循環,

  單線程情況下,只有一個線程對HashMap的數據結構進行操作,是不可能產生閉合的迴路的。

  只有在多線程併發的情況下才會出現這種情況,那就是在put操作的時候,如果size>initialCapacity*loadFactor,hash表進行擴容,那麼這時候HashMap就會進行rehash操作,隨之HashMap的結構就會很大的變化。很有可能就是在兩個線程在這個時候同時觸發了rehash操作,產生了閉合的迴路。

  推薦使用currentHashMap

多線程下[HashMap]的問題:

1、多線程put操作後,get操作導致死循環
2、多線程put非NULL元素後,get操作得到NULL值
3、多線程put操作,導致元素丟失

 

HashMap閉環的詳細原因

Java的HashMap是非線程安全的,所以在併發下必然出現問題,以下做詳細的解釋:

問題的症狀

  從前我們的Java代碼因爲一些原因使用了HashMap這個東西,但是當時的程序是單線程的,一切都沒有問題。因爲考慮到程序性能,所以需要變成多線程的,於是,變成多線程後到了線上,發現程序經常佔了100%的CPU,查看堆棧,你會發現程序都Hang在了HashMap.get()這個方法上了,重啓程序後問題消失。但是過段時間又會來。而且,這個問題在測試環境裏可能很難重現。

  我們簡單的看一下我們自己的代碼,我們就知道HashMap被多個線程操作。而Java的文檔說HashMap是非線程安全的,應該用ConcurrentHashMap。

Hash表數據結構

  簡單地說一下HashMap這個經典的數據結構。

  HashMap通常會用一個指針數組(假設爲table[])來做分散所有的key,當一個key被加入時,會通過Hash算法通過key算出這個數組的下標i,然後就把這個<key, value>插到table[i]中,如果有兩個不同的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上形成一個鏈表

  我們知道,如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那麼碰撞非常頻繁,於是一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷(可參看《Hash Collision DoS 問題》)。

  所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大Hash表的尺寸,這樣一來,整個Hash表裏的無素都需要被重算一遍。這叫rehash,這個成本相當的大。

 

HashMap的rehash源代碼

下面,我們來看一下Java的HashMap的源代碼。

Put一個Key,Value對到Hash表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public V put(K key, V value)
{
    ......
    //算Hash值
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    //如果該key已被插入,則替換掉舊的value (鏈接操作)
    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))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //該key不存在,需要增加一個結點
    addEntry(hash, key, value, i);
    return null;
}

檢查容量是否超標

1
2
3
4
5
6
7
8
void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看當前的size是否超過了我們設定的閾值threshold,如果超過,需要resize
    if (size++ >= threshold)
        resize(2 * table.length);
}

新建一個更大尺寸的hash表,然後把數據從老的Hash表中遷移到新的Hash表中。

1
2
3
4
5
6
7
8
9
10
11
12
void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //創建一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //將Old Hash Table上的數據遷移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

遷移的源代碼,注意高亮處:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面這段代碼的意思是:
    //  從OldTable裏摘一個元素出來,然後放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            while (e != null);
        }
    }
}

好了,這個代碼算是比較正常的。而且沒有什麼問題。

正常的ReHash的過程

畫了個圖做了個演示。

  • 我假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
  • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以後都衝突在table[1]這裏了。
  • 接下來的三個步驟是Hash表 resize成4,然後所有的<key,value> 重新rehash的過程

併發下的Rehash

1)假設我們有兩個線程。我用紅色和淺藍色標註了一下。

我們再回頭看一下我們的 transfer代碼中的這個細節:

  1. do { 
  2.     Entry<K,V> next = e.next; // <--假設線程一執行到這裏就被調度掛起了 
  3.     int i = indexFor(e.hash, newCapacity); 
  4.     e.next = newTable[i]; 
  5.     newTable[i] = e; 
  6.     e = next; 
  7. while (e != null); 

而我們的線程二執行完成了。於是我們有下面的這個樣子。

注意,因爲Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表。我們可以看到鏈表的順序被反轉後。

2)線程一被調度回來執行。

  • 先是執行 newTalbe[i] = e;
  • 然後是e = next,導致了e指向了key(7),
  • 而下一次循環的next = e.next導致了next指向了key(3)

3)一切安好。

線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

4)環形鏈接出現。

e.next = newTable[i] 導致  key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

於是,當我們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。

其它

有人把這個問題報給了Sun,不過Sun不認爲這個是一個問題。因爲HashMap本來就不支持併發。要併發就用ConcurrentHashmap

我在這裏把這個事情記錄下來,只是爲了讓大家瞭解並體會一下併發環境下的危險。

 

cocurrentHashMap的底層機制

  ConcurrentHashMap的讀取併發,因爲在讀取的大多數時候都沒有用到鎖定,所以讀取操作幾乎是完全的併發操作,而寫操作鎖定的粒度又非常細。只有在求size等操作時才需要鎖定整個表。而在迭代時,ConcurrentHashMap使用了不同於傳統集合的快速失敗迭代器的弱一致迭代器。在這種迭代方式中,當iterator被創建後集合再發生改變就不再是拋出ConcurrentModificationException,取而代之的是在改變時new新的數據從而不影響原有的數據,iterator完成後再將頭指針替換爲新的數據,這樣iterator線程可以使用原來老的數據,而寫線程也可以併發的完成改變,更重要的,這保證了多個線程併發執行的連續性和擴展性,是性能提升的關鍵。

效率低下的HashTable容器

     Hashtable繼承的是Dictionary(Hashtable是其唯一公開的子類),HashTable容器使用synchronized來保證線程安全,但在線程競爭激烈的情況下HashTable的效率非常低下。因爲當一個線程訪問HashTable的同步方法時,其他線程訪問HashTable的同步方法時,可能會進入阻塞或輪詢狀態。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,並且也不能使用get方法來獲取元素,所以競爭越激烈效率越低。

  Hashtable的實現方式---鎖整個hash表;而ConcurrentHashMap的實現方式---鎖桶(或段)

ConcurrentHashMap的鎖分段技術

     HashTable容器在競爭激烈的併發環境下表現出效率低下的原因,是因爲所有訪問HashTable的線程都必須競爭同一把鎖,那假如容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高併發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問

  從上面看出,ConcurrentHashMap定位一個元素的過程需要進行兩次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的鏈表的頭部,因此,這一種結構的帶來的副作用是Hash的過程要比普通的HashMap要長,但是帶來的好處是寫操作的時候可以只對元素所在的Segment進行加鎖即可,不會影響到其他的Segment,這樣,在最理想的情況下,ConcurrentHashMap可以最高同時支持Segment數量大小的寫操作(剛好這些寫操作都非常平均地分佈在所有的Segment上),併發能力大大提高。

  

  ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖ReentrantLock,在ConcurrentHashMap裏扮演鎖的角色,HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組,Segment的結構和HashMap類似,是一種數組和鏈表結構, 一個Segment裏包含一個HashEntry數組,每個HashEntry是一個鏈表結構的元素, 每個Segment守護者一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先獲得它對應的Segment鎖。

三、ConcurrentHashMap實現原理
  鎖分離 (Lock Stripping)
  ConcurrentHashMap內部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的hash table,它們有自己的鎖。只要多個修改操作發生在不同的段上,它們就可以併發進行。同樣當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
  ConcurrentHashMap有些方法需要跨段,比如size()和containsValue(),它們可能需要鎖定整個表而而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢後,又按順序釋放所有段的鎖。這裏"按順序"是很重要的,否則極有可能出現死鎖,在ConcurrentHashMap內部,段數組是final的,並且其成員變量實際上也是final的,但是,僅僅是將數組聲明爲final的並不保證數組成員也是final的,這需要實現上的保證。這可以確保不會出現死鎖,因爲獲得鎖的順序是固定的不變性是多線程編程佔有很重要的地位,下面還要談到。
  final Segment<K,V>[] segments;


  不變(Immutable)和易變(Volatile)
  ConcurrentHashMap完全允許多個讀操作併發進行,讀操作並不需要加鎖。如果使用傳統的技術,如HashMap中的實現,如果允許可以在hash鏈的中間添加或刪除元素,讀操作不加鎖將得到不一致的數據ConcurrentHashMap實現技術是保證HashEntry幾乎是不可變的HashEntry代表每個hash鏈中的一個節點,其結構如下所示:
  static final class HashEntry<K,V> {
  final K key;
  final int hash;
  volatile V value;
  final HashEntry<K,V> next;
  }
  可以看到除了value不是final的,其它值都是final的,爲了防止鏈表結構被破壞,出現ConcurrentModification的情況。這意味着不能從hash鏈的中間或尾部添加或刪除節點,因爲這需要修改next引用值,所有的節點的修改只能從頭部開始對於put操作,可以一律添加到Hash鏈的頭部。但是對於remove操作,可能需要從中間刪除一個節點,這就需要將要刪除節點的前面所有節點整個複製一遍,最後一個節點指向要刪除結點的下一個結點,爲了確保讀操作能夠看到最新的值,將value設置成volatile,這避免了加鎖

  

ConcurrentHashMap的初始化

下面我們來結合源代碼來具體分析一下ConcurrentHashMap的實現,先看下初始化方法:

 

Java代碼  
  1. public ConcurrentHashMap(int initialCapacity,  
  2.                          float loadFactor, int concurrencyLevel) {  
  3.     if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)  
  4.         throw new IllegalArgumentException();  
  5.    
  6.     if (concurrencyLevel > MAX_SEGMENTS)  
  7.         concurrencyLevel = MAX_SEGMENTS;  
  8.    
  9.     // Find power-of-two sizes best matching arguments  
  10.     int sshift = 0;  
  11.     int ssize = 1;  
  12.     while (ssize < concurrencyLevel) {  
  13.         ++sshift;  
  14.         ssize <<= 1;  
  15.     }  
  16.     segmentShift = 32 - sshift;  
  17.     segmentMask = ssize - 1;  
  18.     this.segments = Segment.newArray(ssize);  
  19.    
  20.     if (initialCapacity > MAXIMUM_CAPACITY)  
  21.         initialCapacity = MAXIMUM_CAPACITY;  
  22.     int c = initialCapacity / ssize;  
  23.     if (c * ssize < initialCapacity)  
  24.         ++c;  
  25.     int cap = 1;  
  26.     while (cap < c)  
  27.         cap <<= 1;  
  28.    
  29.     for (int i = 0; i < this.segments.length; ++i)  
  30.         this.segments[i] = new Segment<K,V>(cap, loadFactor);  
  31. }  

 

CurrentHashMap的初始化一共有三個參數,一個initialCapacity,表示初始的容量,一個loadFactor,表示負載參數,最後一個是concurrentLevel,代表ConcurrentHashMap內部的Segment的數量,ConcurrentLevel一經指定,不可改變,後續如果ConcurrentHashMap的元素數量增加導致ConrruentHashMap需要擴容,ConcurrentHashMap不會增加Segment的數量,而只會增加Segment中鏈表數組的容量大小,這樣的好處是擴容過程不需要對整個ConcurrentHashMap做rehash,而只需要對Segment裏面的元素做一次rehash就可以了。

整個ConcurrentHashMap的初始化方法還是非常簡單的,先是根據concurrentLevel來new出Segment,這裏Segment的數量是不大於concurrentLevel的最大的2的指數,就是說Segment的數量永遠是2的指數個,這樣的好處是方便採用移位操作來進行hash,加快hash的過程。接下來就是根據intialCapacity確定Segment的容量的大小,每一個Segment的容量大小也是2的指數,同樣使爲了加快hash的過程。

這邊需要特別注意一下兩個變量,分別是segmentShift和segmentMask,這兩個變量在後面將會起到很大的作用,假設構造函數確定了Segment的數量是2的n次方,那麼segmentShift就等於32減去n,而segmentMask就等於2的n次方減一。

ConcurrentHashMap的get操作

前面提到過ConcurrentHashMap的get操作是不用加鎖的,我們這裏看一下其實現:

1
2
3
4
public V get(Object key) { 
    int hash = hash(key.hashCode()); 
    return segmentFor(hash).get(key, hash); 

  segmentFor這個函數用於確定操作應該在哪一個segment中進行,幾乎對ConcurrentHashMap的所有操作都需要用到這個函數,我們看下這個函數的實現:

1
2
3
final Segment<K,V> segmentFor(int hash) { 
    return segments[(hash >>> segmentShift) & segmentMask]; 
}    

  這個函數用了位操作來確定Segment,根據傳入的hash值向右無符號右移segmentShift位,然後和segmentMask進行與操作,結合我們之前說的segmentShift和segmentMask的值,就可以得出以下結論:假設Segment的數量是2的n次方,根據元素的hash值的高n位就可以確定元素到底在哪一個Segment中。

  在確定了需要在哪一個segment中進行操作以後,接下來的事情就是調用對應的Segment的get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
V get(Object key, int hash) { 
    if (count != 0) { // read-volatile 
        HashEntry<K,V> e = getFirst(hash); 
        while (e != null) { 
            if (e.hash == hash && key.equals(e.key)) { 
                V v = e.value; 
                if (v != null
                    return v; 
                return readValueUnderLock(e); // recheck 
            
            e = e.next; 
        
    
    return null

  get操作不需要鎖。第一步是訪問count變量,這是一個volatile變量,由於所有的修改操作在進行結構修改時都會在最後一步寫count變量,通過這種機制保證get操作能夠得到幾乎最新的結構更新。對於非結構更新,也就是結點值的改變,由於HashEntry的value變量是volatile的,也能保證讀取到最新的值。接下來就是對hash鏈進行遍歷找到要獲取的結點,如果沒有找到,直接訪回null。對hash鏈進行遍歷不需要加鎖的原因在於鏈指針next是final的。但是頭指針卻不是final的,這是通過getFirst(hash)方法返回,也就是存在table數組中的值。這使得getFirst(hash)可能返回過時的頭結點,例如,當執行get方法時,剛執行完getFirst(hash)之後,另一個線程執行了刪除操作並更新頭結點,這就導致get方法中返回的頭結點不是最新的。這是可以允許,通過對count變量的協調機制,get能讀取到幾乎最新的數據,雖然可能不是最新的。要得到最新的數據,只有採用完全的同步。

1
2
3
4
5
6
7
8
V readValueUnderLock(HashEntry<K,V> e) { 
    lock(); 
    try 
        return e.value; 
    finally 
        unlock(); 
    
}    

  最後,如果找到了所求的結點,判斷它的值如果非空就直接返回,否則在有鎖的狀態下再讀一次。這似乎有些費解,理論上結點的值不可能爲空,這是因爲put的時候就進行了判斷,如果爲空就要拋NullPointerException。空值的唯一源頭就是HashEntry中的默認值,因爲HashEntry中的value不是final的,非同步讀取有可能讀取到空值。仔細看下put操作的語句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在這條語句中,HashEntry構造函數中對value的賦值以及對tab[index]的賦值可能被重新排序,這就可能導致結點的值爲空。這種情況應當很罕見,一旦發生這種情況,ConcurrentHashMap採取的方式是在持有鎖的情況下再讀一遍,這能夠保證讀到最新的值,並且一定不會爲空值。

  對volatile字段的寫入操作happens-before於每一個後續的同一個字段的讀操作。

  因爲實際上put、remove等操作也會更新count的值,所以當競爭發生的時候volatile的語義可以保證寫操作在讀操作之前,也就保證了寫操作對後續的讀操作都是可見的,這樣後面get的後續操作就可以拿到完整的元素內容。

 

ConcurrentHashMap的put操作

看完了get操作,再看下put操作,put操作的前面也是確定Segment的過程,直接看關鍵的segment的put方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
V put(K key, int hash, V value, boolean onlyIfAbsent) { 
    lock();  //加鎖
    try 
        int c = count; 
        if (c++ > threshold) // ensure capacity 
            rehash();  //看是否需要rehash
        HashEntry<K,V>[] tab = table; 
        int index = hash & (tab.length - 1); 
        HashEntry<K,V> first = tab[index];  //確定鏈表頭部的位置
        HashEntry<K,V> e = first; 
        while (e != null && (e.hash != hash || !key.equals(e.key))) 
            e = e.next; 
    
        V oldValue; 
        if (e != null) {  //如果存在,替換掉value
            oldValue = e.value; 
            if (!onlyIfAbsent) 
                e.value = value; 
        
        else 
            oldValue = null
            ++modCount;   //修改modCount和count?
            tab[index] = new HashEntry<K,V>(key, hash, first, value);  //創建一個新的結點並添加到hash鏈的頭部
            count = c; // write-volatile 
        
        return oldValue; 
    finally 
        unlock(); 
    

  

首先對Segment的put操作是加鎖完成的,然後在第五行,如果Segment中元素的數量超過了閾值(由構造函數中的loadFactor算出)這需要進行對Segment擴容,並且要進行rehash

    第8和第9行的操作就是getFirst的過程,確定鏈表頭部的位置。

   第11行這裏的這個while循環是在鏈表中尋找和要put的元素相同key的元素,如果找到,就直接更新更新key的value,如果沒有找到,則進入21行這裏,生成一個新的HashEntry並且把它加到整個Segment的頭部,然後再更新count的值。

  修改操作還有putAll和replace。putAll就是多次調用put方法。replace甚至不用做結構上的更改,實現要比put和delete要簡單得多.

ConcurrentHashMap的remove操作

Remove操作的前面一部分和前面的get和put操作一樣,都是定位Segment的過程,然後再調用Segment的remove方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
V remove(Object key, int hash, Object value) { 
    lock(); 
    try 
        int c = count - 1
        HashEntry<K,V>[] tab = table; 
        int index = hash & (tab.length - 1); 
        HashEntry<K,V> first = tab[index]; 
        HashEntry<K,V> e = first; 
        while (e != null && (e.hash != hash || !key.equals(e.key))) 
            e = e.next;  //空白行之前的行主要是定位到要刪除的節點e
    
        V oldValue = null
        if (e != null) {  
            V v = e.value; 
            if (value == null || value.equals(v)) {  
                oldValue = v; 
                // All entries following removed node can stay 
                // in list, but all preceding ones need to be 
                // cloned. 
                ++modCount; 
                HashEntry<K,V> newFirst = e.next; 
                for (HashEntry<K,V> p = first; p != e; p = p.next) 
                    newFirst = new HashEntry<K,V>(p.key, p.hash, 
                                                  newFirst, p.value); 
                tab[index] = newFirst; 
                count = c; // write-volatile 
            
        
        return oldValue; 
    finally 
        unlock(); 
    
}

  整個操作是先定位到段,然後委託給段的remove操作。當多個刪除操作併發進行時,只要它們所在的段不相同,它們就可以同時進行。下面是Segment的remove方法實現

  首先remove操作也是確定需要刪除的元素的位置,HashEntry中的next是final的,一經賦值以後就不可修改,在定位到待刪除元素的位置以後,程序就將待刪除元素前面的那一些元素全部複製一遍,然後再一個一個重新接到鏈表上去.

  將e前面的結點複製一遍,尾結點指向e的下一個結點。e後面的結點不需要複製,它們可以重用.

假設鏈表中原來的元素如上圖所示,現在要刪除元素3,那麼刪除元素3以後的鏈表就如下圖所示:

 

ConcurrentHashMap的size操作

在前面的章節中,我們涉及到的操作都是在單個Segment中進行的,但是ConcurrentHashMap有一些操作是在多個Segment中進行,比如size操作,ConcurrentHashMap的size操作也採用了一種比較巧的方式,來儘量避免對所有的Segment都加鎖。

前面我們提到了一個Segment中的有一個modCount變量,代表的是對Segment中元素的數量造成影響的操作的次數,這個值只增不減,size操作就是遍歷了兩次Segment,每次記錄Segment的modCount值,然後將兩次的modCount進行比較,如果相同,則表示期間沒有發生過寫入操作,就將原先遍歷的結果返回,如果不相同,則把這個過程再重複做一次,如果再不相同,則就需要將所有的Segment都鎖住,然後一個一個遍歷了.

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