多線程4-線程池執行原理淺析


引言

​ 我們爲什麼要使用線程池,它可以給我們帶來什麼好處?要想合理使用線程池,我們需要對線程池的工作原理有深入的理解和認識,讓我們一起來看看吧。

好處:

​ 1、處理響應快,不用每次任務到達,都需要等待初始化創建線程再執行,到了就能拿來用。

​ 2、資源複用,減少系統資源消耗,減低創建和銷燬線程的消耗。

​ 3、可方便對線程池中線程統一分配,調優,監控,提高系統穩定性。

一、線程池工作流程圖

在這裏插入圖片描述

誤區: 有沒有人之前和我一樣,以爲當線程池中線程達到最大線程數後,纔將任務加入阻塞隊列?

二、線程池的運行原理

先講個小故事,一個銀行網點的服務過程:

如某銀行網點,總共有窗口5個,提供固定座椅3個供客人休息, 在非工作日窗口並不是全都開放,而是安排輪值窗口,比如開放2個窗口給客戶辦理業務。當客戶1,2 進網點辦理業務,可直接去窗口辦理,後又來了3位客戶,這三位客戶只能取號在座椅等待, 這時如果再來3位客戶,這時座椅不夠坐了,大堂經理爲了儘快給客戶辦理,只好增派人手,開放其他3個窗口;
這時5個窗口全部開放爲客戶辦理業務,座椅還有3位客戶排號等待;這時正值客流高峯期,如果再來客戶辦理業務,網點接待不過來,爲了不讓客戶等待太長時間,這時可以對再來客戶勸說選擇其他時間過來,或者去其他就近網點辦理。當客戶高峯過去,客戶逐漸稀少,這時臨時增派人手的窗口工作人員就可以關閉窗口,只保留輪值2個窗口繼續提供服務。

類比銀行的服務過程,線程池的執行原理與之相似:

線程池中一開始沒有線程,在有新任務加入進來,才創建核心線程處理任務,(針對某些業務需求,可以線程池預熱執行prestartAllCoreThreads()方法,可以在線程池初始化後就創建好所有的核心線程)。當多個任務進來,線程池中的線程來不及處理完手上任務,就創建新的線程去處理,當線程數達到核心線程數( corePoolSize),就不再創建新的線程了,再有多的任務添加進來,加入阻塞隊列等待;這裏核心線程就如銀行網點的輪值窗口,阻塞隊列就如網點中的座椅, 但是網點中座椅是有限的,而線程池中的阻塞隊列有可能接近無限,下文會詳細講述幾種隊列,這裏假定線程池中隊列也是有限的,在新加入的任務在阻塞隊列中已經裝不下的時候,這時就得加派人手,如果線程池中還沒有達到最大線程數,創建新的線程來處理任務,如果線程池已經達到最大線程數,如網點辦理窗口都開放了,等候區的椅子也坐滿了客戶,這時就得執行拒絕策略,不再接收新的任務;實際的拒絕策略方式更靈活,這裏如此便於理解,下文再深入探討。當線程處理完阻塞隊列中任務,新加入的任務減少,或者沒有任務添加,線程池中的非核心線程在空閒一定時間(keepAliveTime)後就被回收,可以節約資源。核心線程不會被回收,等待處理新加入的任務。

類比關係:

線程池 --> 銀行網點
線程 --> 辦理業務的窗口
任務 --> 客戶
阻塞隊列 --> 等候區的座椅
核心線程數 --> 輪值的窗口
最大線程數 --> 網點可以開放的所有窗口

三、線程池的7個參數

  public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                     BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                      RejectedExecutionHandler handler)
  • 1、corePoolSize, 核心線程數
  • 2、maximumPoolSize, 線程池中可以創建的最大線程數
  • 3、keepAliveTime, 這個參數僅對非核心線程有效,當非核心線程空閒(沒有任務執行)超過keepAliveTime時間就會被回收。
  • 4、unit, keepAliveTime的時間單位, 如秒,分等
  • 5、workQueue,阻塞隊列,用於存放提交的任務, 在沒有空閒的核心線程時,新加入的任務放入阻塞隊列中等待執行。
  • 6、threadFactory,用於創建線程的工廠。
  • 7、handler,用於拒絕新添加的任務,當線程池中阻塞隊列已滿, 且線程池中已經達到最大線程數,再有新的任務提交進來,執行的拒絕策略。

四、常用4個阻塞隊列

  1. ArrayBlockingQueue 底層數組
  2. LinkedBlockingQueue 底層鏈表
  3. SynchronousQueue 不存儲元素的隊列, 沒有容量的阻塞隊列,每個插入操作必須等待另一個線程的對應移除操作,較難理解,見下文示例分析。

4)PriorityBlockingQueue: 優先級排序隊列,優先級高的任務先被執行,可能會導致優先級低的始終執行不到,導致飢餓現象。

注: 在Executos工具類提供的三種線程池中, FixedThreadPool,SingleThreadExecutor都使用的LinkedBlockingQueue 鏈表結構的隊列, CachedThreadPool使用的SynchronousQueue沒有容量的隊列。

五、四個拒絕策略語義以及測試用例

1、AbortPolicy: 直接拋出異常 (默認方式)
2、CallerRunsPolicy: 拋給調用者去執行任務,如誰創建了線程池提交任務進來,那就找誰去執行,如主線程
3、DiscardOldestPolicy: 丟棄在隊列中時間最長的,即當前排在隊列首位的(先進來的任務),開發中是有適用業務場景,如等待最久的任務已經不具有再執行的意義了,如時效性比較強的業務。或者業務可允許一些任務。
4、DiscardPolicy: 新加入的任務直接丟棄,也不拋異常,直接不處理。

示例如下:

  • 1、AbortPolicy 策略

提交9個任務,超出線程池可最大容納量8個

package ThreadPoolExcutor;

import java.util.concurrent.*;

/**
 * @author zdd
 * 2019/12/1 11:16 上午
 * Description: 測試線程池4種拒絕策略
 */
public class ExcutorTest1 {

    public static void main(String[] args) {

     //   System.out.println("cpu number:"+ Runtime.getRuntime().availableProcessors());

         //實際開發中 自己創建線程池
        //  核心線程數2,最大線程數5,阻塞隊列容量3,即最大可容納8個任務,再多就要執行拒絕策略。
        ExecutorService executorService = new ThreadPoolExecutor(
                2,
                5,
                1L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());

        //提交9個任務,超出線程池可最大容納量8個 
        for (int i = 0; i < 9; i++) { 
           final int index =i+1;
           //此時任務實際還未被提交,打印只是爲了方便可見。
            System.out.println("任務"+index +"被提交");
            executorService.execute(()-> {
                try {
                    //休眠1s,模擬處理任務
                    TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+ " 執行任務" +index);
            }) ;
        }

        executorService.shutdown();
    }
}

執行結果:直接拋出異常

在這裏插入圖片描述

  • 2、CallerRunsPolicy策略
new ThreadPoolExecutor.CallerRunsPolicy() //線程池採用該策略

執行結果:可見任務9被調用者主線程執行

在這裏插入圖片描述

  • 3、DiscardOldestPolicy策略
   new ThreadPoolExecutor.DiscardOldestPolicy()) 

執行過程: 任務1,2提交直接創建核心線程執行,任務3,4,5依次被放入阻塞隊列中,任務6,7,8再提交創建非核心線程執行,此時任務9提交進來,執行拒絕策略,將阻塞隊列中排在首位的任務3丟棄,放入任務9。

執行結果: 可見任務3被丟棄了,未執行。

在這裏插入圖片描述

  • 4、DiscardPolicy 策略
new ThreadPoolExecutor.DiscardPolicy() //修改此處策略

執行結果: 可見有9個任務被提交,實際就8個任務被執行,任務9直接被丟棄
在這裏插入圖片描述

六、Executors工具類

Executors, Executor,ExecutorService, ThreadPoolExecutor 之間的關係?

如下類圖所示:

在這裏插入圖片描述
Executors是一個工具類,就如集合中Collections工具類 一樣,可以提供一些輔助的方法便於我們日常開發,如幫助創建線程池等。

在線程池中核心的類是上圖顏色標識的ThreadPoolExecutor和 SchduledThreadPoolExecutor 兩個類

  • ThreadPoolExecutor:創建線程池核心類,可以根據業務自定義符合需求的線程池。
  • SchduledThreadPoolExecutor:用於操作一些需要定時執行的任務,或者需要週期性執行的任務,如Timer類的功能,但是比Timer類更強大,因爲Timer運行的多個TimeTask 中,只要其中之一沒有被捕獲處理異常,其他所有的都將被停止運行,SchduledThreadPoolExecutor沒有這個問題。
6.1. Executors提供的三種線程池

Exectutos爲我們提供了FixedThreadPool, SingleThreadExecutor, CachedThreadPool 三種線程池,

實際工作中如何使用線程池、用jdk工具類Excutors提供的三類,還是自己寫,爲什麼?

  • 1、固定數量線程的線程池 - FixedThreadPool
//1,固定數量線程池
public static ExecutorService newFixedThreadPool(int nThreads) {
  
        return new ThreadPoolExecutor(
          nThreads, 
          nThreads,
           0L, 
          TimeUnit.MILLISECONDS,
           new LinkedBlockingQueue<Runnable>());
    }

 //2,可見隊列默認大小非常大
  public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }
/** 
解析:
Integer.MAX_VALUE = 2^31 -1,大概21億,近似無界隊列
1)核心線程數== 最大線程數
2)阻塞隊列近似無界
3)由於1,2,空閒線程的最大生存時間(keepAliveTime)也是無效的,不會創建其他非核心線程

存在問題:網上有推薦使用該種方式創建線程池,因爲有一個無界的阻塞隊列,在生產環境出現業務突刺(訪問高峯,任務突然暴增等),不會出現任務丟失;可一旦出現該種情況,阻塞隊列就算無界,服務器資源,如內存等也是有限的,也無法處理如此多的任務,有OOM(內存溢出)的風險,也不是推薦的方法。
**/
  • 2、僅有一個線程處理任務- SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

/**
解析:

1)核心線程 =最大線程數=1,線程池中僅有1個線程
2)採用無界阻塞隊列

1,2,可以實現所有的任務被唯一的線程有序地處理執行。
**/
  • 無界線程數量 – CachedThreadPool
//線程最大線程數近似無界
public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }  

/**
解析:
1)核心線程數 ==0
2)最大線程數無界
3)採用沒有容量的阻塞隊列
4)空閒線程可存活60s,超過60s無新任務進來就會被回收。
5)如果主線程提交任務的速度大於線程處理任務的速度時,會不斷創建新的線程,因最大線程數時無界的,極端情況有可能耗盡cup和內存資源。
6)SynchronousQueue 隊列既然沒有容量,是怎樣是機制實現添加任務和線程獲取任務去執行的呢?

那要實現添加和獲取任務的配對:即 offer()和 poll() 方法的配對

從添加角度看:主線程添加任務到線程池中(調用SynchronousQueue.offer(task)),當前沒有空閒線程可用,則創建新線程處理,有空閒線程給它執行。

從獲取角度看:線程池中線程處理完手上任務後,去阻塞隊列獲取新的任務(調用SynchronousQueue.poll()方法),沒有任務空閒的線程在SynchronousQueue中等待最多60s,即空閒線程去隊列中等待任務提交,在這期間主線程沒有新任務提交,線程就會被回收,如有新任務提交則處理執行。免於被回收的厄運; 當線程池中較長時間沒有新任務添加,整個線程池都空閒時,線程都會被回收,此時沒有線程存在,可節約資源。
**/

在分析了Executors工具類提供的創建三種線程池, 雖然簡單易用,但在實際開發中我們卻不建議使用,因此我們需要根據公司業務來自己創建線程池。在阿里巴巴的Java開發手冊中也強制不讓使用Executors去創建線程池,都有OOM的風險。如:
在這裏插入圖片描述

6.2 實際開發中應該怎樣設定合適線程池?

cpu 密集型任務:儘量創建少一些線程 , cpu個數+1

IO 密集型任務: 線程數可以多一些,cup個數*2

//可獲取當前設備的cpu個數
Runtime.getRuntime().availableProcessors()

七、線程池提交任務的2種方式

  • execute(): 提交任務無返回值
  • submit() :有返回值,可獲取異步任務的執行結果。
void execute(Runnable command)  
//分割線 ---
Future<?> submit(Runnable task)
Future<T> submit(Callable<T> task)

示例: 使用線程池的submit提交異步任務,主線程調用 FutureTask的get() 方法,在異步任務未執行完畢前,主線程阻塞等待,異步任務執行結束,獲取到返回結果。

**適用場景 :**當一個線程需要開啓另一個線程去執行異步任務,而需要異步任務的返回結果,存在數據依賴關係,在實際開發中,可將一次任務拆分爲多個子任務,開啓多個線程去併發執行,最後異步獲取結果,能有效提高程序執行效率。

代碼如下:

package ThreadPoolExcutor;

import java.util.concurrent.*;

/**
 * @author zdd
 * 2019/12/5 7:22 下午
 * Description:測試 Callable與FutureTask的簡單實用,執行異步任務
 */
public class ThreadPoolSubmitTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
       //1,創建線程池
       ExecutorService threadPool =  Executors.newFixedThreadPool(5);
        Callable callableTask = new Callable() {
            @Override
            public Object call() throws Exception {
                try {
                    //1,一個異步任務,模擬執行一個比較耗時的業務。休眠3s
                    TimeUnit.SECONDS.sleep(3);
                    System.out.println("休眠3s結束! ");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //2,返回執行結果
                return "ok!";
            }};

       FutureTask<String> futureTask = new FutureTask(callableTask);
       threadPool.submit(futureTask);
       // 2,主線程想要獲取 異步任務的執行結果
        System.out.println(futureTask.get());
        //3,關閉線程池
        threadPool.shutdown();
    }
}

執行結果:主線程阻塞等待直至獲取到結果

休眠3s結束!  
ok!

八、總結

  • 1,在線程池中線程數還未達到核心線程數時,每新來一個任務就創建一個新線程,即使有空閒的線程。
  • 2,線程池中不是在達到最大線程數後,再將新提交的任務放入阻塞隊列中,而是在大於等於核心線程數後,就將新任務添加到阻塞隊列,有些線程池雖然核心線程數等於最大線程數,但是判斷對象一定是核心線程數 。
  • 3,每次創建線程池,記着使用完畢,執行shutdown()方法,關閉線程池。
  • 4,Java爲我們提供的線程池更偏向 cpu 密集型任務場景,因爲只有在加入阻塞隊列失敗的情況,纔會去嘗試創建其他非核心線程,如果我們想要處理IO密集型任務,創建多個線程來處理,又能非常高效,此處可參考Tomcat的線程池原理,她對java原生線程池做了拓展修改,以應對非常多的請求的場景(IO密集任務)。

開發中推薦使用線程池創建線程, 可減少線程的創建和銷燬的時間和系統資源的開銷,合理使用線程池,可節約資源,減少每次都創建線程的開銷,可實現線程的重複使用。本文從線程池的內部工作原理開始介紹、以及Jdk爲我們默認提供的三類線程池的特點和缺陷、線程池中常用的3種阻塞隊列、以及4種拒絕策略、開發中我們推薦自定義線程池、線程池2種提交任務的方式等;在瞭解線程池後,開發中我們能夠避免踩坑,也能有效讓它爲我們所用,提升開發效率,節約資源。


參考資料:

1、Java 併發編程的藝術 - 方騰飛

2、Java 開發手冊

道阻且長,且歌且行!

每天一小步,踏踏實實走好腳下的路,文章爲自己學習總結,不復制黏貼,就是想讓自己的知識沉澱一下,也希望與更多的人交流,如有錯誤,請批評指正!

我的微信公衆號:夕陽下飛奔的豬
在這裏插入圖片描述

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