
阿里妹導讀
一、背景
前兩天應用線上機器突然罷工了,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>(){
publicString call() {
try {
System.out.println("taskA run");
Future<String> taskB = handleExecutor.submit(new Callable<String>() {
publicString 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內建元件進行彈性伸縮,提升資源利用率,縮減資源成本。
點選閱讀原文檢視詳情。
關鍵詞
程式碼
System.out.println
執行緒
執行緒池
任務