《性能優(yōu)化》并發(fā)與并行
前言
性能優(yōu)化系列第一篇主要給大家科普了一些性能相關(guān)的數(shù)字,為大家建立性能的初步概念。第二篇給大家介紹了支撐淘寶雙十一這種達(dá)到百萬(wàn)QPS項(xiàng)目所需的相關(guān)核心技術(shù)。
本文帶來(lái)的是性能優(yōu)化中的第一利器:并發(fā)與并行。
除了核心原理介紹外,我將結(jié)合我自身的過(guò)去的實(shí)戰(zhàn)經(jīng)驗(yàn),給出一些自己在使用上的建議,希望對(duì)大家有幫助。
不多廢話,直接開懟。
正文
1、并發(fā)和并行?
并發(fā)和并行最關(guān)鍵的區(qū)別是:并行是同時(shí)執(zhí)行,而并發(fā)不是同時(shí)。
這邊使用 Joe Armstrong 排隊(duì)使用咖啡機(jī)的例子來(lái)看并行和并發(fā)的區(qū)別,如下圖所示:

上半部分為并發(fā):兩個(gè)隊(duì)伍交替使用咖啡機(jī)
下半部分為并行:兩個(gè)隊(duì)伍同時(shí)使用咖啡機(jī)
從和我們更相關(guān)的CPU的角度來(lái)看兩者的區(qū)別。
并發(fā)是這樣的:同一時(shí)刻只有一個(gè)任務(wù)執(zhí)行。

并行是這樣的:同一時(shí)刻有多個(gè)任務(wù)執(zhí)行。

并發(fā)和并行結(jié)合起來(lái)是這樣的:

2、并發(fā)一定能提升性能嗎?
并行能提升性能大家不會(huì)有太多的疑問(wèn),但是并發(fā)是否一定能提升性能,估計(jì)還是有不少同學(xué)會(huì)有疑問(wèn)。
答案是否定的,并發(fā)不一定能提升性能,但是在絕大多數(shù)場(chǎng)景都能提升性能。
什么場(chǎng)景下并發(fā)不能提升性能?
我們舉個(gè)簡(jiǎn)單的例子:假設(shè)我們的服務(wù)器配置為單核CPU,要執(zhí)行10個(gè)任務(wù),10個(gè)任務(wù)都是CPU計(jì)算密集型任務(wù),此時(shí)單線程執(zhí)行效率理論上要比開10個(gè)線程執(zhí)行要快。
在執(zhí)行的整個(gè)過(guò)程中,基本都是CPU在運(yùn)行,但是開十個(gè)線程會(huì)涉及到線程上下文切換,需要花費(fèi)一些時(shí)間,導(dǎo)致反而更慢。
再舉個(gè)更形象的例子:囧輝上語(yǔ)文開小差,被老師罰抄10篇課文,此時(shí)囧輝腦子里想到了兩種方法。
方法1:先抄完第一篇,再抄第二篇,再抄第三篇,直到抄完第十篇。
方法2:先抄第一篇的第一段,再抄第二篇的第一段,...,再抄第十篇的第一段,再抄第一篇的第二段,直到抄完全部。
方法1為串行執(zhí)行,方法2為并發(fā)執(zhí)行,相信大家都能很容易看出方法二反而會(huì)更慢,因?yàn)槲覀冊(cè)趶那袚Q不同文章時(shí),需要先放好原來(lái)的文章,然后找新文章抄到哪個(gè)位置了,這個(gè)過(guò)程需要花費(fèi)一些時(shí)間,這個(gè)過(guò)程就類似于線程上下文切換。
那什么場(chǎng)景下并發(fā)會(huì)提升性能了?
再舉個(gè)例子:囧輝要燒10壺水,一壺水燒開的時(shí)間為1分鐘。
串行執(zhí)行:囧輝先燒第一壺,第一壺?zé)_了后接著燒第二壺,直到燒完第十壺,這個(gè)方法燒完十壺水大概需要10分鐘。
并發(fā)執(zhí)行:囧輝先燒第一壺,沒(méi)等第一壺?zé)_,接著燒第二壺,就這樣,囧輝一下子將十壺水都放到灶臺(tái)上同時(shí)燒,這個(gè)方法燒完十壺水大概需要1分鐘。
在這個(gè)場(chǎng)景里,并發(fā)執(zhí)行就體現(xiàn)了很大的優(yōu)化,性能提升了接近10倍。
在我們實(shí)際項(xiàng)目中,大部分應(yīng)用場(chǎng)景都是第二類,因此并發(fā)大多時(shí)候能提升性能,而哪些動(dòng)作是“燒開水”了,這個(gè)其實(shí)在性能優(yōu)化第一篇里提到了,最常見(jiàn)的“燒開水”操作就是I/O操作,最常見(jiàn)的如:調(diào)用其他服務(wù)的RPC接口查詢數(shù)據(jù)、查詢MySQL數(shù)據(jù)庫(kù)獲取數(shù)據(jù)等等。
3、實(shí)現(xiàn)方式
并發(fā)/并行的實(shí)現(xiàn)方式通常有兩種,如下。
1)開線程直接懟,每循環(huán)一次都會(huì)新建一個(gè)線程來(lái)執(zhí)行,例如下面代碼,
public static void test() throws InterruptedException {CountDownLatch countDownLatch = new CountDownLatch(10);for (int i = 0; i < 10; i++) {new Thread(() -> {// 燒水boilingWater();countDownLatch.countDown();}).start();}// 等待處理結(jié)束countDownLatch.await();}
2)使用線程池,例如下面代碼。
public static void test() {List<Future> futureList = new ArrayList<>();for (int i = 0; i < 10; i++) {futureList.add(THREAD_POOL_EXECUTOR.submit(() -> {// 燒開水boilingWater();}));}for (Future future : futureList) {try {// 等待處理結(jié)束future.get();} catch (Exception e) {e.printStackTrace();}}}
1是反例,實(shí)際項(xiàng)目中不要使用,就算只開1個(gè)線程,也要用線程池,因?yàn)槊看蝿?chuàng)建和回收線程都是需要開銷的。
下面用一個(gè)簡(jiǎn)單的demo來(lái)模擬“燒開水”的例子
public class BoilingWaterTest {/*** CPU的核數(shù)*/private static final int NCPUS = Runtime.getRuntime().availableProcessors();/*** 創(chuàng)建線程池*/private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(NCPUS,NCPUS * 2,30,TimeUnit.MINUTES,new LinkedBlockingDeque<>(1000),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());public static void main(String[] args) throws Exception {serial();concurrent();}public static void serial() {// 串行執(zhí)行long start = System.currentTimeMillis();for (int i = 0; i < 10; i++) {boilingWater();}System.out.println("serial cost:" + (System.currentTimeMillis() - start));}public static void concurrent() throws InterruptedException {// 并發(fā)執(zhí)行CountDownLatch countDownLatch = new CountDownLatch(10);long start = System.currentTimeMillis();for (int i = 0; i < 10; i++) {THREAD_POOL_EXECUTOR.execute(() -> {boilingWater();countDownLatch.countDown();});}// 等待任務(wù)全部執(zhí)行完畢countDownLatch.await();System.out.println("concurrent cost:" + (System.currentTimeMillis() - start));}public static void boilingWater() {try {// 燒開一壺水需要1秒Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
執(zhí)行該方法輸出如下,符合我們的預(yù)期。
serial cost:10091concurrent cost:1048
此時(shí)并發(fā)執(zhí)行的流程就如下圖,從一個(gè)task拆出多個(gè)task,然后由每個(gè)CPU負(fù)責(zé)處理1個(gè),因此處理時(shí)間接近于1個(gè)任務(wù)的處理時(shí)間。

4、線程池的參數(shù)設(shè)置
1)線程數(shù)
之前的線程池面試文章里有介紹過(guò)線程數(shù)的設(shè)置,這邊直接復(fù)制過(guò)來(lái):
要想合理的配置線程池大小,首先我們需要區(qū)分任務(wù)是計(jì)算密集型還是I/O密集型。
對(duì)于計(jì)算密集型,設(shè)置 線程數(shù) = CPU數(shù) + 1,通常能實(shí)現(xiàn)最優(yōu)的利用率。
對(duì)于I/O密集型,網(wǎng)上常見(jiàn)的說(shuō)法是設(shè)置 線程數(shù) = CPU數(shù) * 2 ,這個(gè)做法是可以的,但個(gè)人覺(jué)得不是最優(yōu)的。
在我們?nèi)粘5拈_發(fā)中,我們的任務(wù)幾乎是離不開I/O的,常見(jiàn)的網(wǎng)絡(luò)I/O(RPC調(diào)用)、磁盤I/O(數(shù)據(jù)庫(kù)操作),并且I/O的等待時(shí)間通常會(huì)占整個(gè)任務(wù)處理時(shí)間的很大一部分,在這種情況下,開啟更多的線程可以讓 CPU 得到更充分的使用,一個(gè)較合理的計(jì)算公式如下:
線程數(shù) = CPU數(shù) * CPU利用率 * (任務(wù)等待時(shí)間 / 任務(wù)計(jì)算時(shí)間 + 1)
例如我們有個(gè)定時(shí)任務(wù),部署在4核的服務(wù)器上,該任務(wù)有100ms在計(jì)算,900ms在I/O等待,則線程數(shù)約為:4 * 1 * (1 + 900 / 100) = 40個(gè)。
當(dāng)然,具體我們還要結(jié)合實(shí)際的使用場(chǎng)景來(lái)考慮。如果要求比較精確,可以通過(guò)壓測(cè)來(lái)獲取一個(gè)合理的值。
上述是比較理想的線程數(shù)計(jì)算方式,在實(shí)際項(xiàng)目使用中,如果無(wú)法很準(zhǔn)確的計(jì)算,那么可以先用我上面的線程池配置,也就是:
corePoolSize = CPU核數(shù)
maximumPoolSize = CPU核數(shù) * 2
這個(gè)參數(shù)設(shè)置可能不是最理想的,但在大多數(shù)情況下都是一個(gè)還不錯(cuò)的選擇,比較合適。
2)keepAliveTime、TimeUnit
這兩個(gè)參數(shù)一起決定了非核心線程空閑后的存活時(shí)間。
這兩個(gè)參數(shù)說(shuō)實(shí)話并不是非常重要,實(shí)際使用過(guò)程中不要設(shè)置太離譜的值一般問(wèn)題不大,我個(gè)人一般使用5分鐘或30分鐘。
3)workQueue
工作隊(duì)列,當(dāng)核心線程處理不過(guò)來(lái)時(shí),任務(wù)會(huì)堆積在隊(duì)列里。
常見(jiàn)的隊(duì)列有 ArrayBlockingQueue 和 LinkedBlockingQueue,兩者的主要區(qū)別在于 ArrayBlockingQueue 占用空間會(huì)更小,而 LinkedBlockingQueue 在生產(chǎn)者和消費(fèi)者使用了不同的鎖性能會(huì)好一點(diǎn)。
通常情況下,兩者的區(qū)別微乎其微,除非你要處理的任務(wù)量非常非常大,此時(shí)你需要仔細(xì)考慮使用哪個(gè)更合適,否則通常情況下兩個(gè)隨便選都可以。
常見(jiàn)的坑:使用 LinkedBlockingQueue 時(shí)沒(méi)設(shè)置隊(duì)列大小,也就是使用了無(wú)界隊(duì)列(Integer.MAX_VALUE),任務(wù)處理不過(guò)來(lái),不斷積壓在隊(duì)列里,最終造成內(nèi)存溢出。
線程池使用不當(dāng)導(dǎo)致內(nèi)存溢出的case我已經(jīng)見(jiàn)過(guò)很多次了,這個(gè)經(jīng)驗(yàn)大家一定要銘記在心:使用 LinkedBlockingQueue 一定要設(shè)置隊(duì)列大小。
另外,這邊給大家介紹下另一個(gè)我常用的工作隊(duì)列:SynchronousQueue。
SynchronousQueue 不是一個(gè)真正的隊(duì)列,而是一種在線程之間移交的機(jī)制。要將一個(gè)元素放入 SynchronousQueue 中,必須有另一個(gè)線程正在等待接受這個(gè)元素。如果沒(méi)有線程等待,并且線程池的當(dāng)前大小小于 maximumPoolSize,那么線程池將創(chuàng)建一個(gè)線程,否則根據(jù)拒絕策略,這個(gè)任務(wù)將被拒絕。使用直接移交將更高效,因?yàn)槿蝿?wù)會(huì)直接移交給執(zhí)行它的線程,而不是被放在隊(duì)列中,然后由工作線程從隊(duì)列中提取任務(wù)。只有當(dāng)線程池是無(wú)界的或者可以拒絕任務(wù)時(shí),該隊(duì)列才有實(shí)際價(jià)值,Executors.newCachedThreadPool使用了該隊(duì)列。
上述內(nèi)容里提到了:當(dāng)線程池是無(wú)界的或者可以拒絕任務(wù)時(shí),該隊(duì)列才有實(shí)際價(jià)值。
使用無(wú)界的線程池說(shuō)實(shí)話挺危險(xiǎn)的,我強(qiáng)烈建議不要使用,特別是經(jīng)驗(yàn)不太豐富的新人。因此我們?cè)谑褂?SynchronousQueue 的時(shí)候可以理解為一定會(huì)出現(xiàn)任務(wù)被拒絕的情況,因此要選擇好合適的拒絕策略。
SynchronousQueue 我一般會(huì)搭配 CallerRunsPolicy 使用,個(gè)人覺(jué)得這2個(gè)是個(gè)絕佳組合,這個(gè)組合起到的效果是:當(dāng)線程池處理不過(guò)來(lái)時(shí),直接交由調(diào)用者線程(往線程池里添加任務(wù)的主線程)來(lái)執(zhí)行,此時(shí)任務(wù)不會(huì)被積壓在隊(duì)列里,同時(shí)調(diào)用者線程無(wú)法繼續(xù)提交任務(wù)。
簡(jiǎn)單來(lái)說(shuō):任務(wù)處理非常高效,沒(méi)有任務(wù)積壓的概念不會(huì)有內(nèi)存溢出的風(fēng)險(xiǎn),同時(shí)在線程池處理不過(guò)來(lái)時(shí)具有控制任務(wù)提交速度的效果。
4)ThreadFactory
線程工廠,這個(gè)沒(méi)啥好說(shuō)的,通常使用默認(rèn)的就行。
常見(jiàn)的改動(dòng)場(chǎng)景是:給線程設(shè)置個(gè)自定義的名字,方便區(qū)分。
這種場(chǎng)景下,可以使用一些工具類提供的現(xiàn)有方法,也可以將 DefaultThreadFactory 拷貝出來(lái)自己修改一下。
5)RejectedExecutionHandler
拒絕策略,線程池處理不過(guò)來(lái)時(shí)的策略。默認(rèn)有4種策略,其中3種我個(gè)人比較常用到。
AbortPolicy:默認(rèn)的策略,直接拋出異常,沒(méi)有特殊需求直接使用該策略即可。
CallerRunsPolicy:調(diào)用者線程執(zhí)行策略,該策略上面提到了,我一般是配合 SynchronousQueue 使用,起到一個(gè)控制任務(wù)提交速度的效果。
DiscardPolicy:拋棄策略,直接丟掉要提交的任務(wù),這個(gè)策略一般在線程池執(zhí)行的是不太重要的任務(wù)時(shí)使用。
5、并發(fā)并行適用于哪種場(chǎng)景
典型的適合使用并發(fā)并行的場(chǎng)景通常有以下特點(diǎn):
1)存在I/O操作,并且I/O操作有多次,最典型的就是RPC調(diào)用和查詢數(shù)據(jù)庫(kù)
2)I/O操作比較耗時(shí),越耗時(shí)越有優(yōu)化價(jià)值
3)多次I/O操作之間沒(méi)有依賴關(guān)系,可以同時(shí)調(diào)用
總結(jié)
并發(fā)和并行是性能優(yōu)化中非常常用的手段,使用起來(lái)非常簡(jiǎn)單,并且?guī)?lái)的性能提升通常非常明顯,很容易就有幾倍幾倍的提升,快在自己的項(xiàng)目中用起來(lái)吧。
最后
我是囧輝,一個(gè)堅(jiān)持分享原創(chuàng)技術(shù)干貨的程序員,如果覺(jué)得本文對(duì)你有幫助,歡迎一鍵三連。
推薦閱讀
最近我將面試:阿里、字節(jié)、美團(tuán)、快手、拼多多等大廠的高頻面試整理出來(lái),并按大廠的標(biāo)準(zhǔn)給出自己的解析。
群里有不少同學(xué)看完拿下了阿里、美團(tuán)等大廠 Offer,希望能助你一臂之力,早日拿下大廠 Offer。
獲取方式:關(guān)注公眾號(hào)回復(fù)【面試】即可領(lǐng)取,更多大廠面試真題解析 PDF 整理中。

