看到一個魔改執行緒池,面試素材加一!

why技術發表於2021-11-29

你好呀,我是歪歪。

今天給大家分享一個經過擴充套件後的執行緒池,且我覺得擴充套件的思路非常好的。

放心,我標題黨來著,我覺得面試不會有人考這個玩意,但是工作中是有可能真的會遇到相應的場景。

為了引出這個執行緒池,我先給大家搞個場景,方便理解。

就拿下面這個表情包來做例子吧。

假設我們有兩個程式設計師,就叫富貴和旺財吧。

上面這個表情包就是這兩個程式設計師一天的工作寫照,用程式來表示是這樣的。

首先我們搞一個物件,表示程式設計師當時正在做的事兒:

public class CoderDoSomeThing {

    private String name;
    private String doSomeThing;

    public CoderDoSomeThing(String name, String doSomeThing) {
        this.name = name;
        this.doSomeThing = doSomeThing;
    }
}

然後,用程式碼描述一下富貴和旺財做的事兒:

public class NbThreadPoolTest {

    public static void main(String[] args) {
        CoderDoSomeThing rich1 = new CoderDoSomeThing("富貴""啟動Idea");
        CoderDoSomeThing rich2 = new CoderDoSomeThing("富貴""搞資料庫,連tomcat,crud一頓輸出");
        CoderDoSomeThing rich3 = new CoderDoSomeThing("富貴""嘴角瘋狂上揚");
        CoderDoSomeThing rich4 = new CoderDoSomeThing("富貴""介面訪問報錯");
        CoderDoSomeThing rich5 = new CoderDoSomeThing("富貴""心態崩了,解除安裝Idea");

        CoderDoSomeThing www1 = new CoderDoSomeThing("旺財""啟動Idea");
        CoderDoSomeThing www2 = new CoderDoSomeThing("旺財""搞資料庫,連tomcat,crud一頓輸出");
        CoderDoSomeThing www3 = new CoderDoSomeThing("旺財""嘴角瘋狂上揚");
        CoderDoSomeThing www4 = new CoderDoSomeThing("旺財""介面訪問報錯");
        CoderDoSomeThing www5 = new CoderDoSomeThing("旺財""心態崩了,解除安裝Idea");
    }
}

簡單解釋一下變數的名稱,表明我還是經過深思熟慮了的。

富貴,就是有錢,所以變數名叫做 rich。

旺財,就是汪汪汪,所以變數名叫做 www。

你看我這個類的名稱,NbThreadPoolTest,就知道我是要用到執行緒池了。

實際情況中,富貴和旺財兩個人是可以各幹各的事兒,互不干擾的,也就是他們應該是各自的執行緒。

各幹各的事兒,互不干擾,這聽起來好像是可以用執行緒池的。

所以,我把程式修改成了下面這個樣子,把執行緒池用起來:

public class NbThreadPoolTest {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        List<CoderDoSomeThing> coderDoSomeThingList = new ArrayList<>();

        coderDoSomeThingList.add(new CoderDoSomeThing("富貴""啟動Idea"));
        coderDoSomeThingList.add(new CoderDoSomeThing("富貴""搞資料庫,連tomcat,crud一頓輸出"));
        coderDoSomeThingList.add(new CoderDoSomeThing("富貴""嘴角瘋狂上揚"));
        coderDoSomeThingList.add(new CoderDoSomeThing("富貴""介面訪問報錯"));
        coderDoSomeThingList.add(new CoderDoSomeThing("富貴""心態崩了,解除安裝Idea"));

        coderDoSomeThingList.add(new CoderDoSomeThing("旺財""啟動Idea"));
        coderDoSomeThingList.add(new CoderDoSomeThing("旺財""搞資料庫,連tomcat,crud一頓輸出"));
        coderDoSomeThingList.add(new CoderDoSomeThing("旺財""嘴角瘋狂上揚"));
        coderDoSomeThingList.add(new CoderDoSomeThing("旺財""介面訪問報錯"));
        coderDoSomeThingList.add(new CoderDoSomeThing("旺財""心態崩了,解除安裝Idea"));

        coderDoSomeThingList.forEach(coderDoSomeThing -> {
            executorService.execute(() -> {
                System.out.println(coderDoSomeThing.toString());
            });
        });
    }
}

上面程式就是把富貴和旺財兩人做的事情都封裝到了 list 裡面,然後遍歷這個 list 把裡面的東西,即“做的事情”都扔到執行緒池裡面去。

那麼上面的程式執行後,一種可能的輸出是這樣的:

乍一看沒問題,富貴和旺財都在同時做事。

但是仔細一看,每個人做的事情的順序不對了啊。

比如旺財看起來有點“精神分裂”,剛剛啟動 Idea,嘴角就開始瘋狂上揚了。

所以,到這裡可以引出我想要的東西了。

我想要的是什麼樣的東西呢?

就是在保證富貴和旺財在同時做事的情況下,還要保證他們的做的事情是有一定順序的,即按照我投放到執行緒池裡面的順序來執行。

用正式一點的話來描述是這樣的:

我需要這樣的一個執行緒池,它可以確保投遞進來的任務按某個維度劃分出任務,然後按照任務提交的順序依次執行。這個執行緒池可以通過並行處理(多個執行緒)來提高吞吐量、又要保證一定範圍內的任務按照嚴格的先後順序來執行。

用我前面的例子,“按某個維度”就是人名,就是富貴和旺財這個維度。

請問你怎麼做?

一頓分析

我會怎麼做?

首先,我可以肯定的是 JDK 的執行緒池是幹不成這個事兒的。

因為從執行緒池原理的角度來說,並行和先後順序它是不能同時滿足的。

你明白我意思吧?

比如我要用執行緒池來保證先後順序,那麼它是這樣的:

只有一個執行緒的執行緒池,它可以保證先後順序。

但是這玩意有意義嗎?

有點意義,因為它並不佔用主執行緒,但是意義不大,畢竟閹割了重要的“多執行緒”能力。

所以我們怎麼在這個場景下把並行能力給提上去呢?

等等,我們好像已經有一個可以保證先後順序的執行緒池了。

那麼我們把它橫向擴容,多搞幾個,不就具備了並行的能力了嗎?

然後前面提到的“按某個維度”,如果有多個只有一個執行緒的執行緒池了,那我也可以按照這個維度去對映“維度”和“每個執行緒池”呀。

用程式來說就是這樣的:

標號為 ① 的地方就是搞了多個只有一個執行緒的執行緒池,目的是為了保證消費的順序性。

標號為 ② 的地方就是通過一個 map 對映人名和執行緒池之間的關係。這裡只是一個示意,比如我們還可以用使用者號取模的方式去定位對應的執行緒池,比如使用者號為奇數的用一個執行緒池,為偶數的用另外一個執行緒。

所以並不是“某個維度”裡面有多少個資料就要定義多少個只有一個執行緒的執行緒池,它們也是可以複用的,這個地方有個小彎要轉過來。

標號為 ③ 的地方就是根據名稱去 map 裡面去對應的執行緒池。

從輸出結果來看,也是沒有毛病的:

看到這裡有的朋友就要說:你這不是作弊嗎?

不是說好一個執行緒池嗎,你這都弄了多個了。

你要這個角度看問題的話,那就把路走窄了。

你要想著有一個大的執行緒池,裡面又放了很多個只有一個執行緒的執行緒池。

這樣格局就開啟了。

我上面的寫法是一個非常簡陋的 Demo,主要是引出這個方案的思路。

我要介紹的,就是基於這個思路搞出的一個開源專案。

是一位大公司的大佬寫的,我看了一下原始碼,拍案叫絕:寫的真他孃的好。

我先給你上一個使用案例和輸出結果:

從案例看起來,使用方式也是非常的簡單。

和 JDK 原生的用法的差異點就是我框起來的部分。

首先搞一個 KeyAffinityExecutor 的物件,來代替原生的執行緒池。

KeyAffinityExecutor 其中涉及到一個單詞,Affinity。

翻譯過來有類同的含義:

所以 KeyAffinityExecutor 翻譯過來就是 key 類同的執行緒池,當你明白它的功能和作用範圍後會覺得這個名字取的是針不戳。

接著是呼叫了 KeyAffinityExecutor 物件的 executeEx 方法,可以多傳入一個引數,這個引數就是區分某一類相同任務的維度,比如我這裡就給的是 name 欄位。

從使用案例上看來,可以說封裝的非常好,開箱即用。

KeyAffinityExecutor用法

先說說這個類的用法吧。

其對應的開源專案地址是這個:

https://github.com/PhantomThief/more-lambdas-java

如果你想把它用起來,得引入下面這個 maven 地址:

<dependency>
    <groupId>com.github.phantomthief</groupId>
    <artifactId>more-lambdas</artifactId>
    <version>0.1.55</version>
</dependency>

其核心程式碼是這個介面:

com.github.phantomthief.pool.KeyAffinityExecutor

這個介面裡面有大量的註釋,大家可以拉下來看一下。

我這裡主要給大家看一下介面上面,作者寫的註釋,他是這樣介紹自己的這個工具的。

這是一個按指定的 Key 親和順序消費的執行緒池。

KeyAffinityExecutor 是一個特殊的任務執行緒池。

它可以確保投遞進來的任務按 Key 相同的任務依照提交順序依次執行。在既要通過並行處理來提高吞吐量、又要保證一定範圍內的任務按照嚴格的先後順序來執行的場景下非常適用。

KeyAffinityExecutor 的內建實現方式,是將指定的 Key 對映到固定的單執行緒執行緒池上,它內部會維護多個(數量可配)這樣的單執行緒執行緒池,來保持一定的任務並行度。

需要注意的是,此介面定義的 KeyAffinityExecutor,並不要求 Key 相同的任務在相同的執行緒上執行,儘管實現類可以按照這種方式來實現,但它並非一個強制性的要求,因此在使用時也請不要依賴這樣的假定。

很多人問,這和自己使用一個執行緒池的陣列,並通過簡單取模的方式來實現有什麼區別?

事實上,大多數場景的確差異不大,但是當資料傾斜發生時,被雜湊到相同位置的資料可能會因為熱點傾斜資料被延誤。

本實現在併發度較低時(閾值可設定),會挑選最閒置的執行緒池投遞,盡最大可能隔離傾斜資料,減少對其它資料帶來的影響。

在作者的這段介紹裡面,簡單的說明了該專案的應用場景和內部原理,和我們前面分析的差不多。

除此之外,還有兩個需要特別注意的地方。

第一個地方是這裡:

作為區分的任務維度的物件,如果是自定義物件,那麼一定要重寫其 hashCode、equals,以確保可以起到標識作用。

這一處的提醒就和 HashMap 的 key 如果是物件的話,應該要重寫 hashCode、equals 方法的原因是一樣一樣的。

程式設計基礎,只提一下,不多贅述。

第二個地方得好好說一下,屬於他的核心思想。

他沒有采用簡單取模的方式,因為在簡單取模的場景上,資料是有可能發生傾斜的。

我個人是這樣理解作者的思路的。

首先說明一下取模的資料傾斜是咋回事,舉個簡單的例子:

上面的程式碼片段中,我加入了一個新角色“摸魚大師”。同時給物件新增了一個 id 欄位。

假設,我們對 id 欄位用 2 取餘:

那麼會出現的情況就是大師和富貴對應的 id 取餘結果都是 1,它們將同用一個執行緒池。

很明顯,由於大師的頻繁操作,導致“摸魚”變成了熱點資料,從而導致編號為 0 的連線池發了傾斜,進而影響到了富貴的正常工作。

而 KeyAffinityExecutor 的策略是什麼樣的呢?

它會挑選最閒置的執行緒池進行投遞。

怎麼理解呢?

還是上面的例子,如果我們構建這樣的執行緒池:

KeyAffinityExecutor executorService =
                KeyAffinityExecutor.newSerializingExecutor(3, 200, "MY-POOL-%d");

第一個引數 3,代表它會在這裡執行緒池裡面構建 3 個只有一個執行緒的執行緒池。

那麼當用它來提交任務的時候,由於維度是 id 維度,我們剛好三個 id,所以剛好把這個執行緒池佔滿:

這個時候是不存在資料傾斜的。

但是,如果我把前面構建執行緒池的引數從 3 變成 2 呢?

KeyAffinityExecutor executorService =
                KeyAffinityExecutor.newSerializingExecutor(2, 200, "MY-POOL-%d");

提交方式不變,裡面加上對 id 為 1 和 2 的任務延遲的邏輯,目的是觀察 id 為 3 的資料怎麼處理:

毋庸置疑,當提交執行大師的摸魚操作的時候執行緒池肯定不夠用了,怎麼辦?

這個時候,根據作者描述“會挑選最閒置的執行緒池投遞”。

我用這樣的資料來說明:

所以,當執行大師摸魚操作的時候,會去從僅有的兩個選項中選一個出來。

怎麼選?

誰的併發度低,就選誰。

由於有延遲時間在任務裡面,所以我們可以觀察到執行富貴的執行緒的併發度是 5,而執行旺財的執行緒的併發度是 6。

因此執行大師的摸魚操作的時候,會選擇併發度為 5 的執行緒進行處理。

這個場景下就出現了資料傾斜。但是傾斜的前提發生了變化,變成了當前已經沒有可用執行緒了。

所以,作者說“盡最大可能隔離傾斜資料”。

這兩個方案最大的差異就是對執行緒資源的利用程度,如果是單純的取模,那麼有可能出現發生資料傾斜的時候,還有可用執行緒。

如果是 KeyAffinityExecutor 的方式,它可以保證發生資料傾斜的時候,執行緒池裡面的執行緒一定是已經用完了。

然後,你再品一品這兩個方案之間的細微差異。

KeyAffinityExecutor原始碼

原始碼不算多,一共就這幾個類:

但是他的原始碼裡面絕大部分都是 lambdas 的寫法,基本上都是函數語言程式設計,如果你對這方面比較薄弱的話那麼看起來會比較吃力一點。

如果你想掌握其原始碼的話,我建議是把專案拉到本地,然後從他的測試用例入手:

https://github.com/PhantomThief/more-lambdas-java

我給大家彙報一下我看到的一些關鍵的地方,方便大家自己去看的時候梳理思路。

首先肯定是從它的構造方法入手,每一個入參的含義作者都標註的非常清楚了:

假設我們的建構函式是這樣的,含義是構建 3 個只有一個執行緒的執行緒池,每個執行緒池的佇列大小是 200:

KeyAffinityExecutor executorService =
                KeyAffinityExecutor.newSerializingExecutor(3, 200, "WHY-POOL-%d");

首先我們要找到構建“只有一個執行緒的執行緒池”的邏輯在哪。

就藏在建構函式裡面的這個方法:

com.github.phantomthief.pool.KeyAffinityExecutorUtils#executor(java.lang.String, int)

在這裡可以看到我們一直提到的“只有一個執行緒的執行緒池”,佇列的長度也可以指定:

該方法返回的是一個 Supplier 介面,等下就要用到。

接下來,我們要找到 “3” 這個數字是體現在哪兒的呢?

就藏在建構函式的 build 方法裡面,該方法最終會呼叫到這個方法來:

com.github.phantomthief.pool.impl.KeyAffinityImpl#KeyAffinityImpl

你到時候在這個地方打個斷點,然後 Debug 看一眼,就非常明確了:

關於框起來的這部分的幾個關鍵引數,我解釋一下:

首先是 count 引數,就是我們定義的 3。那麼 range(0,3),就是 0,1,2。

然後是 supplier,這玩意就是前面我們說的 executor 方法返回的 supplier 介面,可以看到裡面封裝的就是個執行緒池。

接著是裡面有一個非常關鍵的操作 :map(ValueRef::new)。

這個操作裡面的 ValueRef 物件,很關鍵:

com.github.phantomthief.pool.impl.KeyAffinityImpl.ValueRef

關鍵的地方就是這個物件裡面的 concurrency 變數。

還記得最前面說的“挑選最閒置的執行器(執行緒池)”這句話嗎?

怎麼判斷是否閒置?

靠的就是 concurrency 變數。

其對應的程式碼在這:

com.github.phantomthief.pool.impl.KeyAffinityImpl#select

能走到斷點的地方,說明當前這個 key 是之前沒有被對映過的,所以需要為其指定一個執行緒池。

而指定這個執行緒池的操作,就是迴圈這個 all 集合,集合裡面裝的就是 ValueRef 物件:

所以,comparingInt(ValueRef::concurrency) 方法就是在選當前所有的執行緒池,併發度最小的一個。

如果這個執行緒池從來沒有用過或者目前沒有任務在使用,那麼併發度必然是 0 ,所有會被選出來。

如果所有執行緒池正在被使用,就會選 concurrency 這個值最低的執行緒池。

我這裡只是給大家說一個大概的思路,如果要深入瞭解的話,自己去翻原始碼去。

如果你非常瞭解 lambdas 的用法的話,你會覺得寫的真的很優雅,看起來很舒服。

如果你不瞭解 lambdas 的話...

那你還不趕緊去學?

另外我還發現了兩個熟悉的東西。

朋友們,請看這是什麼:

這難道不就是執行緒池引數的動態調整嗎?

第二個是這樣的:

RabbitMQ 裡面的動態調整我也寫過啊,也是強調過這三處地方:

  • 增加 {@link #setCapacity(int)} 和 {@link #getCapacity()}
  • {@link #capacity} 判斷邊界從 == 改為 >=
  • 部分 signal() 訊號觸發改為 signalAll()

另外作者還提到了 RabbitMQ 的版本里面會有導致 NPE 的 BUG 的問題。

這個就沒細研究了,有興趣的可以去對比一下程式碼,就應該能知道問題出在哪裡。

說說 Dubbo

為什麼要說一下 Dubbo 呢?

因為我似乎在 Dubbo 裡面也發現了 KeyAffinityExecutor 的蹤跡。

為什麼說是似乎呢?

因為最終沒有被合併到程式碼庫裡面去。

其對應的連結是這裡:

https://github.com/apache/dubbo/pull/8975

這一次提交一共提交了這麼多檔案:

裡面是可以找到我們熟悉的東西:

其實思路都是一樣的,但是你會發現即使是思路一樣,但是兩個不同的人寫出來的程式碼結構還是很不一樣的。

Dubbo 這裡把程式碼的層次分的更加明顯一點,比如定義了一個抽象的 AbstractKeyAffinity 物件,然後在去實現了隨機和最小併發兩種方案。

在這些細節處上是有不同的。

但是這個程式碼的提供者最終沒有用這些程式碼,而是拿出了一個替代方案:

https://github.com/apache/dubbo/pull/8999

在這一次提交裡面,他主要提交了這個類:

org.apache.dubbo.common.threadpool.serial.SerializingExecutor

這個類從名字上你就知道了,它強調的是序列化。

帶大家看看它的測試用例,你就知道它是怎麼用的了:

首先是它的構造方法入參是另外一個執行緒池。

然後提交任務的時候用 SerializingExecutor 的 execute 方法進行提交。

在任務內部,乾的事就是從 map 裡面取出 val 對應的 key ,然後進行加 1 操作再放回去。

大家都知道上面的這個操作在多執行緒的情況是執行緒不安全的,最終加出來的結果一定是小於迴圈次數的。

但是,如果是單執行緒的情況下,那肯定是沒問題的。

那麼怎麼把執行緒池對映為單執行緒呢?

SerializingExecutor 幹得就是這事。

而且它的原理特別簡單,核心程式碼就幾行。

首先它自己搞了個佇列:

提交進來的任務都扔到佇列裡面去。

接下來再一個個的執行。

怎麼保證一個個的執行呢?

方法有很多,它這裡是搞了個 AtomicBoolean 物件來控制:

這樣就實現了把多執行緒任務搞成序列化的場景。

只是讓我奇怪的是 SerializingExecutor 這個類目前在 Dubbo 裡面並沒有使用場景。

但是,如果你時候你就要實現這樣奇怪的功能,比如別人給你一個執行緒池,但是到你的流程裡面出入某種考慮,需要把任務序列化,這個時候肯定是不能動別人的執行緒池的,那麼你可以想起 Dubbo 這裡有一個現成的,比較優雅的、逼格較高的解決方案。

最後說一句

好了,看到了這裡了, 轉發、在看、點贊隨便安排一個吧,要是你都安排上我也不介意。寫文章很累的,需要一點正反饋。

給各位讀者朋友們磕一個了:

本文已收錄至個人部落格,歡迎大家來玩。

https://www.whywhy.vip/

相關文章