圖解 | 原來這就是執行緒池

閃客sun發表於2021-02-03

小宇:閃客,我最近看到執行緒池,被裡邊亂七八槽的引數給搞暈了,你能不能給我講講呀?

圖解 | 原來這就是執行緒池

閃客:沒問題,這個我擅長,我們們從一個最簡單的情況開始,假設有一段程式碼,你希望非同步執行它,是不是要寫出這樣的程式碼?

new Thread(r).start();

  小宇:嗯嗯,最簡單的寫法似乎就是這樣呢。

閃客:這種寫法當然可以完成功能,可是你這樣寫,老王這樣寫,老張也這樣寫,程式中到處都是這樣建立執行緒的方法,能不能寫一個統一的工具類讓大家呼叫呢?

小宇:可以的,感覺有一個統一的工具類,更優雅一些。

閃客:那如果讓你來設計這個工具類,你會怎麼寫呢?我先給你定一個介面,你來實現。

public interface Executor {
    public void execute(Runnable r);
}

小宇:emmm,我可能先定義幾個成員變數,比如核心執行緒數、最大執行緒數 ...反正就是那些亂七八糟的引數。

閃客:STOP!小宇呀,你現在深受面試手冊的毒害,你先把這些全部的概念忘掉,就說讓你寫一個最簡單的工具類,第一反應,你會怎麼寫?

 

第一版

小宇:那我可能會這樣

// 新執行緒:直接建立一個新執行緒執行
class FlashExecutor implements Executor {
    public void execute(Runnable r) {
        new Thread(r).start();
    }
}

  閃客:嗯嗯很好,你的思路非常棒。

小宇:啊,我這個會不會太 low 了呀,我還以為你會罵我呢。

怎麼會, Doug Lea 大神在 JDK 原始碼註釋中給出的就是這樣的例子,這是最根本的功能。你在這個基礎上,嘗試著優化一下?

 

第二版

小宇:還能怎麼優化呢?這不已經用一個工具類實現了非同步執行了嘛!

閃客:我問你一個問題,假如有 10000 個人都呼叫這個工具類提交任務,那就會建立 10000 個執行緒來執行,這肯定不合適吧!能不能控制一下執行緒的數量呢?

小宇:這不難,我可以把這個任務 r 丟到一個 tasks 佇列中,然後只啟動一個執行緒,就叫它 Worker 執行緒吧,不斷從 tasks 佇列中取任務,執行任務。這樣無論呼叫者呼叫多少次,永遠就只有一個 Worker 執行緒在執行,像這樣。

圖解 | 原來這就是執行緒池

閃客:太棒了,這個設計有了三個重大的意義:

1. 控制了執行緒數量。

2. 佇列不但起到了緩衝的作用,還將任務的提交與執行解耦了。

3. 最重要的一點是,解決了每次重複建立和銷燬執行緒帶來的系統開銷。

小宇:哇真的麼,這麼小的改動有這麼多意義呀。

閃客:那當然,不過只有一個後臺的工作執行緒 Worker 會不會少了點?還有如果這個 tasks 佇列滿了怎麼辦呢?

 

第三版

小宇:哦,的確,只有一個執行緒在某些場景下是很吃力的,那我把 Worker 執行緒的數量增加?

閃客:沒錯,Worker 執行緒的數量要增加,但是具體數量要讓使用者決定,呼叫時傳入,就叫核心執行緒數 corePoolSize 吧。

小宇:好的,那我這樣設計。

1. 初始化執行緒池時,直接啟動 corePoolSize 個工作執行緒 Worker 先跑著。

2. 這些 Worker 就是死迴圈從佇列裡取任務然後執行。

3. execute 方法仍然是直接把任務放到佇列,但佇列滿了之後直接拋棄

圖解 | 原來這就是執行緒池

閃客:太完美了,獎勵你一塊費列羅吧。

小宇:哈哈謝謝,那我先吃一會兒哈。 

閃客:好,你邊吃我邊說。現在我們已經實現了一個至少不那麼醜陋的執行緒池了,但還有幾處小瑕疵,比如初始化的時候,就建立了一堆 Worker 執行緒在那空跑著,假如此時並沒有非同步任務提交過來執行,這就有點浪費了。

小宇:哦好像是誒!

閃客:還有,你這佇列一滿,就直接把新任務丟棄了,這樣有些粗暴,能不能讓呼叫者自己決定該怎麼處理呢?

小宇:哎呀,想不到我這麼溫柔的妹紙居然寫出了這麼粗暴的程式碼。

閃客:額,你先把費列羅嚥下去吧。

 

第四版

小宇:我吃完了,現在腦子有點不夠用了,得先消化消化食物,要不你幫我分析分析吧。

閃客:好的,現在我們做出如下改進。

1. 按需建立Worker:剛初始化執行緒池時,不再立刻建立 corePoolSize 個工作執行緒,而是等待呼叫者不斷提交任務的過程中,逐漸把工作執行緒 Worker 建立出來,等數量達到 corePoolSize 時就停止,把任務直接丟到佇列裡。那就必然要用一個屬性記錄已經建立出來的工作執行緒數量,就叫 workCount 吧。

2. 加拒絕策略:實現上就是增加一個入參,型別是一個介面 RejectedExecutionHandler,由呼叫者決定實現類,以便在任務提交失敗後執行 rejectedExecution 方法。

3. 增加執行緒工廠:實現上就是增加一個入參,型別是一個介面 ThreadFactory,增加工作執行緒時不再直接 new 執行緒,而是呼叫這個由呼叫者傳入的 ThreadFactory 實現類的 newThread 方法。

就像下面這樣。

圖解 | 原來這就是執行緒池

小宇:哇,還是你厲害,這一版應該很完美了吧? 閃客:不不不,離完美還差得很遠,接下來的改進,由你來想吧,我這裡可以給你一個提示

彈性思維

圖解 | 原來這就是執行緒池


第五版

小宇:彈性思維?哈哈閃客你這術語說的真是越來越不像人話了

閃客:咳咳

小宇:哦,我是說你肯定是指我這個程式碼寫的沒有彈性,對吧?可是彈性是指什麼呢?

閃客:簡單說,在這個場景裡,彈性就是在任務提交比較頻繁,和任務提交非常不頻繁這兩種情況下,你這個程式碼是否有問題?

小宇:emmm 讓我想想,我這個執行緒池,當提交任務的量突增時,工作執行緒和佇列都被佔滿了,就只能走拒絕策略,其實就是被丟棄掉

圖解 | 原來這就是執行緒池

閃客:是的

小宇:這樣的確是太硬了,誒不過我想了下,呼叫方可以通過設定很大的核心執行緒數 corePoolSize 來解決這個問題呀。

圖解 | 原來這就是執行緒池

閃客:的確是可以,但一般場景下 QPS 高峰期都很短,而為了這個很短暫的高峰,設定很大的核心執行緒數,簡直太浪費資源了,你看上面的圖不覺得眼暈麼?

小宇:是呀,那怎麼辦呢,太大了也不行,太小了也不行。

閃客:我們可以發明一個新的屬性,叫最大執行緒數 maximumPoolSize 。當核心執行緒數和佇列都滿了時,新提交的任務仍然可以通過建立新的工作執行緒(叫它 非核心執行緒 ),直到工作執行緒數達到 maximumPoolSize 為止,這樣就可以緩解一時的高峰期了,而使用者也不用設定過大的核心執行緒數。

圖解 | 原來這就是執行緒池

小宇:哦好像有點感覺了,可是具體怎麼操作呢? 閃客:想象力不行呀小宇,那你看下面的演示。

圖解 | 原來這就是執行緒池

1. 開始的時候和上一版一樣,當 workCount < corePoolSize 時,通過建立新的 Worker 來執行任務。

2. 當 workCount >= corePoolSize 就停止建立新執行緒,把任務直接丟到佇列裡。

3. 但當佇列已滿且仍然 workCount < maximumPoolSize 時,不再直接走拒絕策略,而是建立非核心執行緒,直到 workCount = maximumPoolSize,再走拒絕策略。

小宇:哎呀,我怎麼沒想到,這樣 corePoolSize 就負責平時大多數情況所需要的工作執行緒數,而 maximumPoolSize 就負責在高峰期臨時擴充工作執行緒數。

閃客:沒錯,高峰時期的彈性搞定了,那自然就還要考慮低谷時期。當長時間沒有任務提交時,核心執行緒與非核心執行緒都一直空跑著,浪費資源。我們可以給非核心執行緒設定一個超時時間 keepAliveTime ,當這麼長時間沒能從佇列裡獲取任務時,就不再等了,銷燬執行緒。

圖解 | 原來這就是執行緒池

小宇:嗯,這回我們們的執行緒池在 QPS 高峰時可以臨時擴容,QPS 低谷時又可以及時回收執行緒(非核心執行緒)而不至於浪費資源,真的顯得十分 Q 彈呢。

圖解 | 原來這就是執行緒池

閃客:是呀是呀。誒不對,怎麼又變成我說了,不是說這一版你來思考麼?

小宇:我也想啊,但你這一講技術就自說自話的毛病老是不改,我有啥辦法。 閃客:額抱歉抱歉,那接下來你總結一下我們的執行緒池吧

 

總結

小宇:嗯好的,首先它的構造方法是這個樣子滴

 

public FlashExecutor(
        int corePoolSize,
        int maximumPoolSize,
        long keepAliveTime,
        TimeUnit unit,
        BlockingQueue<Runnable> workQueue,
        ThreadFactory threadFactory,
        RejectedExecutionHandler handler) 
{
    ... // 省略一些引數校驗
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

 

這些引數分別是

int corePoolSize:核心執行緒數

int maximumPoolSize:最大執行緒數

long keepAliveTime:非核心執行緒的空閒時間

TimeUnit unit:空閒時間的單位

BlockingQueue<Runnable> workQueue:任務佇列(執行緒安全的阻塞佇列)

ThreadFactory threadFactory:執行緒工廠

RejectedExecutionHandler handler:拒絕策略

整個任務的提交流程是

圖解 | 原來這就是執行緒池

閃客:不錯不錯,這可是你自己總結的喲,現在還用我給你講什麼是執行緒池了麼?

小宇:啊天呢,我才發現這似乎就是我一直弄不清楚的執行緒池的引數和原理呢!

閃客:沒錯,而且最後一版程式碼的構造方法,就是 Java 面試常考的  ThreadPoolExecutor 最長的那個構造方法,引數名都沒變。

小宇:哇,太讚了!我都忘了一開始我想幹嘛了,嘻嘻。

閃客:哈哈,不知不覺學到了技術才爽呢,對吧?晚飯時間快到了,要不要一塊去吃山西面館呀?

小宇:哦,那家店餐桌的顏色我不太喜歡,下次吧。

閃客:哦好吧。

後記


執行緒池是面試常考的知識點,網上很多文章都是直接從它那有 7 個引數的構造方法講起,強行把各個引數的含義說給你聽,讓人云裡霧裡。 希望讀者讀完本篇文章後,執行緒池的這些引數不再是死記硬背,而是像本文中這些動圖一樣在你的腦中活靈活現,這樣就能永遠記住他們啦~ 本文中各個版本都有對應程式碼,在公眾號 低併發程式設計 後臺回覆 執行緒池 即可獲取。

相關文章