我的程式突然罷工了|深入探究HSF呼叫異常,從死鎖到活鎖的全面分析與解決

阿里妹導讀
本文詳細記錄了作者在處理HSF呼叫異常問題的過程中,從初步懷疑死鎖到最終發現並解決活鎖問題的全過程。
一、背景
前兩天應用線上機器突然罷工了,HSF呼叫程式的某個介面一直處於執行中狀態,持續了20分鐘(超時時間為60分鐘),正常的響應時間在2分鐘以內,但是奇怪的是業務邏輯也沒有再執行,非常詭異,層層排查從懷疑ForkJoin池使用不當導致程式出現活鎖,再到復現問題時結果與現象相悖直接頭暈爆炸,最後終於從原始碼層面上面找到最終答案。
二、分析過程
2.1 初步分析
首先檢視日誌,排查業務邏輯是否執行,從日誌上面來看,打印出了獲取到鎖的日誌,業務邏輯處於執行狀態。
前一段時間其他專案發生過死鎖問題,導致業務邏輯無法正常執行,這個時候第一想法是出現了死鎖,自信的下載Arthas,輸入thread -b,No most blocking thread found!
當場直接愣住,為什麼沒有死鎖呢?沒有死鎖執行緒,我的程式在幹什麼呢?
2.2 深入排查
本著程式絕大多數時間比人可靠的觀點,繼續深入排查。
既然是HSF執行緒池在Wait,那麼就看看HSF在Wait什麼,一步一步排查,Arthas一頓操作,找到了wait的HSF執行緒(以下截圖的當時執行緒的快照),在wait CompletableFuture的結果返回。
這個時候Arthas再去檢視業務的執行緒在做什麼,居然鎖在了parallelStream.collect的操作,檢視程式碼collect操作只是一個普通的併發操作序列化物件資訊。
突然靈光一現,CompletableFuture 和 parallelStream 使用的是一個公共執行緒池ForkJoin池,是不是出現了此執行緒池出現了問題呢?
Arthas 檢視ForkJoin池在做什麼,發現所有的執行緒都在等待一個鎖,而這個鎖的持有者是正在wait collect的業務執行緒。
好了,大功告成,CompletableFuture 和 parallelStream使用一個執行緒池併發的問題,把其中一個併發去了就死鎖就解除了。
2.3 問題復現
執行緒池執行緒設定為1,此時提交一個任務A,任務A的方法是給執行緒池提交一個任務B,然後獲取任務B的返回值,程式執行後,會發現檢測不到死鎖,但是程式無法正常工作,此時便處於活鎖狀態。
package com.example.learn.thread;import java.util.concurrent.Callable;import java.util.concurrent.ExecutionException;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.concurrent.LinkedBlockingQueue;import java.util.concurrent.ThreadPoolExecutor;import java.util.concurrent.TimeUnit;publicclass LockTest {publicstatic ThreadPoolExecutor handleExecutor = new ThreadPoolExecutor(1, 1,5L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(2), Executors.defaultThreadFactory(),new ThreadPoolExecutor.CallerRunsPolicy());publicstaticvoid main(String[] args) throws InterruptedException, ExecutionException { Callable<String> taskA = new Callable<String>(){@OverridepublicString call() {try { System.out.println("taskA run"); Future<String> taskB = handleExecutor.submit(new Callable<String>() {@OverridepublicString call() throws Exception { System.out.println("taskB run");return"taskB"; } });return taskB.get(); } catch (Exception e) { e.printStackTrace(); }return"taskA"; } }; Future<?> submit = handleExecutor.submit(taskA); submit.get(); System.out.println("finish"); }}
三、山重水複疑無路
3.1 復現問題,結果與現象相悖
解決完死鎖問題後,長舒一口氣,但是突然腦子裡面蹦出來幾個問題,始終讓我覺得問題沒這麼簡單:
  • 程式碼版本很久沒變更了,為什麼這次出問題了?
  • CompletableFuture 和 parallelStream 這種java自帶的用法,如果併發有問題的話,所有的程式都會有這個隱藏問題。而且使用兩個用法的地方穿插在無數類裡面,尤其是在多人開發的情況下,如果我負責寫入口方法,想要用parallelStream做併發操作,其他人提供的實現也正好用了parallelStream,那不就涼了嗎?而且方法如果很複雜,涉及到幾十個類的話,這種問題怎麼避免呢?
不管了,直接把業務邏輯給簡化一下,然後寫一個程式執行看看是否會出現問題吧。 
邏輯如下:
提交4個任務,Fork-join池改成2個(為什麼是2個?因為1個的話CompletableFuture不會使用Fork-join的公共池),理論上來講,Fork-join池都會被全域性鎖給鎖住,此時獲取到鎖的執行緒用parallelStream應該獲取不到Fork-join池的執行緒來做操作,從而導致活鎖。
程式碼如下:
package com.example.learn.thread;import java.util.ArrayList;import java.util.List;import java.util.concurrent.CompletableFuture;import java.util.concurrent.CompletionException;import java.util.concurrent.ExecutionException;import java.util.concurrent.Executors;import java.util.concurrent.LinkedBlockingQueue;import java.util.concurrent.ThreadPoolExecutor;import java.util.concurrent.TimeUnit;import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;import java.util.stream.Collectors;publicclassLockThread{privatestaticfinalList<String> nameList = new ArrayList<>();publicstaticThreadPoolExecutor handleExecutor = new ThreadPoolExecutor(1, 1, 5L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy());static {System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "2"); nameList.add("1"); nameList.add("2"); nameList.add("3"); nameList.add("4"); }privatestaticfinalLockLOCK = new ReentrantLock();publicstatic void main(String[] args) throwsInterruptedException, ExecutionException {List<CompletableFuture<Void>> futures = nameList.stream() .map(name -> CompletableFuture.runAsync(() -> { processJob(name); })) .collect(Collectors.toList());try {CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } catch (CompletionException e) {System.out.println("catch Error"); }System.out.println("finish"); }/** * 做一些複雜操作 * @param name */privatestatic void processJob(String name) {try {//其他操作System.out.println("submit job:" + name + "Thread:" + Thread.currentThread().getName() + ",count:" + handleExecutor.getQueue().size()); handleExecutor.submit(() -> publishMessage(name)); } catch (Exception e) { } }privatestatic void publishMessage(String name) {try { boolean acquired = false;while (!acquired) {try { acquired = LOCK.tryLock(3, TimeUnit.SECONDS);if (!acquired) {Thread.sleep(1000); } } catch (Exception e) { } }// 複雜操作Thread.sleep(2000);List<String> jobList = findByName(name);List<String> resultList = jobList.parallelStream() .map(s -> {try {Thread.sleep(10); } catch (Exception e) { }System.out.println("Thread:" + Thread.currentThread().getName() + ",job:" + s);return s + "complete"; }) .collect(Collectors.toList());System.out.println("Thread:" + Thread.currentThread().getName()+ ",result:" + resultList); } catch (Exception e) { } finally {System.out.println("unlock");LOCK.unlock(); } }privatestaticList<String> findByName(String name) {List<String> result = new ArrayList<>();for (int i = 0; i < 5; i++) { result.add(name + "-" + i); }return result; }}
最後的結果出乎意料,執行緒沒有出現鎖,任務都順利完成了。
任務順序完成的時候,我的頭直接爆炸了,原來的分析都是錯的嗎?Arthas抓住的活鎖難道是假的嗎?只是正好看的瞬間在wait嗎?
3.2 檢視監控,簡化問題,找出蛛絲馬跡
感謝monitor監控會有機器取樣,重新觀察當時的棧快照的詳情,發現與Arthas看到的現象一致,而且觀察wait的物件,block的時間等等,最終確定還是CompletableFuture 和 parallelStream 出現衝突,導致程式活鎖。
寫一個簡單的程式,先佔滿Fork-join池,在用parallelStream 看看能不能完成,最終發現可以完成,但是也發現一些蛛絲馬跡,parallelStream 只有一個執行緒在做事情,而且是當前執行緒,並不是Fork-Join池執行緒。
package com.example.learn.thread;import java.util.ArrayList;import java.util.List;import java.util.stream.Collectors;publicclassParallelStreamTest {static {        System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "2");    }publicstaticvoidmain(String[] args){        List<String> jobList = findByName("1");        boolean always = true;new Thread(() -> {            List<String> resultList = jobList.parallelStream()                .map(s -> {while(always) {try {                            Thread.sleep(1000);                        } catch (Exception e) {                        }//System.out.println("Thread:" + Thread.currentThread().getName() + ", job:" + s);                    }return s + "complete";                })                .collect(Collectors.toList());            System.out.println(resultList);        }).start();        List<String> jobList2 = findByNameTwo("1");new Thread(() ->{while (true) {try {                    jobList2.parallelStream()                        .map(s -> {try {                                Thread.sleep(20);                            } catch (Exception e) {                            }                            System.out.println("Thread:" + Thread.currentThread().getName() + ", 外部迴圈:" + s);return s + "complete";                        })                        .collect(Collectors.toList());                    Thread.sleep(10000);                } catch (Exception e) {                }            }        }).start();    }privatestatic List<String> findByName(String name) {        List<String> result = new ArrayList<>();for (int i = 0; i < 5; i++) {            result.add(name + "-" + i);        }return result;    }privatestatic List<String> findByNameTwo(String name) {        List<String> result = new ArrayList<>();for (int i = 0; i < 1000; i++) {            result.add(name + "-" + i);        }return result;    }}
這個時候崩潰了,如果parallelStream的用法可以保證Fork-join池就算滿了,也能用當前執行緒執行,為什麼我的業務執行緒還會被鎖住呢?
3.3 搜尋文件,詢問其他人有沒有遇到過類似的問題
網上都搜尋不到類似問題,詢問其他人也沒有遇到過類似的問題,難道真的要用出最後一招了嗎?看原始碼,debug原始碼,看看parallelStream到底是怎麼執行的?
四、柳岸花明又一村
4.1 找原始碼解析的文件和模型抽象圖
擷取幾張重要的圖片
參考以下文件:
https://www.cnblogs.com/FraserYu/p/14439497.html
https://www.cnblogs.com/ciel717/p/16444880.html
4.2 文件只是引路人,還需要安安心心debug程式碼
看了很多文章講述Fork-join池的原理,但是都沒有解開我心中的問題,到底什麼時候會用Fork-join的執行緒池呢?什麼時候用本執行緒呢?到底會不會出現活鎖呢?
初步懷疑,當前執行緒提交任務的時候,如果發現Fork-join執行緒有問題就不提交了,自己去執行?
但是經不起推敲,怎麼發現執行緒有問題的呢?
經過Debug發現,parallelStream執行後,確實呼叫了Fork-join中的fork操作,然後將任務放到frok-join的佇列中。
向出現死鎖的執行緒排隊佇列中提交任務,然後還能完成?難道當前執行緒可以獲取到佇列的任務嗎?
順著程式碼排查,發現wait任務完成之前有一個奇怪的方法(“幫忙”?)
最後發現,當前執行緒在“幫忙”的時候,能把佇列中的任務都給處理完成,直到本任務結束。
但是如果是這樣的話,那麼為什麼活鎖出現的現場,當前執行緒沒有“幫忙”把所有任務完成呢?
4.3 真相大白
頭暈眼花的時候,突然發現“幫忙”的時候拿取的是一個佇列,Fork-join池最少有兩個佇列,為啥只幫我處理一個佇列呢?檢查別的地方沒發現有遍歷全部佇列的地方,難道說當時是因為有一個任務分配給其他的死鎖的佇列裡面了嗎?
向Fork-Join池中提交任務時原始碼再探究
發現用當前執行緒提交的任務都只會分配到一個佇列裡面,而且“幫忙”的時候也只會幫忙這一個佇列。
哎,不對,那我這個parallelStream只能用兩個執行緒嗎?透過別的文件發現,Fork-join執行緒池的模型與執行緒池存在區別,而且有一個竊取演算法,可以竊取任務到本佇列。
按照結果和現象來推論當時為什麼會出現活鎖,執行緒池有2個情況下,Work執行緒提交了兩個普通任務,Hsf執行緒提交了兩個死鎖任務,但是很不巧,負責Hsf執行緒處理的fork-1的執行緒stole了一個普通任務,而且過程中hsf執行緒提交了兩個死鎖任務,導致fork-1處於無法工作的狀態,這個時候fork-1佇列中的普通任務無法完成,fork-2拉到死鎖任務,然後Fork-join執行緒全部死鎖。
第一步:
第二步:
第三步:
第四步:
第五步
上述分析很有道理,但是需要需要程式碼證明,Stole的時候並不會提前上一個全域性鎖,不然fork-1執行緒Stole的時候,直接執行的話或者一定此任務最佳化的話,Stole來的3+4就能算完,不會出現活鎖問題。
scan是Stole的核心程式碼
最後發現竊取的任務和普通的任務一樣,都是向佇列做一個push操作,並沒有上全域性鎖,而且fork執行緒做stole操作的任務,一定會放在自己的佇列中。
自此真相大白,如果是上面的這種情況,發生的機率非常低,而且復現的難度也比較高,所以線上運行了很久才出現了這一次問題。
五、總結
對於ForkJoin池的理解不夠,本次問題排查一波三折,期間無數次各種懷疑,最終終於真相大白,支撐排查下來的理念就是程式絕大多數時間比人可靠的觀點,並且80%的問題都可以解決。
後續的建議和修改方案是對於用到ForkJoin池相關的操作如CompletableFuture 和 parallelStream等不要做任何複雜的操作,不要呼叫其他類的方法,只做一些無鎖的基礎操作,如果需要呼叫其他類的方法需要使用自定義執行緒池。
學習一個新知識的時候,搜尋文件是必要的而且有用的,但是大部分的文件都是宏觀層面,並不會深入探究細節,此時需要自己深入debug程式碼結合文件一起學習。
理論為實踐提供指導和支援,實踐則是理論得到驗證和應用的手段。
透過HPA實現容器應用的水平彈性伸縮
本方案使用應用型負載均衡和容器服務 Kubernetes 版智慧分配網路流量,提高應用的高可用性和吞吐量,使用HPA內建元件進行彈性伸縮,提升資源利用率,縮減資源成本。    
點選閱讀原文檢視詳情。

相關文章