從使用到原理,探究Java線程池

什麼是線程池

當我們需要處理某個任務的時候,可以新創建一個線程,讓線程去執行任務。線程池的字面意思就是存放線程的池子,當我們需要處理某個任務的時候,可以從線程池裡取出一條線程去執行。

為什麼需要線程池

首先我們要知道不用線程池,直接創建線程有什麼弊端:

  1. 第一個是創建與銷毀線程的開銷,Java中的線程是映射到操作系統線程上的,頻繁地創建和銷毀線程會極大地損耗系統的性能。

  2. 線程會佔用一定的內存空間,如果我們在同一時間內創建大量的線程執行任務,很有可能出現內存不足的情況。

為了解決這兩個問題我們引入線程池的概念,通過復用線程避免重複創建銷毀線程帶來的開銷,同時可以設置最大線程數,避免同時創建大量線程導致內存溢出。

線程池的使用

1.線程池的核心參數

想掌握線程池首先要理解線程池構造函數的參數:

參數名 類型 含義
corePoolSize int 核心線程數
maxPoolSize int 最大線程數
keepAliveTime long 保持存活時間
workQueue BlockingQueue 任務存儲隊列
threadFactory ThreadFactory 當線程池需要新創建線程的時候,會通過ThreadFactory創建
Handler RejectedExecutionHandler 當線程池無法接受你提交的任務時所採取的拒絕策略

逐個解釋這些參數是很難理解的,這裡我結合一張線程池處理的流程圖進行講解:

image

當我們往線程池裡提交任務時,如果線程池內的線程數少於corePoolSize,則會直接創建新的線程處理任務;

如果線程池的線程數達到了corePoolSize,並且存儲隊列沒滿,則會把任務放到workQueue任務存儲隊列里;

如果存儲隊列也滿了,但是線程數還沒有達到maxPoolSize,這個時候就會繼續創建線程執行任務。注意:這個時候線程池內的線程數已經超過了corePoolSize,超過corePoolSize的線程不會一直存活在線程池內,當他們閑下來時並超過keepAliveTime設定的時間後,就會被銷毀。

如果線程數已經達到了maxPoolSize,這個時候如果再來任務,線程池就採取Handler所指定的拒絕策略拒絕任務。

2.幾種常見的線程池分析

Java為我們提供了幾種常用的線程池,通過Executors類可以輕易地獲取它們。下面我們通過分析這幾種常用線程池的參數,了解這些線程池之間的異同。

  1. newSingleThreadExecutor

從字面上也好理解,這是一個單線程的線程池,它的構造參數如下(創建的時候不需要傳參,這裡指的是下一層調用線程池構造函數時的傳參):

corePoolSize:1
maximumPoolSize(maxPoolSize):1
keepAliveTime:0L
workQueue:LinkedBlockingQueue
其他參數為默認值

大家按照照着上面的流程圖模擬提交任務走一遍,就知道為什麼這是一個單線程的線程池了。

當初次任務提交的時候,會創建一個線程執行任務;當提交第二個任務的時候,由於corePoolSize值為1,所以任務會放到任務隊列中。由於任務隊列選擇的是LinkedBlockingQueue,底層結構是鏈表,理論上可以存放幾乎無窮多的任務(默認的大小是Integer.MAX_VALUE),所以永遠不會觸發任務隊列已滿的條件,也就永遠不會繼續增加線程,所以該線程池能保持一個單線程的工作狀態。

如果這個唯一的線程因為異常結束了,線程池會創建一個新的線程補上。通過阻塞隊列,這個線程池能夠保證任務是按順序執行的。

  1. newFixedThreadPool

這是一個固定線程數的線程池,它的構造參數如下:

corePoolSize:n
maximumPoolSize(maxPoolSize):n
keepAliveTime:0L
workQueue:LinkedBlockingQueue
其他參數為默認值

如果理解了 SingleThreadExecutor 是如何限制只有一條線程執行任務的話,那這裡固定線程數的原理也是一樣的,關鍵是限定 corePoolSize 和 maxPoolSize 的大小一樣,並使用幾乎無限容量LinkedBlockingQueue

  1. newCachedThreadPool

可緩存的線程池,我理解的緩存是關於線程的緩存,它的構造參數如下:

corePoolSize:0
maximumPoolSize(maxPoolSize):Integer.MAX_VALUE
keepAliveTime:60L
workQueue:SynchronousQueue
其他參數為默認值

由於corePoolSize為0,所以任務提交到該線程池後會直接到阻塞隊列。又由於阻塞隊列採用的是SynchronousQueue,這是一種不存儲任務的隊列,一旦獲得任務它就會分發給任務處理線程,所以直接觸發流程圖中第三個判斷框:如果當前線程數小於maxPoolSize就創建線程。由於maxPoolSize設置了一個很大的值,基本上可以無限地創建線程,具體的數量取絕於JVM所能創建的最大線程數。若線程空閑60秒沒任務處理便會被線程池回收。

該線程池在處理大量異步短鏈接任務的時候有較好的性能,在空閑的時候池內是沒有線程的,節省了系統的資源。

  1. newScheduledThreadPool

corePoolSize:自定義
maximumPoolSize(maxPoolSize):Integer.MAX_VALUE
keepAliveTime:0
workQueue:DelayedWorkQueue
其他參數為默認值

由於maxPoolSize設置為Integer.MAX_VALUE,該線程池可以無限創建線程,由於阻塞隊列選擇了DelayedWorkQueue,所以可以周期性地執行任務。

  1. newWorkStealingPool

這個是JDK1.8新加入的線程池,底層使用的是ForkJoinPool。如果使用默認參數創建的話,該線程池能夠創建足夠多的線程以達到和系統相匹配的並行處理能力。每個線程都有自己的工作隊列,如果當前線程工作完了,它會到別的工作隊列中「竊取」任務執行,充分地利用了CPU的多核能力。

阿里巴巴關於創建線程池的約規

下面這段話搬運自阿里巴巴Java開發手冊,相信大家看完上面的參數解釋以及各種線程池的異同後,就不難理解這段約規了:

(六)並發處理
4. 【強制】線程池不允許使用Executors去創建,而是通過ThreadPoolExecutor的方式,這樣的處理方式讓寫的同學更加明確線程池的運行規則,避免資源耗盡的風險。

說明:Executors返回線程池的弊端如下:
1) FixedThreadPool和SingleThreadPool:
允許的請求隊列長度為Integer.MAX_VALUE,可能會堆積大量的請求,從而導致OOM。
2) CacheThreadPool和ScheduledThreadPool:
允許創建的線程數量為Integer.MAX_VALUE,可能會創建大量的線程,導致OOM。

3. 線程池的數量設置為多少比較合適?

這個問題是沒有固定答案的,我們可以先通過業界權威給出的公式計算線程池的數量,然後通過壓測進一步確認具體的數量。

業界給出的指導公式是:

  1. 若任務是CPU密集型任務(比如說加密,計算哈希等),線程數可以設置為CPU核心數的1-2倍左右。

  2. 若任務是耗時IO型任務(比如說讀寫數據庫,文件,網絡等),線程數的公式為:線程數 = CPU核心數 * (1 + 平均等待時間 / 平均處理時間)

這兩種不同設計都遵循着儘力壓榨CPU性能的原則。

4. 線程池的五種狀態

線程池的五種狀態都寫在了ThreadPoolExecutor類中了,它們分別是:

  1. RUNNING:接受新任務,並處理新任務
  2. SHUTDOWN:不接受新任務,但是會處理隊列中的任務
  3. STOP:不接受新任務,不處理隊列中的任務,中斷正在處理的任務
  4. TIDYING:所有任務已經結束,workerCount為零,這時線程會轉到TIDYING狀態,並將運行terminated()鉤子方法
  5. TERMINATED:terminated()運行完成

5. 線程池運行的原理

我們先回顧一下如何新創建一個線程處理任務,看懂了再看線程池的原理就簡單了:

//首先把我們要放在線程里運行的代碼在Runnable接口實現類的run方法中封裝好    class MyTask implements Runnable {        @Override      public void run() {          System.out.println("處理任務 + 1");      }  }    //然後創建一個線程,把該Runnable接口實現類作為構造參數傳給線程    public class Basic {      public static void main(String[] args) {          Thread thread = new Thread(new MyTask());          thread.start();      }  }  //最後調用線程的start方法運行,實際上調用的是Runnable的run方法  

在上面的代碼中,實現了Runnable接口的實例傳入到線程類中,成為了線程對象的一個成員變量,線程運行的時候會調用該實例的run方法。

可以看到如果新創建一個線程來執行任務,任務會和線程耦合在一起。而線程池的關鍵原理在於它添加了一個阻塞隊列,把任務和線程解耦了

在線程池中,有一個worker的概念,這個概念解釋起來有點困難,你可以直接理解為worker就是一個線程工人,它手上拿着任務,當調用線程池的runWorker()方法時,線程就會處理一個任務,詳細見下面代碼

final void runWorker(Worker w) {      Thread wt = Thread.currentThread();      Runnable task = w.firstTask;      w.firstTask = null;      w.unlock(); // allow interrupts      boolean completedAbruptly = true;      try {          while (task != null || (task = getTask()) != null) {//會到阻塞隊列中獲取任務              w.lock();              //...              try {                  //執行任務              } finally {                  //...                  w.unlock();              }          }          //...      } finally {          //...      }  }  

從代碼中可以看到線程池的關鍵代碼就是一個while循環,在while循環中會不斷地向阻塞隊列中獲取任務,獲取到了任務就執行。


參考:

  1. 慕課網《玩轉Java並發工具,精通JUC,成為並發多面手》課程
  2. https://www.oschina.net/question/565065_86540
  3. https://www.cnblogs.com/dolphin0520/p/3932921.html
  4. https://www.cnblogs.com/ok-wolf/p/7761755.html