【Java分享客棧】SpringBoot線程池參數搜一堆資料還是不會配,我花一天測試換你此生明白。
一、前言
首先說一句,如果比較忙順路點進來的,可以先收藏,有時間或用到了再看也行;
我相信很多人會有一個困惑,這個困惑和我之前一樣,就是線程池這個玩意兒,感覺很高大上,用起來很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足夠,所以最大線程數沒起作用。
思考:怎麼提高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;
}
看效果:咦?報錯了?
為什麼,看源碼就知道了。
原來,線程池初始化時,內部有做判斷,最大線程數若小於核心線程數就會拋出這個異常,所以我們設置時要特別注意,至少核心線程數要大於等於最大線程數。
我們修改下配置:核心線程數和最大線程數都設置為100.
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(100);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("my-executor-");
看效果:後台運行過程中可以發現,運行速度非常快,至少和之前相比提升了10倍,200個線程一會兒就跑完了。
原因:我們設定的是耗時業務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個跑哪兒去啦?
我們把最大線程數改為7個再試試
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(7);
executor.setQueueCapacity(1);
executor.setThreadNamePrefix("my-executor-");
再看看,發現竟然只執行了8個,這下好了,竟然有2個都不翼而飛了……
為什麼呢,具體演示效果我會在下面的拒絕策略那裡一起演示出來,這裡我先直接告訴大家結論:
最大線程數究竟在線程池中是什麼意思,沒錯,就是字面意思。當核心線程數滿了,隊列也滿了,剩下的線程走最大線程數創建的新線程執行任務,這個流程一開始給大家梳理過。
但是聽好了,因為是最大線程數,所以執行線程怎麼樣都不會超過這個數字,超過就被拒絕策略拒絕了。
現在我們再根據本節剛開始的配置參數來梳理一遍,10個並發數,4個佔用了核心線程數,1個進入隊列,最大線程數配置是8,在當前這2秒的業務時間內,活動線程一共是:
核心線程數(4) + 新創建線程數(?) = 最大線程數(8)
可見,因為最大線程數配置的是8,所以核心線程數和隊列都打滿之後,新創建的線程數只能是8-4=4個,因此最終執行的就是:
核心線程數(4) + 新創建的線程數(4) + 隊列中的線程(1) = 9
一點問題都沒有,剩下的一個超出最大線程數8所以被拒絕策略拒絕了。
最後,一張圖給你整的明明白白,注意看左邊的時間,就知道最後那個是隊列裏面2秒後執行的線程。
這裡,我們也可以得出第二個結論:
最大線程數就是字面意思,當前活動線程不能超過這個限制,超過了就會被拒絕策略給拒絕掉。
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個,具體算法上一節最大線程數已經講過不再贅述了。
這裡我們主要明確一點,就是當前線程數超過隊列大小後,會走最大線程數去計算後創建新線程來執行業務,那麼我們不妨想一下,是不是把隊列設置大一點就可以了,這樣就不會再走最大線程數。
我們把隊列大小從100調成500看看
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("my-executor-");
測試效果:可以看到,200個都執行完了,說明我們的設想是正確的。
這裡可以得出第三個結論:
隊列大小設置合理,就不需要走最大線程數造成額外開銷,所以配置線程池的最佳方式是核心線程數搭配隊列大小。
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個雖然被拒絕了但還是由調用者線程執行完了。
可以看到圖中紅線的兩個線程,名稱和自定義線程的名稱是有明顯區別的,這就是調用者線程去執行了。
那麼,這種策略這麼人性化,一定是好的嗎?
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來削峰的,這也是我總結中講的不要和高並發處理方案混淆在一起的原因,你應該把線程池用在需要延遲處理又不太重要的業務中最合適。我總結的公式可以從這裡獲取:
鏈接: //pan.baidu.com/doc/share/TES95Wnsy3ztUp_Al1L~LQ-567189327526315
提取碼: 2jjy
本人原創文章純手打,覺得有一滴滴用處的話就請點個推薦吧。
不定期分享實際工作中的經驗和趣事,感興趣的話就請關注一下吧~