速讀Java執行緒池

Horizon757發表於2019-04-11

一、前言

執行緒池是開發中繞不開的一個知識點。
對於移動開發而言,網路框架、圖片載入、AsyncTask、RxJava, 都和執行緒池有關。
正因為執行緒池應用如此廣泛,所以也成了面試的高頻考點。

我們今天就來講講執行緒池的基本原理和周邊知識。
先從執行緒的生命週期開始。

二、執行緒生命週期

執行緒是程式執行流的最小單元。
Java執行緒可分為五個階段:

速讀Java執行緒池

  • 新建(New): 建立Thread物件,並且未呼叫start();
  • 就緒(Runnable): 呼叫start()之後, 等待作業系統排程;
  • 執行(Running): 獲取CPU時間分片,執行 run()方法中的程式碼;
  • 阻塞(Blocked): 執行緒讓出CPU,進入等待(就緒);
  • 終止(Terminated): 自然退出或者被終止。

執行緒的建立和銷燬代價較高,當有大量的任務時,可複用執行緒,以提高執行任務的時間佔比。
如上圖,不斷地 Runnable->Runing->Blocked->Runnable, 就可避免過多的執行緒建立和銷燬。
此外,執行緒的上下文切換也是開銷比較大的,若要使用執行緒池,需注意設定合理的引數,控制執行緒併發。

三、ThreadPoolExecutor

JDK提供了一個很好用的執行緒池的封裝:ThreadPoolExecutor

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
複製程式碼

corePoolSize:核心執行緒大小
maximumPoolSize:執行緒池最大容量(需大於等於corePoolSize,否則會拋異常)
keepAliveTime:執行緒執行任務結束之後的存活時間
unit:時間單位
workQueue:任務佇列
threadFactory:執行緒工廠
handler:拒絕策略

執行緒池中有兩個任務容器:

private final HashSet<Worker> workers = new HashSet<Worker>();
private final BlockingQueue<Runnable> workQueue;
複製程式碼

前者用於儲存Worker,後者用於緩衝任務(Runnable)。
下面是execute方法的簡要程式碼:

    public void execute(Runnable command) {
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
        }
        // 若workQueue已滿,offer會返回false
        if (isRunning(c) && workQueue.offer(command)) {
            // ...
        } else if (!addWorker(command, false))
            reject(command);
    }

    private boolean addWorker(Runnable firstTask, boolean core) {
        int wc = workerCountOf(c);
        if (wc >= (core ? corePoolSize : maximumPoolSize))
                    return false;
        Worker w = new Worker(firstTask);
        final Thread t = w.thread;
        workers.add(w);
        t.start();
    }
複製程式碼

一個任務到來,假設此時容器workers中Worker數的數量為c,則

  • 1、當c < corePoolSize時,建立Worker來執行這個任務,並放入workers
  • c >= corePoolSize時,
    • 2、若workQueue未滿,則將任務放入workQueue
    • workQueue已滿,
      • 3、若c < maximumPoolSize,建立Worker來執行這個任務,並放入workers
      • 4、若c >= maximumPoolSize, 執行拒絕策略。

速讀Java執行緒池

很多人在講執行緒池的時候,乾脆把workers說成“執行緒池”,將Worker和執行緒混為一談;
不過這也無妨,能幫助理解就好,就像看到一杯水,說“這是水”一樣,很少人會說這是“杯子裝著水”。

Worker和執行緒,好比汽車和引擎:汽車裝著引擎,汽車行駛,其實是引擎在做功。
Worker本身實現了Runnable,然後有一個Thread和Runnable的成員;
建構函式中,將自身(this)委託給自己的成員thread
thread.start(), Worker的run()函式被回撥,從而開啟 “執行任務-獲取任務”的輪迴。

    private final class Worker implements Runnable{
        final Thread thread;
        Runnable firstTask;
        Worker(Runnable firstTask) {
            this.firstTask = firstTask;
            this.thread = getThreadFactory().newThread(this);
        }
        public void run() {
            runWorker(this);
        }
    }

    final void runWorker(Worker w) {
        Runnable task = w.firstTask;
        while (task != null || (task = getTask()) != null) {
            task.run();
        }
    }

    private Runnable getTask() {
        for (;;) {
            boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
            Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
            if (r != null)
                return r;
        }
    }
複製程式碼

當執行緒執行完任務(task.run()結束),會嘗試去workQueue取下一個任務,
如果workQueue已經清空,則執行緒進入阻塞態:workQueue是阻塞佇列,如果取不到元素會block當前執行緒。
此時,allowCoreThreadTimeOuttrue, 或者 n > corePoolSize,workQueue等待keepAliveTime的時間,
如果時間到了還沒有任務進來, 則退出迴圈, 執行緒銷燬;
否則,一直等待,直到新的任務到來(或者執行緒池關閉)。
這就是執行緒池可以保留corePoolSize個執行緒存活的原理。

從執行緒的角度,要麼執行任務,要麼阻塞等待,或者銷燬;
從任務的角度,要麼馬上被執行,要麼進入佇列等待被執行,或者被拒絕執行。
上圖第2步,任務進入workQueue, 如果佇列為空且有空閒的Worker的話,可馬上得到執行。

關於workQueue,常用的有兩個佇列:

  • LinkedBlockingQueue(capacity):
    傳入capacity(大於0), 則LinkedBlockingQueue的容量為capacity;
    如果不傳,預設為Integer.MAX_VALUE,相當於無限容量(不考慮記憶體因素),多少元素都裝不滿。
  • SynchronousQueue 除非另一個執行緒試圖移除獲取元素,否則不能新增元素。

四、 ExecutorService

為了方便使用,JDK還封裝了一些常用的ExecutorService:

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

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

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

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
}
複製程式碼
型別 最大併發 適用場景
newFixedThreadPool nThreads 計算密集型任務
newSingleThreadExecutor 1 序列執行的任務
newCachedThreadPool Integer.MAX_VALUE IO密集型任務
newScheduledThreadPool Integer.MAX_VALUE 定時任務,週期任務

newSingleThreadExecutor 其實是 newFixedThreadPool的特例 (nThreads=1),
寫日誌等任務,比較適合序列執行,一者不會佔用太多資源,二者為保證日誌有序與完整,同一時間一個執行緒寫入即可。

眾多方法中,newCachedThreadPool() 是比較特別的,
1、corePoolSize = 0,
2、maximumPoolSize = Integer.MAX_VALUE,
3、workQueue 為 SynchronousQueue。

結合上一節的分析:
當一個任務提交過來,由於corePoolSize = 0,任務會嘗試放入workQueue;
如果沒有執行緒在嘗試從workQueue獲取任務,offer()會返回false,然後會建立執行緒執行任務;
如果有空閒執行緒在等待任務,任務可以放進workQueue,但是放進去後馬上就被等待任務的執行緒取走執行了。
總的來說,就是有空閒執行緒則交給空閒執行緒執行,沒有則建立執行緒執行;
SynchronousQueue型別workQueue並不儲存任務,只是一個傳遞者。
所以,最終效果為:所有任務立即排程,無容量限制,無併發限制。

這樣的特點比較適合網路請求任務。
OkHttp的非同步請求所用執行緒池與此類似(除了ThreadFactory ,其他引數一模一樣)。

  public synchronized ExecutorService executorService() {
    if (executorService == null) {
      executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
          new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));
    }
    return executorService;
  }
複製程式碼

五、 任務併發的估算

一臺裝置上,給定一批任務,要想最快時間完成所有任務,併發量應該如何控制?
併發量太小,CPU利用率不高;
併發量太大,CPU 滿負荷,但是花線上程切換的時間增加,用於執行任務的時間反而減少。

一些文章提到如下估算公式:

速讀Java執行緒池

M:併發數;
C:任務佔用CPU的時間;
I:等待IO完成的時間(為簡化討論,且只考慮IO);
N:CPU核心數。

代入特定引數驗證這條公式:
1、比方說 I 接近於0,則M≈N,一個執行緒對應一個CPU,剛好滿負荷且較少執行緒切換;
2、假如 I=C,則M = 2N,兩個執行緒對應一個CPU,每個執行緒一半時間在等待IO,一半時間在計算,也是剛好。

遺憾的是,對於APP而言這條公式並不適用:

  • 任務佔用CPU時間和IO時間無法估算
    APP上的非同步任務通常是碎片化的,而不同的任務性質不一樣,有的計算耗時多,有的IO耗時多;
    然後同樣是IO任務,比方說網路請求,IO時間也是不可估計的(受伺服器和網速影響)。
  • 可用CPU核心可能會變化
    有的裝置可能會考慮省電或者熱量控制而關閉一些核心;
    大家經常吐槽的“一核有難,九核圍觀”對映的就是這種現象。

雖然該公式不能直接套用來求解最大併發,但仍有一些指導意義:
IO等待時間較多,則需要高的併發,來達到高的吞吐率;
CPU計算部分較多,則需要降低併發,來提高CPU的利用率。

換言之,就是:
計算密集型任務時控制併發小一點;
IO密集型任務時控制併發大一點。

問題來了,小一點是多小,大一點又是多大呢?
說實話這個只能憑經驗了,跟“多吃水果”,“加鹽少許”一樣,看實際情況而定。

比如RxJava就提供了Schedulers.computation()Schedulers.io()
前者預設情況下為最大併發為CPU核心數,後者最大併發為Integer.MAX_VALUE(相當於不限制併發)。
可能是作者也不知道多少才合適,所以乾脆就不限制了。
這樣其實很危險的,JVM對程式有最大執行緒數限制,超過則會拋OutOfMemoryError

六、總結

回顧文章的內容,大概有這些點:

  • 介紹了執行緒的生命週期;
  • 從執行緒池的引數入手,分析這些引數是如何影響執行緒池的運作;
  • 列舉常用的ExecutorService,介紹其各自特點和適用場景;
  • 對併發估算的一些理解。

文章沒有對Java執行緒池做太過深入的探討,而是從使用的角度講述基本原理和周邊知識;
第二節有結合關鍵程式碼作簡要分析,也是點到為止,目的在於加深對執行緒池相關引數的理解,
以便在平時使用執行緒池的時候合理斟酌,在閱讀涉及執行緒池的開原始碼時也能“知其所以然”。

相關文章