本文來自網易雲社區。

作者:張偉

關於HashMap在並發場景下的問題有很多人,很多公司遇到過!也很多人總結過,我們很多時候都認為這樣的坑距離自己很遠,自己一定不會掉入這樣的坑。可是我們隨時都有就遇到了這樣的問題,坑一直都在我們身邊。今天遇到了一個非線程安全對象在並發場景下使用的問題,通過這個案例分析HashMap 在並發場景下使用存在的問題(當然在這個案例中還有很多問題值得我們去分析,值得大家引以為戒。)通過分析問題產生的原因,讓我們今後更好遠離這個BUG。

代碼如圖所示,大家都應該知道HashMap不是線程安全的。那麼HashMap在並發場景下可能存在哪些問題?

  1. 數據丟失
  2. 數據重複
  3. 死循環

關於死循環的問題,在Java8中個人認為是不存在了,在Java8之前的版本中之所以出現死循環是因為在resize的過程中對鏈表進行了倒序處理;在Java8中不再倒序處理,自然也不會出現死循環。

對這個問題Doug Lea 是這樣說的:

Doug Lea writes:

"This is a classic symptom of an incorrectly synchronized use of
HashMap. Clearly, the submitters need to use a thread-safe
HashMap. If they upgraded to Java 5, they could just use
ConcurrentHashMap. If they cant do this yet, they can use
either the pre-JSR166 version, or better, the unofficial backport
as mentioned by Martin. If they cant do any of these, they can
use Hashtable or synchhronizedMap wrappers, and live with poorer
performance. In any case, its not a JDK or JVM bug."

I agree that the presence of a corrupted data structure alone
does not indicate a bug in the JDK.

首先看一下put源碼

public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry 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++;
addEntry(hash, key, value, i);
return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}

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

void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

通過上面Java7中的源碼分析一下為什麼會出現數據丟失,如果有兩條線程同時執行到這條語句 table[i]=null,時兩個線程都會區創建Entry,這樣存入會出現數據丟失。

如果有兩個線程同時發現自己都key不存在,而這兩個線程的key實際是相同的,在向鏈表中寫入的時候第一線程將e設置為了自己的Entry,而第二個線程執行到了e.next,此時拿到的是最後一個節點,依然會將自己持有是數據插入到鏈表中,這樣就出現了數據 重複。通過商品put源碼可以發現,是先將數據寫入到map中,再根據元素到個數再決定是否做resize.在resize過程中還會出現一個更為詭異都問題死循環。這個原因主要是因為hashMap在resize過程中對鏈表進行了一次倒序處理。假設兩個線程同時進行resize, A->B 第一線程在處理過程中比較慢,第二個線程已經完成了倒序編程了B-A 那麼就出現了循環,B->A->B.這樣就出現了就會出現CPU使用率飆升。

在下午突然收到其中一台機器CPU利用率不足告警,將jstack內容分析發現,可能出現了死循環和數據丟失情況,當然對於鏈表的操作同樣存在問題。

PS:在這個過程中可以發現,之所以出現死循環,主要還是在於對於鏈表對倒序處理,在Java 8中,已經不在使用倒序列表,死循環問題得到了極大改善。

下圖是負載和CPU的表現:

下面是線程棧的部分日誌:

DubboServerHandler-10.172.75.33:20880-thread-139" daemon prio=10 tid=0x0000000004a93000 nid=0x76fe runnable [0x00007f0ddaf2d000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.getEntry(HashMap.java:465)
at java.util.HashMap.containsKey(HashMap.java:449)

"pool-9-thread-16" prio=10 tid=0x00000000033ef000 nid=0x4897 runnable [0x00007f0dd62cb000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.put(HashMap.java:494)

DubboServerHandler-10.172.75.33:20880-thread-189" daemon prio=10 tid=0x00007f0de99df800 nid=0x7722 runnable [0x00007f0dd8b09000]
java.lang.Thread.State: RUNNABLE
at java.lang.Thread.yield(Native Method)

DubboServerHandler-10.172.75.33:20880-thread-157" daemon prio=10 tid=0x00007f0de9a94800 nid=0x7705 runnable [0x00007f0dda826000]
java.lang.Thread.State: RUNNABLE
at java.lang.Thread.yield(Native Method)

原文:HashMap在並發場景下踩過的坑,經作者張偉授權發布

了解網易雲 :

網易雲官網:https://www.163yun.com

網易雲免費體驗館,0成本體驗20+款雲產品!

更多網易研發、產品、運營經驗分享請訪問網易雲社區。


推薦閱讀:
相关文章