多線程到底該設(shè)置多少個(gè)線程?
我們?cè)谑褂镁€程池的時(shí)候,會(huì)有兩個(gè)疑問點(diǎn):
- 線程池的線程數(shù)量設(shè)置過多會(huì)導(dǎo)致線程競(jìng)爭(zhēng)激烈
- 如果線程數(shù)量設(shè)置過少的話,還會(huì)導(dǎo)致系統(tǒng)無法充分利用計(jì)算機(jī)資源
那么如何設(shè)置才不會(huì)影響系統(tǒng)性能呢?
其實(shí)線程池的設(shè)置是有方法的,不是憑借簡(jiǎn)單的估算來決定的。今天我們就來看看究竟有哪些計(jì)算方法可以復(fù)用,線程池中各個(gè)參數(shù)之間又存在怎樣的關(guān)系呢? 本文咱們來慢慢聊。
線程池原理
開始優(yōu)化之前,我們先來看看線程池的實(shí)現(xiàn)原理,有助于你更好地理解后面的內(nèi)容。
在 HotSpot VM 的線程模型中,Java 線程被一對(duì)一映射為內(nèi)核線程。Java 在使用線程執(zhí)行程序時(shí),需要?jiǎng)?chuàng)建一個(gè)內(nèi)核線程;當(dāng)該 Java 線程被終止時(shí),這個(gè)內(nèi)核線程也會(huì)被回收。因此 Java 線程的創(chuàng)建與銷毀將會(huì)消耗一定的計(jì)算機(jī)資源,從而增加系統(tǒng)的性能開銷。
除此之外,大量創(chuàng)建線程同樣會(huì)給系統(tǒng)帶來性能問題,因?yàn)閮?nèi)存和 CPU 資源都將被線程搶占,如果處理不當(dāng),就會(huì)發(fā)生內(nèi)存溢出、CPU 使用率超負(fù)荷等問題。
為了解決上述兩類問題,Java 提供了線程池概念,對(duì)于頻繁創(chuàng)建線程的業(yè)務(wù)場(chǎng)景,線程池可以創(chuàng)建固定的線程數(shù)量,并且在操作系統(tǒng)底層,輕量級(jí)進(jìn)程將會(huì)把這些線程映射到內(nèi)核。
線程池可以提高線程復(fù)用,又可以固定最大線程使用量,防止無限制地創(chuàng)建線程。
當(dāng)程序提交一個(gè)任務(wù)需要一個(gè)線程時(shí),會(huì)去線程池中查找是否有空閑的線程,若有,則直接使用線程池中的線程工作,若沒有,會(huì)去判斷當(dāng)前已創(chuàng)建的線程數(shù)量是否超過最大線程數(shù)量,如未超過,則創(chuàng)建新線程,如已超過,則進(jìn)行排隊(duì)等待或者直接拋出異常。
線程池框架 Executor
Java 最開始提供了 ThreadPool 實(shí)現(xiàn)了線程池,為了更好地實(shí)現(xiàn)用戶級(jí)的線程調(diào)度,更有效地幫助開發(fā)人員進(jìn)行多線程開發(fā),Java 提供了一套 Executor 框架。
這個(gè)框架中包括了 ScheduledThreadPoolExecutor 和 ThreadPoolExecutor 兩個(gè)核心線程池。前者是用來定時(shí)執(zhí)行任務(wù),后者是用來執(zhí)行被提交的任務(wù)。
鑒于這兩個(gè)線程池的核心原理是一樣的,下面我們就重點(diǎn)看看 ThreadPoolExecutor 類是如何實(shí)現(xiàn)線程池的。
Executors 實(shí)現(xiàn)了以下四種類型的 ThreadPoolExecutor:
Executors 利用工廠模式實(shí)現(xiàn)的四種線程池,我們?cè)谑褂玫臅r(shí)候需要結(jié)合生產(chǎn)環(huán)境下的實(shí)際場(chǎng)景。
不過我不太推薦使用它們,因?yàn)檫x擇使用 Executors 提供的工廠類,將會(huì)忽略很多線程池的參數(shù)設(shè)置,工廠類一旦選擇設(shè)置默認(rèn)參數(shù),就很容易導(dǎo)致無法調(diào)優(yōu)參數(shù)設(shè)置,從而產(chǎn)生性能問題或者資源浪費(fèi)。
我建議你使用 ThreadPoolExecutor 自我定制一套線程池(阿里規(guī)范中也是建議不要使用Executors 創(chuàng)建線程池,建議使用ThreadPoolExecutor 來創(chuàng)建線程池)。
進(jìn)入四種工廠類后,我們可以發(fā)現(xiàn)除了 newScheduledThreadPool 類,其它類均使用了 ThreadPoolExecutor 類進(jìn)行實(shí)現(xiàn),
你可以通過以下代碼簡(jiǎn)單看下該方法
corePoolSize:線程池的核心線程數(shù)量
maximumPoolSize:線程池的最大線程數(shù)
keepAliveTime:當(dāng)線程數(shù)大于核心線程數(shù)時(shí),多余的空閑線程存活的最長(zhǎng)時(shí)間
unit:時(shí)間單位
workQueue:任務(wù)隊(duì)列,用來儲(chǔ)存等待執(zhí)行任務(wù)的隊(duì)列
threadFactory:線程工廠,用來創(chuàng)建線程,一般默認(rèn)即可
handler:拒絕策略,當(dāng)提交的任務(wù)過多而不能及時(shí)處理時(shí),我們可以定制策略來處理任務(wù)
我們還可以通過下面這張圖來了解下線程池中各個(gè)參數(shù)的相互關(guān)系:
通過上圖,我們發(fā)現(xiàn)線程池有兩個(gè)線程數(shù)的設(shè)置,一個(gè)為核心線程數(shù),一個(gè)為最大線程數(shù)。在創(chuàng)建完線程池之后,默認(rèn)情況下,線程池中并沒有任何線程,等到有任務(wù)來才創(chuàng)建線程去執(zhí)行任務(wù)。
但有一種情況排除在外,就是調(diào)用 prestartAllCoreThreads() 或者 prestartCoreThread() 方法的話,可以提前創(chuàng)建等于核心線程數(shù)的線程數(shù)量,這種方式被稱為預(yù)熱,在搶購系統(tǒng)中就經(jīng)常被用到。
當(dāng)創(chuàng)建的線程數(shù)等于 corePoolSize 時(shí),提交的任務(wù)會(huì)被加入到設(shè)置的阻塞隊(duì)列中。當(dāng)隊(duì)列滿了,會(huì)創(chuàng)建線程執(zhí)行任務(wù),直到線程池中的數(shù)量等于 maximumPoolSize。
當(dāng)線程數(shù)量已經(jīng)等于 maximumPoolSize 時(shí), 新提交的任務(wù)無法加入到等待隊(duì)列,也無法創(chuàng)建非核心線程直接執(zhí)行,我們又沒有為線程池設(shè)置拒絕策略,這時(shí)線程池就會(huì)拋出 RejectedExecutionException 異常,即線程池拒絕接受這個(gè)任務(wù)。
當(dāng)線程池中創(chuàng)建的線程數(shù)量超過設(shè)置的 corePoolSize,在某些線程處理完任務(wù)后,如果等待 keepAliveTime 時(shí)間后仍然沒有新的任務(wù)分配給它,那么這個(gè)線程將會(huì)被回收。線程池回收線程時(shí),會(huì)對(duì)所謂的“核心線程”和“非核心線程”一視同仁,直到線程池中線程的數(shù)量等于設(shè)置的 corePoolSize 參數(shù),回收過程才會(huì)停止。
即使是 corePoolSize 線程,在一些非核心業(yè)務(wù)的線程池中,如果長(zhǎng)時(shí)間地占用線程數(shù)量,也可能會(huì)影響到核心業(yè)務(wù)的線程池,這個(gè)時(shí)候就需要把沒有分配任務(wù)的線程回收掉。
我們可以通過 allowCoreThreadTimeOut 設(shè)置項(xiàng)要求線程池:將包括“核心線程”在內(nèi)的,沒有任務(wù)分配的所有線程,在等待 keepAliveTime 時(shí)間后全部回收掉。
我們可以通過下面這張圖來了解下線程池的線程分配流程:
計(jì)算線程數(shù)量
了解完線程池的實(shí)現(xiàn)原理和框架,我們就可以動(dòng)手實(shí)踐優(yōu)化線程池的設(shè)置了。
我們知道,環(huán)境具有多變性,設(shè)置一個(gè)絕對(duì)精準(zhǔn)的線程數(shù)其實(shí)是不大可能的,但我們可以通過一些實(shí)際操作因素來計(jì)算出一個(gè)合理的線程數(shù),避免由于線程池設(shè)置不合理而導(dǎo)致的性能問題。下面我們就來看看具體的計(jì)算方法。
一般多線程執(zhí)行的任務(wù)類型可以分為 CPU 密集型和 I/O 密集型,根據(jù)不同的任務(wù)類型,我們計(jì)算線程數(shù)的方法也不一樣。
CPU 密集型任務(wù)
這種任務(wù)消耗的主要是 CPU 資源,可以將線程數(shù)設(shè)置為 N(CPU 核心數(shù))+1,比 CPU 核心數(shù)多出來的一個(gè)線程是為了防止線程偶發(fā)的缺頁中斷,或者其它原因?qū)е碌娜蝿?wù)暫停而帶來的影響。
一旦任務(wù)暫停,CPU 就會(huì)處于空閑狀態(tài),而在這種情況下多出來的一個(gè)線程就可以充分利用 CPU 的空閑時(shí)間。
下面我們用一個(gè)例子來驗(yàn)證下這個(gè)方法的可行性,通過觀察 CPU 密集型任務(wù)在不同線程數(shù)下的性能情況就可以得出結(jié)果,你可以點(diǎn)擊Github下載到本地運(yùn)行測(cè)試:
- public class CPUTypeTest implements Runnable {
- // 整體執(zhí)行時(shí)間,包括在隊(duì)列中等待的時(shí)間
- List<Long> wholeTimeList;
- // 真正執(zhí)行時(shí)間
- List<Long> runTimeList;
- private long initStartTime = 0;
- /**
- * 構(gòu)造函數(shù)
- * @param runTimeList
- * @param wholeTimeList
- */
- public CPUTypeTest(List<Long> runTimeList, List<Long> wholeTimeList) {
- initStartTime = System.currentTimeMillis();
- this.runTimeList = runTimeList;
- this.wholeTimeList = wholeTimeList;
- }
- /**
- * 判斷素?cái)?shù)
- * @param number
- * @return
- */
- public boolean isPrime(final int number) {
- if (number <= 1)
- return false;
- for (int i = 2; i <= Math.sqrt(number); i++) {
- if (number % i == 0)
- return false;
- }
- return true;
- }
- /**
- * 計(jì)算素?cái)?shù)
- * @param number
- * @return
- */
- public int countPrimes(final int lower, final int upper) {
- int total = 0;
- for (int i = lower; i <= upper; i++) {
- if (isPrime(i))
- total++;
- }
- return total;
- }
- public void run() {
- long start = System.currentTimeMillis();
- countPrimes(1, 1000000);
- long end = System.currentTimeMillis();
- long wholeTime = end - initStartTime;
- long runTime = end - start;
- wholeTimeList.add(wholeTime);
- runTimeList.add(runTime);
- System.out.println(" 單個(gè)線程花費(fèi)時(shí)間:" + (end - start));
- }
- }
測(cè)試代碼在 4 核 intel i5 CPU 機(jī)器上的運(yùn)行時(shí)間變化如下:
綜上可知:當(dāng)線程數(shù)量太小,同一時(shí)間大量請(qǐng)求將被阻塞在線程隊(duì)列中排隊(duì)等待執(zhí)行線程,此時(shí) CPU 沒有得到充分利用;當(dāng)線程數(shù)量太大,被創(chuàng)建的執(zhí)行線程同時(shí)在爭(zhēng)取 CPU 資源,又會(huì)導(dǎo)致大量的上下文切換,從而增加線程的執(zhí)行時(shí)間,影響了整體執(zhí)行效率。通過測(cè)試可知,4~6 個(gè)線程數(shù)是最合適的。
I/O 密集型任務(wù)
這種任務(wù)應(yīng)用起來,系統(tǒng)會(huì)用大部分的時(shí)間來處理 I/O 交互,而線程在處理 I/O 的時(shí)間段內(nèi)不會(huì)占用 CPU 來處理,這時(shí)就可以將 CPU 交出給其它線程使用。因此在 I/O 密集型任務(wù)的應(yīng)用中,我們可以多配置一些線程,具體的計(jì)算方法是 2N。
這里我們還是通過一個(gè)例子來驗(yàn)證下這個(gè)公式是否可以標(biāo)準(zhǔn)化:
- public class IOTypeTest implements Runnable {
- // 整體執(zhí)行時(shí)間,包括在隊(duì)列中等待的時(shí)間
- Vector<Long> wholeTimeList;
- // 真正執(zhí)行時(shí)間
- Vector<Long> runTimeList;
- private long initStartTime = 0;
- /**
- * 構(gòu)造函數(shù)
- * @param runTimeList
- * @param wholeTimeList
- */
- public IOTypeTest(Vector<Long> runTimeList, Vector<Long> wholeTimeList) {
- initStartTime = System.currentTimeMillis();
- this.runTimeList = runTimeList;
- this.wholeTimeList = wholeTimeList;
- }
- /**
- *IO 操作
- * @param number
- * @return
- * @throws IOException
- */
- public void readAndWrite() throws IOException {
- File sourceFile = new File("D:/test.txt");
- // 創(chuàng)建輸入流
- BufferedReader input = new BufferedReader(new FileReader(sourceFile));
- // 讀取源文件, 寫入到新的文件
- String line = null;
- while((line = input.readLine()) != null){
- //System.out.println(line);
- }
- // 關(guān)閉輸入輸出流
- input.close();
- }
- public void run() {
- long start = System.currentTimeMillis();
- try {
- readAndWrite();
- } catch (IOException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
- long end = System.currentTimeMillis();
- long wholeTime = end - initStartTime;
- long runTime = end - start;
- wholeTimeList.add(wholeTime);
- runTimeList.add(runTime);
- System.out.println(" 單個(gè)線程花費(fèi)時(shí)間:" + (end - start));
- }
- }
備注:由于測(cè)試代碼讀取 2MB 大小的文件,涉及到大內(nèi)存,所以在運(yùn)行之前,我們需要調(diào)整 JVM 的堆內(nèi)存空間:-Xms4g -Xmx4g,避免發(fā)生頻繁的 FullGC,影響測(cè)試結(jié)果。
通過測(cè)試結(jié)果,我們可以看到每個(gè)線程所花費(fèi)的時(shí)間。當(dāng)線程數(shù)量在 8 時(shí),線程平均執(zhí)行時(shí)間是最佳的,這個(gè)線程數(shù)量和我們的計(jì)算公式所得的結(jié)果就差不多。
看完以上兩種情況下的線程計(jì)算方法,你可能還想說,在平常的應(yīng)用場(chǎng)景中,我們常常遇不到這兩種極端情況,那么碰上一些常規(guī)的業(yè)務(wù)操作,比如,通過一個(gè)線程池實(shí)現(xiàn)向用戶定時(shí)推送消息的業(yè)務(wù),我們又該如何設(shè)置線程池的數(shù)量呢?
此時(shí)我們可以參考以下公式來計(jì)算線程數(shù):
WT:線程等待時(shí)間
ST:線程時(shí)間運(yùn)行時(shí)間
我們可以通過 JDK 自帶的工具 VisualVM 來查看 WT/ST 比例,以下例子是基于運(yùn)行純 CPU 運(yùn)算的例子,我們可以看到:
- WT(線程等待時(shí)間)= 36788ms [線程運(yùn)行總時(shí)間] - 36788ms[ST(線程時(shí)間運(yùn)行時(shí)間)]= 0
- 線程數(shù) =N(CPU 核數(shù))*(1+ 0 [WT(線程等待時(shí)間)]/36788ms[ST(線程時(shí)間運(yùn)行時(shí)間)])= N(CPU 核數(shù))
這跟我們之前通過 CPU 密集型的計(jì)算公式 N+1 所得出的結(jié)果差不多。
綜合來看,我們可以根據(jù)自己的業(yè)務(wù)場(chǎng)景,從“N+1”和“2N”兩個(gè)公式中選出一個(gè)適合的,計(jì)算出一個(gè)大概的線程數(shù)量,之后通過實(shí)際壓測(cè),逐漸往“增大線程數(shù)量”和“減小線程數(shù)量”這兩個(gè)方向調(diào)整,然后觀察整體的處理時(shí)間變化,最終確定一個(gè)具體的線程數(shù)量。
總結(jié)
本文我們主要學(xué)習(xí)了線程池的實(shí)現(xiàn)原理,Java 線程的創(chuàng)建和消耗會(huì)給系統(tǒng)帶來性能開銷,因此 Java 提供了線程池來復(fù)用線程,提高程序的并發(fā)效率。
Java 通過用戶線程與內(nèi)核線程結(jié)合的 1:1 線程模型來實(shí)現(xiàn),Java 將線程的調(diào)度和管理設(shè)置在了用戶態(tài),提供了一套 Executor 框架來幫助開發(fā)人員提高效率。Executor 框架不僅包括了線程池的管理,還提供了線程工廠、隊(duì)列以及拒絕策略等,可以說 Executor 框架為并發(fā)編程提供了一個(gè)完善的架構(gòu)體系。
在不同的業(yè)務(wù)場(chǎng)景以及不同配置的部署機(jī)器中,線程池的線程數(shù)量設(shè)置是不一樣的。
其設(shè)置不宜過大,也不宜過小,要根據(jù)具體情況,計(jì)算出一個(gè)大概的數(shù)值,再通過實(shí)際的性能測(cè)試,計(jì)算出一個(gè)合理的線程數(shù)量。
我們要提高線程池的處理能力,一定要先保證一個(gè)合理的線程數(shù)量,也就是保證 CPU 處理線程的最大化。在此前提下,我們?cè)僭龃缶€程池隊(duì)列,通過隊(duì)列將來不及處理的線程緩存起來。在設(shè)置緩存隊(duì)列時(shí),我們要盡量使用一個(gè)有界隊(duì)列,以防因隊(duì)列過大而導(dǎo)致的內(nèi)存溢出問題