你好呀,我是歪歪。
這篇文章來盤一下我最近遇到的兩個有意思的代碼案例,有意思的點在於,拿到代碼後,你一眼望去,沒有任何毛病。然後一頓分析,會發現破綻藏的還比較的深。
幾個基礎招式的一套組合拳下來,直接把我打懵逼了。
你也來看看,是不是你跺你也麻。
第一個場景
首先第一個是這樣的:
一個讀者給我發來的一個關於線程池使用的疑問,同時附上了一個可以復現問題的 Demo。
我打開 Demo 一看,一共就這幾行代碼,結合問題描述來看想着應該不是啥複雜的問題:
我拿過來 Demo,根本就沒看代碼,直接扔到 IDEA 裏面跑了兩次,想着是先看看具體報錯是什麼,然後再去分析代碼。
但是兩次程序都正常結束了。
好吧,既然沒有異常,我也大概的瞅了一眼 Demo,重點關注在了 CountDownLatch 的用法上。
我是橫看豎看也沒看出問題,因爲我一直都是這樣用的,這就是正確的用法啊。
於是從拿到 Demo 到定位問題,不到兩分鐘,我直接得出了一個大膽的結論,那就是:常規用法,沒有問題:
然後我們就結束了這次對話。
過了一會,我準備關閉 IDEA 了。鬼使神差的,我又點了一次運行。
你猜怎麼着?
居然真的報錯了,拋出了 rejectedExecution 異常,意思是線程池滿了。
哦喲,這就有點意思了。
帶大家一起盤一盤。
首先我們還是過一下代碼,爲了減少干擾項,便於理解,我把他給我的 Demo 稍微簡化了一點,但是整體邏輯沒有發生任何變化。
簡化後的完整代碼是這樣的,你直接粘過去,引入一個 guava 的包就能跑:
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class Test {
private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 400; i++) {
list.add(i);
}
for (int i = 0; i < 100; i++) {
List<List<Integer>> sublist = Lists.partition(list, 400 / 32);
int n = sublist.size();
CountDownLatch countDownLatch = new CountDownLatch(n);
for (int j = 0; j < n; j++) {
threadPoolExecutor.execute(() -> {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("===============> 詳情任務 - 任務處理完成");
}
System.out.println("都執行完成了");
}
}
/**
* <dependency>
* <groupId>com.google.guava</groupId>
* <artifactId>guava</artifactId>
* <version>31.1-jre</version>
* </dependency>
*/
一起分析一波代碼啊。
首先定義了一個線程池:
private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));
該線程池核心大小數和最大線程數都是 64,隊列長度爲 32,也就是說這個線程池同時能容納的任務數是 64+32=96。
main 方法裏面是這樣的:
在實際代碼中,肯定是有具體的業務含義的,這裏爲了脫敏,就用 List 來表示一下,這個點你知道就行。
編號爲 ① 的地方,是在給往 list 裏面放 400 個數據,你可以認爲是 400 個任務。
編號爲 ② 的地方,這個 List 是 guava 的 List,含義是把 400 個任務拆分開,每一組有 400/32=12.5 個任務,向下取整,就是 12 個。
具體是什麼個意思呢,我給你看一下 debug 的截圖你就知道了:
400 個任務分組,每一組 12 個任務,那就可以拆出來 34 組,最後一組只有 4 個任務:
但是這都不重要,一點都不重要好吧。
因爲後續他根本就沒有用這個 list ,只是用到了 size 的大小,即 34 。
所以你甚至還能拿到一個更加簡潔的代碼:
爲什麼我最開始的時候不直接給你這個最簡化的代碼,甚至還讓你多引入一個包呢?
因爲歪師傅就是想體現這個簡化代碼的過程。
按照我寫文章的經驗,在定位問題的時候,一定要儘量多的減少干擾項。排除干擾項的過程,也是梳理問題的過程,很多問題在排除干擾項的時候,就逐漸的能摸清楚大概是怎麼回事兒。
如果你遇到一個讓你摸不着頭腦的問題,那就先從排除干擾項做起。
好了,說回我們的代碼。現在我們的代碼就只有這幾行了,核心邏輯就是我圈起來的這個方法:
而圈起來這個部分,主要是線程池結合 CountDownLatch 的使用。
對於 CountDownLatch 我一般只關注兩個地方。
第一個是 new 的時候傳入的“令牌數”和調用 countDown 方法的次數能不能匹配上。只有保持一致,程序才能正常運行。
第二個地方就是 countDown 方法的調用是不是在 finally 方法裏面。
這兩個點,在 Demo 中都是正確的。
所以現在從程序分析不出來問題,我們怎麼辦?
那就從異常信息往回推算。
我們的異常信息是什麼?
觸發了線程池拒絕策略:
什麼時候會出現線程池拒絕策略呢?
核心線程數用完了,隊列滿了,最大線程數也用完了的時候。
但是按理來說,由於有 countDownLatch.await() 的存在,在執行完 for 循環中的 34 次 countDownLatch.countDown() 方法之前,主線程一定是阻塞等待的。
而 countDownLatch.countDown() 方法在 finally 方法中調用,如果主線程繼續運行,執行外層的 for 循環,放新的任務進來,那說明線程池裏面的任務也一定執行完成了。
線程池裏面的任務執行完成了,那麼核心線程就一定會釋放出來等着接受下一波循環的任務。
這樣捋下來,感覺還是沒毛病啊?
除非線程池裏面的任務執行完成了,核心線程就一定會釋放出來等着接受下一波循環的任務,但是不會立馬釋放出來。
什麼意思呢?
就是當一個核心線程執行完成任務之後,到它進入下一次可以開始處理任務的狀態之間,有時間差。
而由於這個時間差的存在,導致第一波的核心線程雖然全部執行完成了 countDownLatch.countDown(),讓主線程繼續運行下去。 但是,在線程池中還有少量線程未再次進入“可以處理任務”的狀態,還在進行一些收尾的工作。
從而導致,第二波任務進來的時候,需要開啓新的核心線程數來執行。
放進來的任務速度,快於核心線程的“收尾工作”的時間,最終導致線程池滿了,觸發拒絕策略。
需要說明的是,這個原因都是基於我個人的猜想和推測。這個結論不一定真的正確,但是偉人曾經說過:大膽假設,小心求證。
所以,爲了證明這個猜想,我需要找到實錘證據。
從哪裏找實錘呢?
源碼之下,無祕密。
當我有了這個猜想之後,我立馬就想到了線程池的這個方法:
java.util.concurrent.ThreadPoolExecutor#runWorker
標號爲 ① 的地方是執行線程 run 方法,也就是這一行代碼執行完成之後,一個任務就算是執行完成了。對應到我們的 Demo 也就是這部分執行完成了:
這部分執行完成了,countDownLatch.countDown() 方法也執行完成了。
但是這個核心線程還沒跑完呢,它還要繼續往下走,執行標號爲 ② 和 ③ 處的收尾工作。
在覈心線程執行“收尾工作”時,主線程又咔咔就跑起來了,下一波任務就扔進來了。
這不就是時間差嗎?
另外,我再問一個問題:線程池裏面的一個線程是什麼時候處於“來吧,哥們,我可以處理任務了”的狀態的?
是不是要執行到紅框框着的這個地方 WAITING 着:
java.util.concurrent.ThreadPoolExecutor#getTask
那在執行到這個紅框框之前,還有一大坨代碼呢,它們不是收尾工作,屬於“就緒準備工作”。
現在我們再捋一捋啊。
線程池裏面的一個線程在執行完成任務之後,到下一次可以執行任務的狀態之間,有一個“收尾工作”和“就緒準備工作”,這兩個工作都是非常快就可以執行完成的。
但是這“兩個工作”和“主線程繼續往線程池裏面扔任務的動作”之間,沒有先後邏輯控制。
從程序上講,這是兩個獨立的線程邏輯,誰先誰後,都有可能。
如果“兩個工作”先完成,那麼後面扔進來的任務一定是可以複用線程的,不會觸發新開線程的邏輯,也就不會觸發拒絕策略。
如果“主線程繼續往線程池裏面扔任務的動作”先完成,那麼就會先開啓新線程,從而有可能觸發拒絕策略。
所以最終的執行結果可能是不報錯,也可能是拋出異常。
同時也回答了這個問題:爲什麼提高線程池的隊列長度,就不拋出異常了?
因爲隊列長度越長,核心線程數不夠的時候,任務大不了在隊列裏面堆着。而且只會堆一小會兒,但是這一小會,給了核心線程足夠的時間去完成“兩個工作”,然後就能開始消耗隊列裏面的任務。
另外,提出問題的小夥伴說換成 tomcat 的線程池就不會被拒絕了:
也是同理,因爲 tomcat 的線程池重寫了拒絕策略,一個任務被拒絕之後會進行重試,嘗試把任務仍回到隊列中去,重試是有可能會成功的。
對應的源碼是這個部分:
org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit)
這就是我從源碼中找到的實錘。
但是我覺得錘的還不夠死,我得想辦法讓這個問題必現一下。
怎麼弄呢?
如果要讓問題必現,那麼就是延長“核心線程完成兩個工作”的時間,讓主線程扔任務的動作”的動作先於它完成。
很簡單,看這裏,afterExecute 方法:
線程池給你留了一個統計數據的口子,我們就可以基於這個口子搞事情嘛,比如睡一下下:
private static final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES,
new ArrayBlockingQueue<>(32)) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
由於收尾任務的時間過長,這樣“主線程扔任務的動作”有極大概率的是先執行的,導致觸發拒絕策略:
到這裏,這個問題其實就算是分析完成了。
但是我還想分享一個我在驗證過程中的一個驗證思路,雖然這個思路最終並沒有得到我想要的結論,但是技多不壓身,你抽空學學,以後萬一用得上呢。
前面說了,在我重寫了 afterExecute 方法之後,一定會觸發拒絕策略。
那麼我在觸發拒絕策略的時候,dump 一把線程,通過 dump 文件觀察線程狀態,是不是就可以看到線程池裏面的線程,可能還在 RUNNING 狀態,但是是在執行“兩個工作”呢?
於是就有了這樣的代碼:
我自定義了一個拒絕策略,在觸發拒絕策略的時候,dump 一把線程池:
但是很不幸,最終 dump 出來的結果並不是我期望的,線程池裏面的線程,不是在 TIMED_WAITING 狀態就是在 WAITING 狀態,沒有一個是 RUNNING 的。
爲什麼?
很簡單,因爲在觸發拒絕策略之後,dump 完成之前,這之間代碼執行的時間,完全夠線程池裏面的線程完成“兩個工作”。
雖然你 dump 了,但是還是晚了一點。
這一點,可以通過在 dump 前面輸出一點日誌進行觀察驗證:
雖然我沒有通過 dump 文件驗證到我的觀點,但是你可以學習一下這個手段。
在正常的業務邏輯中觸發拒絕策略的時候,可以 dump 一把,方便你分析。
那麼問題就來了?
怎麼去 dump 呢?
關鍵代碼就這一行:
JVMUtil.jstack(jStackStream);
這個方法其實是 Dubbo 裏面的一個工具,我只是引用了一下 Dubbo 的包:
但是你完全可以把這個工具類粘出去,粘到你的項目中去。
你的代碼很好,現在它是我的了。
最後,我還是必須要再補充一句:
以上從問題的定位到問題的復現,都是基於我個人的分析,從猜測出發,最終進行驗證的。有可能我猜錯了,那麼整個論證過程可能都是錯的。你可以把 Demo 粘過去跑一跑,帶着懷疑一切的眼光去審視它,如果你有不同的看法,可以告訴我,我學習一下。
最後,你想想整個過程。
拆開了看,無非是線程池和 CountDownLatch 的八股文的考察,這兩個玩意都是面試熱點考察部分,大家應該都背的滾瓜爛熟。
在實際工作中,這兩個東西碰撞在一起也是經常有的寫法,但是沒想到的是,在套上一層簡單的 for 循環之後,完全就變成了一個複雜的問題了。
這玩意着實是把我打懵逼了。以後把 CountDownLatch 放在 for 循環裏面的場景,都需要多多注意一下了。
第二個場景
這個場景就簡單很多了。
當時有個小夥伴在羣裏扔了一個截圖:
需要注意的是, if(!lock) 他截圖的時候是給錯了,真實的寫法是 if(lock),lock 爲 true 的時候就是加鎖成功,進入 if。
同時這個代碼這一行是有事務的:
寫一個對應的僞代碼是這樣的:
if(加鎖成功){
try{
//save有事務註解,並且確認調用的service對象是被代理的對象,即事務的寫法一定是正確的
return service.save();
} catch(Exception e){
//異常打印
} finally {
//釋放鎖
unlock(lockKey);
}
}
就上面這個寫法,先加鎖,再開啓事務,執行事務方法,接着提交事務,最後解鎖,反正歪師傅橫看豎看是沒有發現有任何毛病的。
但是提供截圖的小夥伴是這樣描述的。
當他是這樣寫的時候,從結果來看,程序是先加鎖,再開啓事務,執行事務方法,然後解鎖,最後才提交事務:
當時我就覺得:這現象完全超出了我的認知,絕不可能。
緊接着他提供了第二張截圖:
他說這樣拆開寫的時候,事務就能正常生效了:
這兩個寫法的唯一區別就是一個是直接 return,一個是先返回了一個 resultModel 然後在 return。
在實際效果上,我認爲是沒有任何差異的。
但是他說這樣寫會導致鎖釋放的時機不一樣。
我還是覺得:
然而突然有人冒出來說了一句: try 帶着 finally 的時候,在執行 return 語句之前會先執行 finally 裏面的邏輯。會不會是這個原因導致的呢?
按照這個邏輯推,先執行了 finally 裏面的釋放鎖邏輯,再執行了 return 語句對應的表達式,也就是事務的方法。那麼確實是會導致鎖釋放在事務執行之前。
就是這句話直接給我幹懵逼了,CPU 都快燒了,感覺哪裏不對,又說不上來爲什麼。
雖然很反直覺,但是我也記得八股文就是這樣寫的啊,於是我開始覺得有點意思了。
所以我搞了一個 Demo,準備本地復現一下。
當時想着,如果能復現,這可是一個違背直覺的巨坑啊,是一個很好的寫作素材。
可惜,沒有復現:
最後這個哥們也重新去定位了原因,發現是其他的 BUG 導致的。
另外,關於前面“try 帶着 finally”的說法其實說的並不嚴謹,應該是當 try 中帶有 return 時,會先執行 return 前的代碼,然後把需要 return 的信息暫存起來,接着再執行 finally 中的代碼,最後再通過 return 返回之前保存的信息。
這纔是寫在八股文裏面的正確答案。
要永遠牢記另一位偉人說過:實踐是檢驗真理的唯一標準。
遇事不決,就搞個 Demo 跑跑。
關於這個場景,其實也很簡單,拆開來看就是關於事務和鎖碰撞在一起時的注意事項以及 try-return-finally 的執行順序這兩個基礎八股而已。
但是當着兩個糅在一起的時候,確實有那麼幾個瞬間讓我眼前一黑,又打得我一臉懵逼。
最後,事務和鎖碰撞在一起的情況,上個僞代碼:
@Service
public class ServiceOne{
// 設置一把可重入的公平鎖
private Lock lock = new ReentrantLock(true);
@Transactional(rollbackFor = Exception.class)
public Result func(long seckillId, long userId) {
lock.lock();
// 執行數據庫操作——查詢商品庫存數量
// 如果 庫存數量 滿足要求 執行數據庫操作——減少庫存數量——模擬賣出貨物操作
lock.unlock();
}
}
如果你五秒鐘沒看出這個代碼的問題,秒殺這個問題的話,那歪師傅推薦你個假粉絲看看這篇文章::《幾行爛代碼,我賠了16萬。》
好了,就醬,打完收工~