【Java分享客棧】SpringBoot執行緒池引數搜一堆資料還是不會配,我花一天測試換你此生明白。

福隆苑居士發表於2022-05-01

一、前言

  首先說一句,如果比較忙順路點進來的,可以先收藏,有時間或用到了再看也行;
  我相信很多人會有一個困惑,這個困惑和我之前一樣,就是執行緒池這個玩意兒,感覺很高大上,用起來很fashion,本地環境測試環境除錯毫無問題,但是一上線就出問題。
  然後百度一大堆資料,發現都在講執行緒池要自定義,以及各種配置引數,看完之後點了點頭原來如此,果斷配置,結果線上還是出問題。
  歸根究底,還是對自定義執行緒池的配置引數不瞭解造成的,本篇就通過一個很簡單的案例給大家梳理清楚執行緒池的配置,以及線上環境到底該如何配置。


二、案例

1、編寫案例

自定義一個執行緒池,並加上初始配置。
核心執行緒數10,最大執行緒數50,佇列大小200,自定義執行緒池名稱字首為my-executor-,以及執行緒池拒絕策略為AbortPolicy,也是預設策略,表示直接放棄任務。

package com.example.executor.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
@EnableScheduling
@Slf4j
public class AsyncConfiguration {

   /**
    * 自定義執行緒池
    */
   @Bean(name = "myExecutor")
   public Executor getNetHospitalMsgAsyncExecutor() {
      log.info("Creating myExecutor Async Task Executor");
      ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
      executor.setCorePoolSize(10);
      executor.setMaxPoolSize(50);
      executor.setQueueCapacity(200);
      executor.setThreadNamePrefix("my-executor-");
      // 拒絕策略:直接拒絕丟擲異常
      executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.AbortPolicy());
      return executor;
   }
}

接下來,我們寫一個非同步服務,直接使用這個自定義執行緒池,並且模擬一個耗時5秒的發訊息業務。

package com.example.executor.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

/**
 * <p>
 * 非同步服務
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022/4/30 11:41
 */
@Service
@Slf4j
public class AsyncService {

   /**
    * 模擬耗時的發訊息業務
    */
   @Async("myExecutor")
   public void sendMsg() throws InterruptedException {
      log.info("[AsyncService][sendMsg]>>>> 發訊息....");
      TimeUnit.SECONDS.sleep(5);
   }
}

然後,我們寫一個TestService,使用Hutools自帶的併發工具來呼叫上面的發訊息服務,併發數設定為200,也就是同時開啟200個執行緒來執行業務。

package com.example.executor.service;

import cn.hutool.core.thread.ConcurrencyTester;
import cn.hutool.core.thread.ThreadUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
 * <p>
 * 測試服務
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022/4/30 11:45
 */
@Service
@Slf4j
public class TestService {

   private final AsyncService asyncService;

   public TestService(AsyncService asyncService) {
      this.asyncService = asyncService;
   }

   /**
    * 模擬併發
    */
   public void test() {
      ConcurrencyTester tester = ThreadUtil.concurrencyTest(200, () -> {
         // 測試的邏輯內容
         try {
            asyncService.sendMsg();
         } catch (InterruptedException e) {
            log.error("[TestService][test]>>>> 發生異常: ", e);
         }
      });

      // 獲取總的執行時間,單位毫秒
      log.info("總耗時:{}", tester.getInterval() + " ms");
   }
}

最後,寫一個測試介面。

package com.example.executor.controller;

import com.example.executor.service.TestService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * <p>
 * 測試介面
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022/4/30 11:43
 */
@RestController
@RequestMapping("/api")
public class TestController {

   private final TestService testService;

   public TestController(TestService testService) {
      this.testService = testService;
   }

   @GetMapping("/test")
   public ResponseEntity<Void> test() {
      testService.test();
      return ResponseEntity.ok().build();
   }
}

2、執行順序

案例寫完了,我們就要開始進行呼叫執行緒池的測試了,但在此之前,首先給大家講明白自定義執行緒池的配置在執行過程中到底是怎麼執行的,是個什麼順序,這個搞明白,後面調整引數就不會困惑了。

核心執行緒數(CorePoolSize) ---> (若全部被佔用) ---> 放入佇列(QueueCapacity) ---> (若全部被佔用) ---> 根據最大執行緒數(MaxPoolSize)建立新執行緒 ---> (若超過最大執行緒數) ---> 開始執行拒絕策略(RejectedExecutionHandler)

連看三遍,然後就會了。


3、核心執行緒數怎麼配

我們首先把程式跑起來,這裡把上面案例的重要線索再理一遍給大家聽。
1)、執行緒池核心執行緒數是10,最大執行緒數是50,佇列是200;
2)、發訊息業務是耗時5秒;
3)、併發工具執行執行緒數是200.

可以看到下圖,200個執行緒都執行完了,左邊的時間可以觀測到,每5秒會執行10個執行緒,我這邊的後臺執行可以明顯發現很慢才全部執行完200個執行緒。

由此可見,核心執行緒數先執行10個,剩下190個放到了佇列,而我們的佇列大小是200足夠,所以最大執行緒數沒起作用。

111.png

思考:怎麼提高200個執行緒的執行效率?答案已經很明顯了,因為我們的業務屬於耗時業務花費了5秒,核心執行緒數配置少了就會導致全部200個執行緒數執行完會很慢,那麼我們只需要增大核心執行緒數即可。

我們將核心執行緒數調到100

@Bean(name = "myExecutor")
   public Executor getNetHospitalMsgAsyncExecutor() {
      log.info("Creating myExecutor Async Task Executor");
      ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
      executor.setCorePoolSize(100);
      executor.setMaxPoolSize(50);
      executor.setQueueCapacity(200);
      executor.setThreadNamePrefix("my-executor-");
      // 拒絕策略:直接拒絕丟擲異常
      executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.AbortPolicy());
      // 拒絕策略:呼叫者執行緒執行
//    executor.setRejectedExecutionHandler(
//          new ThreadPoolExecutor.CallerRunsPolicy());
      return executor;
   }

看效果:咦?報錯了?

222.png

為什麼,看原始碼就知道了。

333.png

原來,執行緒池初始化時,內部有做判斷,最大執行緒數若小於核心執行緒數就會丟擲這個異常,所以我們設定時要特別注意,至少核心執行緒數要大於等於最大執行緒數。

我們修改下配置:核心執行緒數和最大執行緒數都設定為100.

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(100);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("my-executor-");

看效果:後臺執行過程中可以發現,執行速度非常快,至少和之前相比提升了10倍,200個執行緒一會兒就跑完了。

444.png

原因:我們設定的是耗時業務5秒,核心執行緒數只有10,那麼放入佇列等待的執行緒都會分批執行該耗時業務,每批次次5秒就會很慢,當我們把核心執行緒數調大後,相當於只執行了一兩個批次就完成了這5秒業務,速度自然成倍提升。

這裡我們就可以得出第一個結論:

如果你的業務是耗時業務,執行緒池配置中的核心執行緒數就要調大。

思考一下:

什麼樣的業務適合配置較小的核心執行緒數和較大的佇列?


4、最大執行緒數怎麼配

接下來,我們來看看最大執行緒數是怎麼回事,這個就有意思了,網上一大堆資料都是錯的。

還是之前的案例,為了更清晰,我們調整一下配置引數:核心執行緒數4個,最大執行緒數8個,佇列就1個。

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("my-executor-");

然後我們把併發測試的數量改為10個。

ConcurrencyTester tester = ThreadUtil.concurrencyTest(10, () -> {
   // 測試的邏輯內容
   try {
      asyncService.sendMsg();
   } catch (InterruptedException e) {
      log.error("[TestService][test]>>>> 發生異常: ", e);
   }
});

啟動,測試:

驚喜發現,咦?10個併發數,怎麼只有9個執行了,還有1個跑哪兒去啦?

555.png

我們把最大執行緒數改為7個再試試

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(7);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("my-executor-");

再看看,發現竟然只執行了8個,這下好了,竟然有2個都不翼而飛了……

666.png

為什麼呢,具體演示效果我會在下面的拒絕策略那裡一起演示出來,這裡我先直接告訴大家結論:

  最大執行緒數究竟線上程池中是什麼意思,沒錯,就是字面意思。當核心執行緒數滿了,佇列也滿了,剩下的執行緒走最大執行緒數建立的新執行緒執行任務,這個流程一開始給大家梳理過。

  但是聽好了,因為是最大執行緒數,所以執行執行緒怎麼樣都不會超過這個數字,超過就被拒絕策略拒絕了。

  現在我們再根據本節剛開始的配置引數來梳理一遍,10個併發數,4個佔用了核心執行緒數,1個進入佇列,最大執行緒數配置是8,在當前這2秒的業務時間內,活動執行緒一共是:

  核心執行緒數(4) + 新建立執行緒數(?) = 最大執行緒數(8)

  可見,因為最大執行緒數配置的是8,所以核心執行緒數和佇列都打滿之後,新建立的執行緒數只能是8-4=4個,因此最終執行的就是:

  核心執行緒數(4) + 新建立的執行緒數(4) + 佇列中的執行緒(1) = 9

  一點問題都沒有,剩下的一個超出最大執行緒數8所以被拒絕策略拒絕了。

最後,一張圖給你整的明明白白,注意看左邊的時間,就知道最後那個是佇列裡面2秒後執行的執行緒。

777.png

這裡,我們也可以得出第二個結論:

最大執行緒數就是字面意思,當前活動執行緒不能超過這個限制,超過了就會被拒絕策略給拒絕掉。


5、佇列大小怎麼配

前面兩個理解了,佇列大小其實一個簡單的測試就能明白。
我們修改下之前的執行緒池配置:

核心執行緒數50,最大執行緒數50,佇列100,業務耗時時間改為1秒方便測試.

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("my-executor-");

併發數設為200

ConcurrencyTester tester = ThreadUtil.concurrencyTest(200, () -> {
   // 測試的邏輯內容
   try {
      asyncService.sendMsg();
   } catch (InterruptedException e) {
      log.error("[TestService][test]>>>> 發生異常: ", e);
   }
});

測試下效果:可以看到,200個併發數,最終只執行了150個,具體演算法上一節最大執行緒數已經講過不再贅述了。

888.png

這裡我們主要明確一點,就是當前執行緒數超過佇列大小後,會走最大執行緒數去計算後建立新執行緒來執行業務,那麼我們不妨想一下,是不是把佇列設定大一點就可以了,這樣就不會再走最大執行緒數。

我們把佇列大小從100調成500看看

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("my-executor-");

測試效果:可以看到,200個都執行完了,說明我們的設想是正確的。

999.png

這裡可以得出第三個結論:

佇列大小設定合理,就不需要走最大執行緒數造成額外開銷,所以配置執行緒池的最佳方式是核心執行緒數搭配佇列大小。


6、拒絕策略怎麼配

前面最大執行緒數如何配置的小節中,經過測試可以發現,超過最大執行緒數後一部分執行緒直接被拒絕了,因為我們一開始有配置拒絕策略,這個策略是執行緒池預設策略,表示直接拒絕。

// 拒絕策略:直接拒絕丟擲異常
executor.setRejectedExecutionHandler(
      new ThreadPoolExecutor.AbortPolicy());

那麼我們怎麼知道這些執行緒確實是被拒絕了呢,這裡我們恢復最大執行緒數小節中的引數配置。

然後,把預設策略改為另一個策略:CallerRunsPolicy,表示拒絕後由呼叫者執行緒繼續執行。

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(7);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("my-executor-");
// 拒絕策略:呼叫者執行緒執行
executor.setRejectedExecutionHandler(
      new ThreadPoolExecutor.CallerRunsPolicy());
return executor;

併發數改為10個

ConcurrencyTester tester = ThreadUtil.concurrencyTest(10, () -> {
   // 測試的邏輯內容
   try {
      asyncService.sendMsg();
   } catch (InterruptedException e) {
      log.error("[TestService][test]>>>> 發生異常: ", e);
   }
});

測試效果:

  可以看到10個併發數都執行完了,而最大執行緒數小節中我們測試時是有2個執行緒被預設策略拒絕掉的,因為現在策略改成了由呼叫者執行緒繼續執行任務,所以那2個雖然被拒絕了但還是由呼叫者執行緒執行完了。

  可以看到圖中紅線的兩個執行緒,名稱和自定義執行緒的名稱是有明顯區別的,這就是呼叫者執行緒去執行了。

1010.png

  那麼,這種策略這麼人性化,一定是好的嗎?

  NO!這種策略反而不可控,如果是網際網路專案,線上上很容易出問題,道理很簡單。

  執行緒池佔用的不是主執行緒,是一種非同步操作來執行任務,這種策略實際上是把拒絕的執行緒重新交給了主執行緒去執行,等於把非同步改為了同步,你試想一下,在高峰流量階段,如果大量非同步執行緒因為這個策略走了主執行緒是什麼後果,很可能導致你主執行緒的程式崩潰,繼而形成服務雪崩。

展示一下執行緒池提供的4種策略:

1)、AbortPolicy:預設策略,直接拒絕並會丟擲一個RejectedExecutionException異常;

2)、CallerRunsPolicy:呼叫者執行緒繼續執行任務,一種簡單的反饋機制策略;

3)、DiscardPolicy:直接拋棄任務,沒有任何通知及反饋;

4)、DiscardOldestPolicy:拋棄一個老任務,通常是存活時間最長的那個。

不少人認為CallerRunsPolicy策略是最完善的,但我個人的觀點,實際上生產環境中風險最低的還是預設策略,我們線上的專案傾向於優先保證安全。


講到這裡,結合案例基本上大家能明白這幾個執行緒池引數的含義,那麼還記得前面我發出的一個思考題嗎,不記得了,因為大家都是魚的記憶,思考題是:

什麼樣的業務適合配置較小的核心執行緒數和較大的佇列?

  答案:低耗時、高併發的場景非常適合,因為低耗時都屬於毫秒級業務,這種業務走CPU和記憶體會更合適,高併發時需要佇列緩衝,同時因為低耗時又不會在佇列中長時間等待,核心執行緒數較大會一次性增加CPU過大的開銷,所以配置較小的核心執行緒數和較大的佇列就很適合這種場景。

  題外話,用過雲產品的就知道,你選購雲伺服器時,總會讓你選什麼CPU密集型和IO密集型之類的款型,如果你對執行緒池比較瞭解,就能知道什麼意思,不同的專案需要搭配的伺服器款型實際上是有考量的,上面的場景就顯然要選CPU密集型的伺服器,而本章前面的案例場景是高耗時的就適合IO密集型的伺服器。


三、總結

這裡面除了針對本章總結,還額外增加了幾點,來源於我的工作經驗。

1)、如果你的業務是耗時業務,執行緒池配置中的核心執行緒數就要調大,佇列就要適當調小;

2)、如果你的業務是低耗時業務(毫秒級),同時流量較大,執行緒池配置中的核心執行緒數就要調小,佇列就要適當調大;

3)、最大執行緒數就是字面意思,當前活動執行緒不能超過這個限制,超過了就會被拒絕策略給拒絕掉;

4)、佇列大小設定合理,就不需要走最大執行緒數造成額外開銷,所以配置執行緒池的最佳方式是核心執行緒數搭配佇列大小;

5)、執行緒池拒絕策略儘量以預設為主,降低生產環境風險,非必要不改變;

6)、同一個伺服器中部署的專案或微服務,全部加起來的執行緒池數量最好不要超過5個,否則生死有命富貴在天;

7)、執行緒池不要亂用,分清楚業務場景,儘量在可以延遲且不是特別重要的場景下使用,比如我這裡的發訊息,或者發訂閱通知,或者做某個場景的日誌記錄等等,千萬別在核心業務中輕易使用執行緒池;

8)、執行緒池不要混用,特定業務記得隔離,也就是自定義各自的執行緒池,不同的名稱不同的引數,你可以試想一下你隨手寫了一個執行緒池,配置了自己那塊業務合適的引數,結果被另一個同事拿去在併發量大的業務中使用了,到時候只能有難同當了;

9)、執行緒池配置不是請客吃飯,哪怕你很熟悉,請在上線前依然做一下壓測,這是本人慘痛的教訓;

10)、請一定要明確執行緒池的應用場景,切勿和高併發處理方案混淆在一起,這倆業務上針對的方向完全不同。


四、分享

  最後,我再分享給大家一個我之前工作中使用過的公式,僅針對中小企業特定業務當前執行緒數千級以上的場景,畢竟哥沒呆過大廠,能分享的經驗有限,貴在真實可用。
  以我公司為例,我們屬於中小型網際網路公司,用的華為雲,線上伺服器基本都是8核,我平常對於特定業務使用執行緒池都是以當前執行緒數2000來測試的,因為同一時間2000個併發執行緒在中小企業沒大家想的那麼容易出現。我公司服務於醫院,一年也遇不到幾次,除了這兩年由於疫情做核酸數量激增的時候。
  你自己可以試想一下,2000個執行緒同時處理某個業務,得有多少使用者量,得是什麼樣的場景才會出現,關鍵你用的是執行緒池,你為什麼會在這種場景使用執行緒池本身也是要反思的事情,有些類似的場景都是通過快取及MQ來削峰的,這也是我總結中講的不要和高併發處理方案混淆在一起的原因,你應該把執行緒池用在需要延遲處理又不太重要的業務中最合適。

我總結的公式可以從這裡獲取:
連結: https://pan.baidu.com/doc/share/TES95Wnsy3ztUp_Al1L~LQ-567189327526315
提取碼: 2jjy



本人原創文章純手打,覺得有一滴滴用處的話就請點個推薦吧。

不定期分享實際工作中的經驗和趣事,感興趣的話就請關注一下吧~


相關文章