一、前言
執行緒池是開發中繞不開的一個知識點。
對於移動開發而言,網路框架、圖片載入、AsyncTask、RxJava, 都和執行緒池有關。
正因為執行緒池應用如此廣泛,所以也成了面試的高頻考點。
我們今天就來講講執行緒池的基本原理和周邊知識。
先從執行緒的生命週期開始。
二、執行緒生命週期
執行緒是程式執行流的最小單元。
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, 執行拒絕策略。
很多人在講執行緒池的時候,乾脆把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當前執行緒。
此時,allowCoreThreadTimeOut為true, 或者 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 滿負荷,但是花線上程切換的時間增加,用於執行任務的時間反而減少。
一些文章提到如下估算公式:
M:併發數; C:任務佔用CPU的時間; I:等待IO完成的時間(為簡化討論,且只考慮IO); N:CPU核心數。
代入特定引數驗證這條公式:
1、比方說 I 接近於0,則M≈N,一個執行緒對應一個CPU,剛好滿負荷且較少執行緒切換;
2、假如 I=U,則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執行緒池做太過深入的探討,而是從使用的角度講述基本原理和周邊知識;
第二節有結合關鍵程式碼作簡要分析,也是點到為止,目的在於加深對執行緒池相關引數的理解,
以便在平時使用執行緒池的時候合理斟酌,在閱讀涉及執行緒池的開原始碼時也能“知其所以然”。