Java 線程池是如何誕生的?

時間回到 2003 年,那時我還是一個名不見經傳的程序員,但是上級卻非常看好我,他們把整個併發模塊,都交給了我一個人開發。(難道不是因爲經費不足?)

這個星期,我必須要完成併發模塊中非常重要的一個功能 —— 線程池。

爲什麼要使用線程池

作爲一個合格的程序員,接到需求,首先我得問自己一句:
爲什麼要做這個需求?爲什麼需要線程池?

軟件中的 “池”,可以理解爲計劃經濟時代的工廠。

首先,作爲工廠,你要管理好你生產的東西,老王從你工廠這裏拿走了一把斧頭,改天他不需要了,還回來,你可以把這把斧頭借給老趙;

其次,你又不能無限制的生產,畢竟在資源極度匱乏的時代,如果都被你拿去生產了,其他要用到資源的地方怎麼辦?

總結成兩點,“池” 的作用:

  • 複用已有資源
  • 控制資源總量

數據庫連接池是這樣,線程池也是如此。

你一個任務過來了,我發現池子裏有沒事幹並且還活着的線程,來,拿去用,我也不用費事給你創建一條線程了,要知道線程的創建和銷燬可都是麻煩事;
你一個任務過來了,我發現池子的線程都在忙,並且現在池子的線程已經太多了,再不限制下去就要內存溢出了,來,排隊去~

線程池需要考慮哪些問題

簡單的架構固然容易實現,但是卻不能解決複雜的問題;
而複雜的架構可以解決複雜的問題,卻沒那麼好實現。

在介紹線程池原理之前,先來大致看看我設計的線程池 ThreadPoolExecutor 長什麼樣子:
在這裏插入圖片描述

你們可以先看看這張圖,想想圖中的各個節點都是什麼,爲什麼需要它們?

好,現在開始聊聊實現一個線程池,都需要考慮哪些問題。

1、 任務隊列
如果每個任務過來,都直接交給線程去執行,那其實算不上解耦。

更好的方法是先把任務放到隊列裏面,然後當線程空閒的時候,去隊列裏面取任務過來處理。
爲了取的時候可以形成阻塞,我選擇了使用阻塞隊列 BlockingQueue,來保存這些未被處理的任務。

如果你們用過 RabbitMQ、Kafka 之類的消息中間件,就會發現他們的原理和阻塞隊列類似。

2、任務隊列的類型
阻塞隊列有很多種:

  • 無界的阻塞隊列(Unbounded queues),比如 LinkedBlockingQueue,來多少任務就放多少;
  • 有界的阻塞隊列(Bounded queues),比如 ArrayBlockingQueue
  • 同步移交(Direct handoffs),比如 SynchronousQueue,這個隊列的 put 方法會阻塞,直到有線程準備從隊列裏面 take,所以本質上 SynchronousQueue 並不是 Queue, 它不存儲任何東西,它只是在移交東西

這麼多種隊列,都有各自的優劣,所以,把任務隊列參數,放在構造函數裏頭,提供給使用線程池的人去設置,是最好不過的了。

3、線程的數量
我定義了兩個線程數的變量,一個是核心線程數 corePoolSize,另一個是最大線程數 maximumPoolSize。這兩個參數的差別,可以這樣來解釋:

  • 當線程池裏的線程數少於 corePoolSize 時,每來一個任務,我就創建一條線程去處理,不管線程池中有沒有空閒的線程;
  • 當線程池裏的線程數達到 corePoolSize 時,新來的任務,會先放到任務隊列裏面;
  • 當任務隊列放滿了(如果隊列是有界隊列),那麼要怎麼辦?馬上拒絕新的任務嗎?似乎不妥,面對這種業務突然繁忙的情況,我是不是可以破例再創建多幾條線程呢?於是就有了 maximumPoolSize,如果任務隊列滿了,但是線程池中的線程數還少於 maximumPoolSize,那我就允許線程池繼續創建線程,這就像腸粉店裏的桌子,一開始擺上十張,到了中午高峯期時,發現不夠用了,老闆娘再讓小二從廚房裏拿出幾張桌子出來一樣。

同樣的,這兩個參數也應該放在構造函數,由使用者根據實際情況,來決定要使用多大容量的線程池。

4、Keep-alive times
從廚房拿出來的桌子,在高峯期過後,就要漸漸撤回了吧?同樣,當我發現線程池中線程的數量超過 corePoolSize,就會去監控線程,發現某條線程很久沒有工作了,就把它關掉,這裏的很久是多久,那就要看你傳過來的 keepAliveTime 是多少了。
如果你想對 corePoolSize 線程也做這種監控,只需要調用 threadPoolExecutor.allowCoreThreadTimeOut (true) 就可以了。

你也許好奇我是怎樣判斷線程有多久沒有活動了,是不是以爲我會啓動一個監控線程,專門監控哪個線程正在偷懶?
想太多,其實我只是在線程從工作隊列 poll 任務時,加上了超時限制,如果線程在 keepAliveTime 的時間內 poll 不到任務,那我就認爲這條線程沒事做,可以幹掉了,看看這個代碼片段你就清楚了,

ThreadPoolExecutor getTask():

    private Runnable getTask() {
        boolean timedOut = false; 

        for (;;) {
            
            ...
            
            try {
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }

5、拒絕策略
如果線程池已經被 shutdown 了,或者線程池中使用的是有界隊列,而這個隊列已經滿了,並且線程數已經達到最大線程數,無法再創建新的線程處理請求,這時候要怎麼處理新來的任務?
在和大家一起討論之後,我們認爲至少有這四種策略:

  • AbortPolicy:使用這種策略的線程池,將在無法繼續接受新任務時,給任務提交方拋出 RejectedExecutionException,讓他們決定要如何處理;
  • CallerRunsPolicy:這個策略,顧名思義,將把任務交給調用方所在的線程去執行;
  • DiscardPolicy:直接丟棄掉新來的任務;
  • DiscardOldestPolicy:丟棄最舊的一條任務,其實就是丟失 blockingQueue.poll () 返回的那條任務,要注意,如果你使用的是 PriorityBlockingQueue 優先級隊列作爲你的任務隊列,那麼這個策略將會丟棄優先級最高的任務,所以一般情況下,PriorityBlockingQueue 和 DiscardOldestPolicy 不會同時使用

說到策略,你們或許以爲我會用策略模式。
這下你們猜對了,我用的就是策略模式,這個模式是如此簡單,以至於我只需要定義一個策略接口,
RejectedExecutionHandler:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

然後寫對應的實現類,實現上面提到的那四種策略,比如 DiscardPolicy,直接丟棄,那就是什麼都不做唄,
DiscardPolicy:

    public static class DiscardPolicy implements RejectedExecutionHandler     {
        public DiscardPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        }
    }

接着在構造函數裏,讓你們給我傳入你們想要使用的策略,最後在我的拒絕任務 reject () 方法裏,調用你們傳過來的策略就 ok 了,

    final void reject(Runnable command) {
        handler.rejectedExecution(command, this);
    }

爲什麼使用 final?那當然是不想讓你們繼承啦,這個方法木有定製的必要嘛。
至於這個 reject 方法,是在哪裏調用的,你們使用 idea,alt + f7 就知道了,然後你們會看到我寫的很多深奧的代碼,那些我今天不就不詳細講解了,今天重點講解構造函數裏的幾個參數的作用,也就是你們可以定製的幾個參數。

給你們造好的輪子

爲了方便你們使用,我已經在 Executors 裏面寫了幾個線程池的工廠方法,這樣,很多新手就不需要了解太多關於 ThreadPoolExecutor 的知識了,他們只需要直接使用我的工廠方法,就可以使用線程池

1、newFixedThreadPool
如果你想對線程池裏的線程總數做一個限制,那麼通過 Executors.newFixedThreadPool (…) 獲取一個固定線程數的線程池,是一個很不錯的選擇,它將返回一個 corePoolSize 和 maximumPoolSize 相等的線程池,
Executors newFixedThreadPool:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

2、newCachedThreadPool
如果你希望有一個非常彈性的線程池,那可以使用 newCachedThreadPool:

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

從上面的工廠方法,可以看出,CachedThreadPool 是一個這樣配置的 ThreadPoolExecutor:

  • corePoolSize:0
  • maxPoolSize:Integer.MAX_VALUE
  • keepAliveTime:60s
  • workQueue: SynchronousQueue

就像不同 CPU、顯卡的組合的電腦有不同的用途一樣(數據分析、打遊戲、視頻處理等),不同配置的 ThreadPoolExecutor 也會產生不同的威力,CachedThreadPool 的這些配置產生的威力在於:

  • **對於新的任務,如果此時線程池裏沒有空閒線程,線程池會毫不猶豫的創建一條新的線程去處理這個任務。**因爲 corePoolSize 是 0,當前線程數肯定大於等於 corePoolSize,而 workQueue 是 SynchronousQueue,前面說了,SynchronousQueue 是不存放東西的,它只移交,所以你可以認爲它的隊列一直是滿的,最後,maxPoolSize 是無窮大,再繼續創建也不會達到最大線程數,所以線程池會創建一條新的線程去處理這個任務;
  • keepAliveTime 是 60s,你可以認爲這就是線程的失效時間。新創建的線程如果 60s 內都沒有任務要執行(緩存沒有命中),那麼就會被銷燬,而如果在這 60s 內,線程分配到任務了(緩存命中),那麼就可以直接拿這條創建好的線程過去用;
  • corePoolSize 設置成 0 還有一個好處,那就是當有一大段時間,線程池都沒有接收到新的任務時,線程池裏的線程會逐漸被銷燬,直到線程池中線程數量降爲 0,這樣整個線程池也就不會佔用什麼資源了,這個特性,使得 CachedThreadPool 特別適合處理具有週期性的,並且執行時間短(short-lived)的任務,比如晚上十二點時,會有一波業務過來處理,其他時間段,業務很少甚至沒有,這種情況就很適合使用 CachedThreadPool

當然,CachedThreadPool 會有一個很明顯的隱患,那就是線程數量不可控,當然,你已經弄懂了 ThreadPoolExecutor 幾個重要參數,你完全可以自己定製一個有線程數量上限的 CachedThreadPool,或者在創建完 CachedThreadPool 後,使用 setMaximumPoolSize 方法修改最大線程數量。

3、newSingleThreadExecutor
觸類旁通,很容易理解,這裏就不貼源碼和解釋了。

4、 newScheduledThreadPool
觸類旁通,理解起來有些許難度,這裏就不貼源碼和解釋了。

總結

本文圍繞 ThreadPoolExecutor 的構造函數,重點講解了 ThreadPoolExecutor 中,幾個可以給外部定製的參數的意義和實現原理,希望能對你理解線程池並定製自己的線程池有所幫助。當然,線程池內部還有很多複雜的機制,比如各種狀態的管理等等,不過這些都不是外部可以定製的了,後面我們再來討論。

後記

時鐘來到了 24 點,我跑完了所有 ThreadPoolExecutor 的測試用例,綠條,全部通過。
正準備提交代碼,回家睡覺,突然發現還沒給這個類寫上自己的大名,於是,啪啪啪,我在類的頭上,留下了我的名字……

在這裏插入圖片描述

參考

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