爲何出現死循環簡要說明
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代碼中的這個細節:
- 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);
而我們的線程二執行完成了。於是我們有下面的這個樣子。
注意,因爲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的實現,先看下初始化方法:
- public ConcurrentHashMap(int initialCapacity,
- float loadFactor, int concurrencyLevel) {
- if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
- throw new IllegalArgumentException();
- if (concurrencyLevel > MAX_SEGMENTS)
- concurrencyLevel = MAX_SEGMENTS;
- // Find power-of-two sizes best matching arguments
- int sshift = 0;
- int ssize = 1;
- while (ssize < concurrencyLevel) {
- ++sshift;
- ssize <<= 1;
- }
- segmentShift = 32 - sshift;
- segmentMask = ssize - 1;
- this.segments = Segment.newArray(ssize);
- if (initialCapacity > MAXIMUM_CAPACITY)
- initialCapacity = MAXIMUM_CAPACITY;
- int c = initialCapacity / ssize;
- if (c * ssize < initialCapacity)
- ++c;
- int cap = 1;
- while (cap < c)
- cap <<= 1;
- for (int i = 0; i < this.segments.length; ++i)
- this.segments[i] = new Segment<K,V>(cap, loadFactor);
- }
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採取的方式是在持有鎖的情況下再讀一遍,這能夠保證讀到最新的值,並且一定不會爲空值。
因爲實際上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都鎖住,然後一個一個遍歷了.