執行緒池原理初探

木瓜芒果發表於2019-05-13

  在Java中,我們如果想同時做多件事情,則需要將不同事情以任務的形式抽象出來(即實現了Runnable介面的類),將不同的任務交給執行緒來驅動,以完成同時執行多件事情的效果。建立任務很容易,new一個類就可以了,但是要跑起來還需要執行緒啊,執行緒可是稀缺資源啊,怎麼獲取呢?

  前面在Java執行緒機制一文中我們簡單介紹了執行緒建立的幾種方法,但這只是作為學習使用的,在生產環境中一般是不會直接通過新建執行緒來獲取執行緒資源的。因為Java中的執行緒是和作業系統底層的執行緒掛鉤的,建立執行緒是一個很消耗時間和資源的事情,如果頻繁建立和銷燬執行緒就可能會導致資源耗盡;而且如果建立了大量執行緒,也會導致執行緒之間的頻繁切換,這也是很耗時間的操作。因此,JDK中提供了執行緒池來幫助我們獲取和管理執行緒資源。

  有了執行緒池,我們無需直接建立執行緒,只需將需要執行的任務交給執行緒池就好了,執行緒池會幫我們分配執行緒來執行任務。

  使用執行緒池,有如下好處:

  • 執行緒池幫我們管理執行緒,使得我們無需關心這些細節,可以更專注於任務的實現,解耦;
  • 執行緒池通過統一管理建立的執行緒,實現執行緒的複用,避免執行緒的頻繁建立和銷燬,減少了在建立和銷燬執行緒上所花的時間以及系統資源的開銷,資源利用率更高;
  • 當需要執行大量的非同步任務時,由執行緒池統一管理和調配執行緒資源,可以獲得更好的效能;

  本文我們會從如下幾個方面來進行總結:

  Executor框架

  執行緒池使用

  執行緒池結構及狀態

  總結

 

1. Executor框架

  既然執行緒池這麼好,我們就來看看JDK中提供了哪些執行緒池供我們使用吧。Java中提供執行緒池工具的是Executor框架,如下是其類圖,我們看一下其基本組成:

 

1.1 Eecutor

  處於最頂部的是Executor,這是一個基礎介面,只定義了一個唯一方法execute(),用於提交任務:

void execute(Runnable command);

1.2 ExecutorService

  ExecutorService則提供了更多功能,包括service的管理功能如shutdown等方法,還包括不同於execute的更全面的提交任務機制,如返回Future的submit方法。因為Runnable是執行工作的獨立任務,但是它不返回任何值,如果希望任務在完成時能夠返回一個值,那麼可以讓任務實現Callable介面而不是Runnable介面,並且必須使用ExecutorService.submit()方法提交任務,看一個demo吧:

// 定義一個帶返回值的任務,實現Callable介面
class
TaskWithResult implements Callable<String>{ private int id; public TaskWithResult(int id){ this.id = id; }
   // 這個就是提供返回值的方法,當獲取返回值時實際會呼叫這個方法
public String call(){ return "result of TaskWithResult " + id; } } public class CallableDemo{ public static void main(String[] args){ ExecutorService exec = Executors.newCachedThreadPool(); ArrayList<Futrue<String>> results = new ArrayList<Future<String>>(); for(int i = 0; i<10 ; i++){
       // 提交任務之後會返回一個Future,可以通過它的get方法獲取任務計算返回的結果 results.add(exec.submit(
new TaskWithResult(i))); } for(Future<String> fs : results){ try{ // 呼叫get()方法時必要的話(計算任務未完成)會阻塞 System.out.println(fs.get()); }catch(InterruptedException e){ System.out.println(e); return; }catch(ExecutionExecution e){ System.out.println(e); return; }finally{ exec.shutdown(); } } } } /** output: result of TaskWithResult 0 result of TaskWithResult 1 ... result of TaskWithResult 9 */

1.3 執行緒池實現

  JDK提供了幾種執行緒池基礎實現,分別是ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。通過不同的構造引數,我們可以產生多種不同特性的執行緒池以滿足複雜多變的實際應用場景。後面我們會進一步分析其建構函式部分原始碼,來剖析這個靈活性的源頭。

1.4 Executors  

  藉助Executors提供的靜態工廠方法,我們可以方便地建立出不同配置的執行緒池,Executors目前主要提供瞭如下幾種不同的執行緒池建立方式:

  • newCachedThreadPool(),它是一種用來處理大量短時間工作任務的執行緒池,它會試圖快取執行緒並重用,當無快取執行緒可用時,就會建立新的工作執行緒;如果執行緒閒置的時間超過60秒,則被終止並移出快取;長時間閒置時,這種執行緒池,不會消耗什麼資源。其內部使用 SynchronousQueue作為工作佇列。

  • newFixedThreadPool(int nThreads),重用指定數目(nThreads)的執行緒,其底層使用的是無界的工作佇列,任何時候最多有nThreads個工作執行緒是活動的。這意味著,如果任務數量超過了活動佇列數目,將在工作佇列中等待空閒執行緒出現;如果有工作執行緒退出,將會有新的工作執行緒被建立,以補足指定的數目 nThreads。

  • newSingleThreadExecutor(),它的特點在於工作執行緒數目被限制為1,操作一個無界的工作佇列,所以它保證了所有任務的都是被順序執行,最多會有一個任務處於活動狀態,並且不允許使用者改動執行緒池例項,因此可以避免其改變執行緒數目。

  • newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize),建立的是一個ScheduledExecutorService,可以進行定時或週期性的工作排程,區別在於單一工作執行緒還是多個工作執行緒。

  • newWorkStealingPool(int parallelism),這是一個經常被人忽略的執行緒池,java8才加入這個建立方法,其內部會構建ForkJoin Pool,利用Work-Stealing演算法,並行地處理任務,不保證處理順序。

 

2. 執行緒池使用

   利用這些工廠方法,常見的執行緒池建立方式如下:

ExecutorService threadPool1 = Executors.newCachedThreadPool();
ExecutorService threadPool2 = Executors.newFixedThreadPool(10);
ExecutorService threadPool3 = Executors.newSingleThreadExecutor();
ExecutorService threadPool4 = Executors.newScheduledThreadPool(10);
ExecutorService threadPool5 = Executors.newWorkStealingPool();

  在大多數應用場景下,使用Executors提供的靜態工廠方法就足夠了,但是仍然可能需要直接利用ThreadPoolExecutor等建構函式執行緒池建立(其實如上5種方式除了newWorkStealingPool之外,其餘都是通過ThreadPoolExecutor類的建構函式來實現的),比如:

ExecutorService service = new ThreadPoolExecutor(1,1,
                60L,TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(10));

  為什麼需要這樣做呢?因為這樣做可以根據我們的實際使用場景靈活調整執行緒池引數。這需要對執行緒池構造方式有進一步的瞭解,需要明白執行緒池的設計和結構。因為大部分執行緒池的建構函式都是呼叫的ThreadPoolExecutor的構造器,所以在本文以及後面的原理分析的文章中我們都是針對ThreadPoolExecutor,JDK為1.8,我們先來看一下ThreadPoolExecutor的建構函式:

 public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

  當然ThreadPoolExecutor還有很多建構函式,但是底層也都是呼叫的這個建構函式,只是傳的引數是預設引數而已,這裡就不一一列出了,佔空間。執行緒池的建構函式有一堆的引數,這個還是有必要看一下的:

  • corePoolSize:核心執行緒數量,常駐執行緒數量,包括空閒執行緒;

  • maximumPoolSize:最大的執行緒數量,常駐+臨時執行緒數量;

  • workQueue:多餘任務等待佇列,此佇列僅保持由 execute方法提交的 Runnable任務,必須是BlockingQueue;

  • keepAliveTime:非核心執行緒空閒時間,即當執行緒數大於核心數時,多餘的空閒執行緒等待新任務的最長時間;

  • unit:keepAliveTime 引數的時間單位;

  • threadFactory:執行程式建立新執行緒時使用的工廠,這裡用到了抽象工廠模式,Executors提供了一個預設的執行緒工廠實現DefaultThreadFactory;

  • handler:執行緒池拒絕策略,當任務實在是太多,沒有空閒執行緒,等待佇列也滿了,如果還有任務怎麼辦?預設是不處理,丟擲異常告訴任務提交者,我這忙不過來了,你提交了也處理不了;

  通過配置不同的引數,我們就可以建立出行為特性各異的執行緒池,而這,就是執行緒池高度靈活性的基石。

 

3. 執行緒池結構及狀態

  到這裡我們知道執行緒的優點,學習了怎樣建立執行緒池以及通過構造器部分的原始碼我們知道了執行緒池靈活性的根源,是時候再進一步了。我們可以把執行緒池理解成為一個容器,幫我們建立執行緒,接受我們提交給它的任務,並幫我們執行任務。那我們就有必要詳細來看一下執行緒池內部是如何儲存我們的任務以及執行緒,並通過什麼方式來表徵執行緒池自身的狀態的。

  我們進入原始碼,首先映入眼簾的便是如下這一堆程式碼:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
// 工作執行緒的理論上限,大約5億多個執行緒
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING    = -1 << COUNT_BITS; //11100000000000000000000000000000
private static final int SHUTDOWN   =  0 << COUNT_BITS; //0
private static final int STOP       =  1 << COUNT_BITS; //00100000000000000000000000000000
private static final int TIDYING    =  2 << COUNT_BITS; //01000000000000000000000000000000
private static final int TERMINATED =  3 << COUNT_BITS; //01100000000000000000000000000000

// Packing and unpacking ctl
private static int runStateOf(int c)     { return c & ~CAPACITY; }
private static int workerCountOf(int c)  { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

  ctl,即執行緒池的控制狀態,這是一個原子類,在這個整型數中封裝了兩層意思(限於表達能力,只能這樣表達):

  • workerCount,即有效執行緒數量(也可以說是worker的數量);
  • runState,你執行緒池的執行狀態;

  我們來看一下Doug Lea大神是如何在一個整型變數中表達兩層含義的呢?

3.1 執行緒數量

  我們知道Java中的int型整數是32位的,線上程池中利用整型的高3位來表徵執行緒池的執行狀態,用剩下的低29位來表達有效執行緒數量,2的29次方是什麼數量級,大概5億吧,在目前以及未來很長一段時間,單機上是很難達到這個級別的執行緒數量的(即便未來存在問題,也可以通過Long型別來解決),所以執行緒數量問題就滿足了,多出來的高三位就可以用來表達執行緒池執行狀態了。

3.2 執行緒池狀態

  對照程式碼來看,上面COUNT_BITS實際為29,CAPACITY表示最大有效執行緒數量,大概是2的29次方。執行緒的狀態和其對應的位的值如下:

  • RUNNING:高三位為111,執行狀態,可以接受任務執行佇列裡的任務;
  • SHUTDOWN:高三位為000,指呼叫了 shutdown() 方法,不再接受新任務了,但是佇列裡的任務得執行完畢;
  • STOP:高三位為001,指呼叫了 shutdownNow() 方法,不再接受新任務,同時拋棄阻塞佇列裡的所有任務並中斷所有正在執行任務;
  • TIDYING:高三位為010,所有任務都執行完畢,在呼叫 shutdown()/shutdownNow() 中都會嘗試更新為這個狀態;
  • TERMINATED:高三位為011,終止狀態,當執行 terminated() 後會更新為這個狀態;

  這些狀態之間是會互相轉變的,它們之間的轉換時間如下:

  • RUNNING -> SHUTDOWN,呼叫執行緒池的shutdown()方法;
  • (RUNNING or SHUTDOWN) -> STOP,呼叫執行緒池的shutdownNow()方法時;
  • SHUTDOWN -> TIDYING,當任務佇列和執行緒池(儲存執行緒的一個hashSet)都為空時;
  • STOP -> TIDYING,當任務佇列為空時;
  • TIDYING -> TERMINATED,呼叫執行緒池的terminated()方法並執行完畢之後;

  說了這麼多,還是上張圖吧:

3.3 為什麼這麼設計

  但是看上面那堆程式碼,因為一個整型變數表示兩種含義,每次要使用的時候都要通過一些位運算來將需要的資訊提取出來,為什麼不直接用兩個變數來表示?難道是節約空間?嗯,起先我也是這樣認為的,後來才發現是自己too young了。。。一個整型總共才佔用4個位元組,兩個才多了4個位元組,為了這4個位元組需要這麼大費周章嗎!後來才知道這是因為在多執行緒環境下,執行狀態和有效執行緒數量往往需要保證統一,不能出現一個改而另一個沒有改動的情況,如果將他們放在同一個AtmocInteger中,利用AtomicInteger的原子操作,就可以保證這兩個值始終是統一的,嗯,對Doug大神併發的理解真是出神入化。後面我們在原始碼分析中可以有更直觀的體會。

3.4 執行緒池核心資料結構

  我們接著看原始碼,主要有兩個地方需要注意:

// 儲存任務的阻塞佇列
private
final BlockingQueue<Runnable> workQueue;
// 儲存工作執行緒的set,即真正的池
private final HashSet<Worker> workers = new HashSet<Worker>();

  對於這裡,比較簡單:

  • 工作佇列負責儲存使用者提交的任務,容量可以指定,必須為BlockingQueue
  • 這個works才是真正的“執行緒池”,用來儲存工作執行緒的集合,原來所謂的執行緒池中的執行緒都是儲存在一個HashSet中。執行緒池的工作執行緒被抽象為靜態內部類Worker,是基於AQS實現,後面會詳細分析其原理。

 

4. 總結

1. 使用執行緒池有很多好處:

  • 降低資源消耗。通過重複利用已建立的執行緒降低執行緒建立和銷燬造成的消耗;

  • 提高響應速度。當任務到達時,任務可以不需要等到執行緒建立就能立即執行;

  • 提高執行緒的可管理性。執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一的分配,調優和監控;

  • 解耦,使用者不用關心執行緒的建立,只需提交任務即可;

2. JDK中Executor框架提供如ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool等執行緒池的基本實現,可以通過Executors提供的靜態工廠方法建立多種執行緒池,也可使用ThreadPoolExecutor提供的建構函式定製化符合業務需求的執行緒池;

3. 執行緒通過一個整型變數ctl表示存活執行緒數量和執行緒池執行狀態;

4. 使用者提交的任務是儲存在一個阻塞佇列中,執行緒池建立的工作執行緒是儲存在一個HashSet中;

 

  在本文中我們從執行緒池優點開始,再到了解整個Executor框架,通過一些加單demo瞭解了執行緒池的基本使用,再結合原始碼初步分析了執行緒池的內部資料結構以及狀態表徵,關於執行緒池進一步的執行原理,有興趣的同學可以關注後面的文章。總結不易,覺得有幫助就點個贊吧^_^

相關文章