三個爛慫八股文,變成兩個場景題,打得我一臉問號。

你好呀,我是歪歪。

這篇文章來盤一下我最近遇到的兩個有意思的代碼案例,有意思的點在於,拿到代碼後,你一眼望去,沒有任何毛病。然後一頓分析,會發現破綻藏的還比較的深。

幾個基礎招式的一套組合拳下來,直接把我打懵逼了。

你也來看看,是不是你跺你也麻。

第一個場景

首先第一個是這樣的:

一個讀者給我發來的一個關於線程池使用的疑問,同時附上了一個可以復現問題的 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萬。》

好了,就醬,打完收工~

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