JDK8 ConcurrentHashMap的死鎖bug

JDK1.8中,其內部實現變化較大,內部對不再使用1.7版本的Segment鎖,而是使用synchronized + CAS(Unsafe類)實現來更高效的對map中每個Node的細粒度獨佔鎖定並更新。

新實現中,對元素的更新操作代碼變化較大。比如下面方法的使用,稍不注意就會產生死鎖

public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)
       ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(16);
       map.computeIfAbsent(
           "AaAa",
           key ->  map.computeIfAbsent("BBBB", key2 -> 42)
       );

引用自:https://stackoverflow.com/questions/43861945/deadlock-in-concurrenthashmap

執行上面的代碼片段會產生死鎖。當map中不存在key="AaAa"時,computeIfAbsent會插入該key,並將以下lamda函數的返回值(42)作爲它的value。而這個lamda函數其實會繼續去對key="BBBB"的Node進行同樣操作,並設置value=42。但是由於這裏的“AaAa”和“BBBB”這個字符串的hashCode一樣,導致執行出現死鎖(https://stackoverflow.com/questions/43861945/deadlock-in-concurrenthashmap)。

   key ->  map.computeIfAbsent("BBBB", key2 -> 42);

這篇文章 https://www.jianshu.com/p/59bd27e137e1 認爲是computeIfAbsent方法中的CAS操作造成的,synchronized是可重入的鎖,兩次去獲取同一個Node的鎖不會阻塞,爲什麼CAS會造成這個問題?我太不認同他的說法,還需要進一步分析。

我在一個名爲ConcurrentMapBug的類中測試以上代碼塊,通過命令
jstack -l pid獲取到線程的執行堆棧內容如下:

"main" #1 prio=5 os_prio=0 tid=0x0062e000 nid=0x614 runnable [0x005ef000]
   java.lang.Thread.State: RUNNABLE
        at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1718)
        at concurrent.map.ConcurrentMapBug.lambda$main$1(ConcurrentMapBug.java:13)
        at concurrent.map.ConcurrentMapBug$$Lambda$1/10634667.apply(Unknown Source)
        at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660)
        - locked <0x049a9c60> (a java.util.concurrent.ConcurrentHashMap$ReservationNode)
        at concurrent.map.ConcurrentMapBug.main(ConcurrentMapBug.java:11)

注意到這幾行:

java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660)
        - locked <0x049a9c60> (a java.util.concurrent.ConcurrentHashMap$ReservationNode)
        at concurrent.map.ConcurrentMapBug.main(ConcurrentMapBug.java:11)

說是在JDK代碼中第1660行處被lock了,查看ConcurrentHashMap源碼:

public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { 
        if (key == null || mappingFunction == null)
            throw new NullPointerException();
        int h = spread(key.hashCode());
        V val = null;
        int binCount = 0; #JDK1.8.0_152源代碼中1648行,binCount值初始化爲0 
        for (Node<K,V>[] tab = table;;) { #1649行
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { #1653行
                Node<K,V> r = new ReservationNode<K,V>(); #1654行,新建一個佔位Node r
                synchronized (r) { #1655行,獲取Node r的monitor鎖
                    if (casTabAt(tab, i, null, r)) { #1656行,通過CAS方式將Node r插入到map內置的table中
                        binCount = 1;
                        Node<K,V> node = null;
                        try {
                            if ((val = mappingFunction.apply(key)) != null) #1660行
                                node = new Node<K,V>(h, key, val, null);
                        } finally {
                            setTabAt(tab, i, node);
                        }
                    }
                }
                if (binCount != 0)
                    break;
            }
            else if ((fh = f.hash) == MOVED)
                 tab = helpTransfer(tab, f);
            else { #1672boolean added = false;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) { #1676行
                            binCount = 1; #上面例子不會被執行到這裏
                            ....
                        }
                        ... #TreeBin
                   }
           }
           if (binCount != 0) { #1710行
           #參考1648行,每次執行computeIfAbsent,都會初始化爲0,
           #因此第二次調用computeIfAbsent時不會執行到if body裏面代碼
               if (binCount >= TREEIFY_THRESHOLD)
                   treeifyBin(tab, i);
               if (!added)
                   return val;
               break;
           }
       } #end 1649行的for_loop
       ...

map.computeIfAbsent("AaAa", mapFunction)會進入1653行的分支。代碼中1654行,這裏會創建一個佔位符作用的Node(hash=-3, key=null, value=null, next Node=null),然後將該Node插入到map中(1656行)。每個執行該方法的線程都可以自己創建一個這樣的Node,因此可以多個線程同時來操作同一個key,但是CAS操作只有一個線程能成功,這和以前1.7版本的排他方式不同。

代碼中1656行,CAS 是自旋鎖,不存在鎖的獲取,一般場景下效率較高,這裏只會執行一次,如果執行成功纔會繼續執行,這裏成功意思是在此時沒有其它線程寫入相同key到map中。

執行到1660行時,會執行key -> map.computeIfAbsent("BBBB", key2 -> 42);,這裏再次調用computeIfAbsent,由於"AaAa"和"BBBB"的hash相同,因此會將值存到同一位置,因此執行到1653行時獲取到之前插入的佔位符Node f注意fh = f.hash = -3,此時會執行1672行的分支,在1676行判斷fh >= 0爲false,因此會結束該分支,由於後面再無代碼來退出1649行的循環,所以會進入下一個循環,重複以上過程。代碼因此在1649行的循環中反覆執行,而不是被阻塞。

簡而言之,map.computeIfAbsent("AaAa", mapFunction)在等待mapFunction.apply(key)的返回值,而mapFunction:key -> map.computeIfAbsent("BBBB", key2 -> 42);卻進入了一個死循環,永遠都不會返回。因此整個代碼得執行就被鎖住了,但是這算死鎖嗎?似乎和死鎖的定義不太一樣!

總之,爲了避免這個問題,在JDK1.8中使用ConcurrentHashMap時,不要在computeIfAbsent的lambda函數中再去執行更新其它節點value的操作。

這一點其實在該方法的Java doc中就已經提到:

Some attempted update operations on this map by other threads may be blocked while computation is in progress, so the computation should be short and simple, and must not attempt to update any other mappings of this map.

簡而言之,不要像上面例子中那樣,在一個更新操作中又去對map中其他元素進行更新。

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