1. 《性能優(yōu)化》并發(fā)與并行

        共 7320字,需瀏覽 15分鐘

         ·

        2021-11-09 22:25

        前言

        性能優(yōu)化系列第一篇主要給大家科普了一些性能相關(guān)的數(shù)字,為大家建立性能的初步概念。第二篇給大家介紹了支撐淘寶雙十一這種達(dá)到百萬QPS項(xiàng)目所需的相關(guān)核心技術(shù)。

        本文帶來的是性能優(yōu)化中的第一利器:并發(fā)與并行。

        除了核心原理介紹外,我將結(jié)合我自身的過去的實(shí)戰(zhàn)經(jīng)驗(yàn),給出一些自己在使用上的建議,希望對(duì)大家有幫助。

        不多廢話,直接開懟。


        正文

        1、并發(fā)和并行?

        并發(fā)和并行最關(guān)鍵的區(qū)別是:并行是同時(shí)執(zhí)行,而并發(fā)不是同時(shí)。

        這邊使用 Joe Armstrong 排隊(duì)使用咖啡機(jī)的例子來看并行和并發(fā)的區(qū)別,如下圖所示:

        上半部分為并發(fā):兩個(gè)隊(duì)伍交替使用咖啡機(jī)

        下半部分為并行:兩個(gè)隊(duì)伍同時(shí)使用咖啡機(jī)


        從和我們更相關(guān)的CPU的角度來看兩者的區(qū)別。

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


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


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

        2、并發(fā)一定能提升性能嗎?

        并行能提升性能大家不會(huì)有太多的疑問,但是并發(fā)是否一定能提升性能,估計(jì)還是有不少同學(xué)會(huì)有疑問。

        答案是否定的,并發(fā)不一定能提升性能,但是在絕大多數(shù)場景都能提升性能。

        什么場景下并發(fā)不能提升性能?

        我們舉個(gè)簡單的例子:假設(shè)我們的服務(wù)器配置為單核CPU,要執(zhí)行10個(gè)任務(wù),10個(gè)任務(wù)都是CPU計(jì)算密集型任務(wù),此時(shí)單線程執(zhí)行效率理論上要比開10個(gè)線程執(zhí)行要快。

        在執(zhí)行的整個(gè)過程中,基本都是CPU在運(yùn)行,但是開十個(gè)線程會(huì)涉及到線程上下文切換,需要花費(fèi)一些時(shí)間,導(dǎo)致反而更慢。


        再舉個(gè)更形象的例子:囧輝上語文開小差,被老師罰抄10篇課文,此時(shí)囧輝腦子里想到了兩種方法。

        方法1:先抄完第一篇,再抄第二篇,再抄第三篇,直到抄完第十篇。

        方法2:先抄第一篇的第一段,再抄第二篇的第一段,...,再抄第十篇的第一段,再抄第一篇的第二段,直到抄完全部。

        方法1為串行執(zhí)行,方法2為并發(fā)執(zhí)行,相信大家都能很容易看出方法二反而會(huì)更慢,因?yàn)槲覀冊趶那袚Q不同文章時(shí),需要先放好原來的文章,然后找新文章抄到哪個(gè)位置了,這個(gè)過程需要花費(fèi)一些時(shí)間,這個(gè)過程就類似于線程上下文切換。


        那什么場景下并發(fā)會(huì)提升性能了?

        再舉個(gè)例子:囧輝要燒10壺水,一壺水燒開的時(shí)間為1分鐘。

        串行執(zhí)行:囧輝先燒第一壺,第一壺?zé)_了后接著燒第二壺,直到燒完第十壺,這個(gè)方法燒完十壺水大概需要10分鐘。

        并發(fā)執(zhí)行:囧輝先燒第一壺,沒等第一壺?zé)_,接著燒第二壺,就這樣,囧輝一下子將十壺水都放到灶臺(tái)上同時(shí)燒,這個(gè)方法燒完十壺水大概需要1分鐘。

        在這個(gè)場景里,并發(fā)執(zhí)行就體現(xiàn)了很大的優(yōu)化,性能提升了接近10倍。


        在我們實(shí)際項(xiàng)目中,大部分應(yīng)用場景都是第二類,因此并發(fā)大多時(shí)候能提升性能,而哪些動(dòng)作是“燒開水”了,這個(gè)其實(shí)在性能優(yōu)化第一篇里提到了,最常見的“燒開水”操作就是I/O操作,最常見的如:調(diào)用其他服務(wù)的RPC接口查詢數(shù)據(jù)、查詢MySQL數(shù)據(jù)庫獲取數(shù)據(jù)等等。


        3、實(shí)現(xiàn)方式

        并發(fā)/并行的實(shí)現(xiàn)方式通常有兩種,如下。

        1)開線程直接懟,每循環(huán)一次都會(huì)新建一個(gè)線程來執(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è)簡單的demo來模擬“燒開水”的例子

        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ù)

        之前的線程池面試文章里有介紹過線程數(shù)的設(shè)置,這邊直接復(fù)制過來:

        要想合理的配置線程池大小,首先我們需要區(qū)分任務(wù)是計(jì)算密集型還是I/O密集型。

        對(duì)于計(jì)算密集型,設(shè)置 線程數(shù) = CPU數(shù) + 1,通常能實(shí)現(xiàn)最優(yōu)的利用率。

        對(duì)于I/O密集型,網(wǎng)上常見的說法是設(shè)置 線程數(shù) = CPU數(shù) * 2 ,這個(gè)做法是可以的,但個(gè)人覺得不是最優(yōu)的。

        在我們?nèi)粘5拈_發(fā)中,我們的任務(wù)幾乎是離不開I/O的,常見的網(wǎng)絡(luò)I/O(RPC調(diào)用)、磁盤I/O(數(shù)據(jù)庫操作),并且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í)際的使用場景來考慮。如果要求比較精確,可以通過壓測來獲取一個(gè)合理的值。


        上述是比較理想的線程數(shù)計(jì)算方式,在實(shí)際項(xiàng)目使用中,如果無法很準(zhǔn)確的計(jì)算,那么可以先用我上面的線程池配置,也就是:

        corePoolSize = CPU核數(shù)

        maximumPoolSize = CPU核數(shù) * 2

        這個(gè)參數(shù)設(shè)置可能不是最理想的,但在大多數(shù)情況下都是一個(gè)還不錯(cuò)的選擇,比較合適。


        2)keepAliveTime、TimeUnit

        這兩個(gè)參數(shù)一起決定了非核心線程空閑后的存活時(shí)間。

        這兩個(gè)參數(shù)說實(shí)話并不是非常重要,實(shí)際使用過程中不要設(shè)置太離譜的值一般問題不大,我個(gè)人一般使用5分鐘或30分鐘。


        3)workQueue

        工作隊(duì)列,當(dāng)核心線程處理不過來時(shí),任務(wù)會(huì)堆積在隊(duì)列里。

        常見的隊(duì)列有 ArrayBlockingQueue 和 LinkedBlockingQueue,兩者的主要區(qū)別在于 ArrayBlockingQueue 占用空間會(huì)更小,而 LinkedBlockingQueue 在生產(chǎn)者和消費(fèi)者使用了不同的鎖性能會(huì)好一點(diǎn)。

        通常情況下,兩者的區(qū)別微乎其微,除非你要處理的任務(wù)量非常非常大,此時(shí)你需要仔細(xì)考慮使用哪個(gè)更合適,否則通常情況下兩個(gè)隨便選都可以。

        常見的坑:使用 LinkedBlockingQueue 時(shí)沒設(shè)置隊(duì)列大小,也就是使用了無界隊(duì)列(Integer.MAX_VALUE),任務(wù)處理不過來,不斷積壓在隊(duì)列里,最終造成內(nèi)存溢出。

        線程池使用不當(dāng)導(dǎo)致內(nèi)存溢出的case我已經(jīng)見過很多次了,這個(gè)經(jīng)驗(yàn)大家一定要銘記在心:使用 LinkedBlockingQueue 一定要設(shè)置隊(duì)列大小。

        另外,這邊給大家介紹下另一個(gè)我常用的工作隊(duì)列:SynchronousQueue。

        SynchronousQueue 不是一個(gè)真正的隊(duì)列,而是一種在線程之間移交的機(jī)制。要將一個(gè)元素放入 SynchronousQueue 中,必須有另一個(gè)線程正在等待接受這個(gè)元素。如果沒有線程等待,并且線程池的當(dāng)前大小小于 maximumPoolSize,那么線程池將創(chuàng)建一個(gè)線程,否則根據(jù)拒絕策略,這個(gè)任務(wù)將被拒絕。使用直接移交將更高效,因?yàn)槿蝿?wù)會(huì)直接移交給執(zhí)行它的線程,而不是被放在隊(duì)列中,然后由工作線程從隊(duì)列中提取任務(wù)。只有當(dāng)線程池是無界的或者可以拒絕任務(wù)時(shí),該隊(duì)列才有實(shí)際價(jià)值,Executors.newCachedThreadPool使用了該隊(duì)列。

        上述內(nèi)容里提到了:當(dāng)線程池是無界的或者可以拒絕任務(wù)時(shí),該隊(duì)列才有實(shí)際價(jià)值。

        使用無界的線程池說實(shí)話挺危險(xiǎn)的,我強(qiáng)烈建議不要使用,特別是經(jīng)驗(yàn)不太豐富的新人。因此我們在使用 SynchronousQueue 的時(shí)候可以理解為一定會(huì)出現(xiàn)任務(wù)被拒絕的情況,因此要選擇好合適的拒絕策略。

        SynchronousQueue 我一般會(huì)搭配 CallerRunsPolicy 使用,個(gè)人覺得這2個(gè)是個(gè)絕佳組合,這個(gè)組合起到的效果是:當(dāng)線程池處理不過來時(shí),直接交由調(diào)用者線程(往線程池里添加任務(wù)的主線程)來執(zhí)行,此時(shí)任務(wù)不會(huì)被積壓在隊(duì)列里,同時(shí)調(diào)用者線程無法繼續(xù)提交任務(wù)。

        簡單來說:任務(wù)處理非常高效,沒有任務(wù)積壓的概念會(huì)有內(nèi)存溢出的風(fēng)險(xiǎn),同時(shí)在線程池處理不過來時(shí)具有控制任務(wù)提交速度的效果。


        4)ThreadFactory

        線程工廠,這個(gè)沒啥好說的,通常使用默認(rèn)的就行。

        常見的改動(dòng)場景是:給線程設(shè)置個(gè)自定義的名字,方便區(qū)分。

        這種場景下,可以使用一些工具類提供的現(xiàn)有方法,也可以將 DefaultThreadFactory 拷貝出來自己修改一下。


        5)RejectedExecutionHandler

        拒絕策略,線程池處理不過來時(shí)的策略。默認(rèn)有4種策略,其中3種我個(gè)人比較常用到。

        AbortPolicy:默認(rèn)的策略,直接拋出異常,沒有特殊需求直接使用該策略即可。

        CallerRunsPolicy:調(diào)用者線程執(zhí)行策略,該策略上面提到了,我一般是配合 SynchronousQueue 使用,起到一個(gè)控制任務(wù)提交速度的效果。

        DiscardPolicy:拋棄策略,直接丟掉要提交的任務(wù),這個(gè)策略一般在線程池執(zhí)行的是不太重要的任務(wù)時(shí)使用。


        5、并發(fā)并行適用于哪種場景

        典型的適合使用并發(fā)并行的場景通常有以下特點(diǎn):

        1)存在I/O操作,并且I/O操作有多次,最典型的就是RPC調(diào)用和查詢數(shù)據(jù)庫

        2)I/O操作比較耗時(shí),越耗時(shí)越有優(yōu)化價(jià)值

        3)多次I/O操作之間沒有依賴關(guān)系,可以同時(shí)調(diào)用


        總結(jié)

        并發(fā)和并行是性能優(yōu)化中非常常用的手段,使用起來非常簡單,并且?guī)淼男阅芴嵘ǔ7浅C黠@,很容易就有幾倍幾倍的提升,快在自己的項(xiàng)目中用起來吧。


        最后

        我是囧輝,一個(gè)堅(jiān)持分享原創(chuàng)技術(shù)干貨的程序員,如果覺得本文對(duì)你有幫助,歡迎一鍵三連。


        推薦閱讀

        Java 基礎(chǔ)高頻面試題(2021年最新版)

        Java 集合框架高頻面試題(2021年最新版)

        面試必問的 Spring,你懂了嗎?

        面試必問的 MySQL,你懂了嗎?


        最近我將面試:阿里、字節(jié)、美團(tuán)、快手、拼多多等大廠的高頻面試整理出來,并按大廠的標(biāo)準(zhǔn)給出自己的解析。

        群里有不少同學(xué)看完拿下了阿里、美團(tuán)等大廠 Offer,希望能助你一臂之力,早日拿下大廠 Offer。

        獲取方式:關(guān)注公眾號(hào)回復(fù)【面試】即可領(lǐng)取,更多大廠面試真題解析 PDF 整理中。

        瀏覽 48
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 性生活视频在线播放 | 一起艹 | 又粗又大又猛又爽免费视频成人a | 台湾久久夕三极片 | 国语自产少妇精品视频蜜桃 |