溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

Java并發(fā)編程:線程及同步的性能——線程池

發(fā)布時(shí)間:2020-05-22 13:01:55 來(lái)源:網(wǎng)絡(luò) 閱讀:221 作者:愛(ài)碼仕i 欄目:編程語(yǔ)言

Java并發(fā)編程:線程及同步的性能——線程池

線程池和ThreadPoolExecutors

雖然在程序中可以直接使用Thread類型來(lái)進(jìn)行線程操作,但是更多的情況是使用線程池,尤其是在Java EE應(yīng)用服務(wù)器中,一般會(huì)使用若干個(gè)線程池來(lái)處理來(lái)自客戶端的請(qǐng)求。Java中對(duì)于線程池的支持,來(lái)自ThreadPoolExecutor。一些應(yīng)用服務(wù)器也確實(shí)是使用的ThreadPoolExecutor來(lái)實(shí)現(xiàn)線程池。

對(duì)于線程池的性能調(diào)優(yōu),最重要的參數(shù)就是線程池的大小。

對(duì)于任何線程池而言,它們的工作方式幾乎都是相同的:

  1. 任務(wù)被投放到一個(gè)隊(duì)列中(隊(duì)列的數(shù)量不定)
  2. 線程從隊(duì)列中取得任務(wù)并執(zhí)行
  3. 線程完成任務(wù)后,繼續(xù)嘗試從隊(duì)列中取得任務(wù),如果隊(duì)列為空,那么線程進(jìn)入等待狀態(tài)

線程池往往擁有最小和最大線程數(shù):

  1. 最小線程數(shù),即當(dāng)任務(wù)隊(duì)列為空時(shí),線程池中最少需要保持的線程數(shù)量,這樣做是考慮到創(chuàng)建線程是一個(gè)相對(duì)耗費(fèi)資源的操作,應(yīng)當(dāng)盡可能地避免,當(dāng)有新任務(wù)被投入隊(duì)列時(shí),總會(huì)有線程能夠立即對(duì)它進(jìn)行處理。
  2. 最大線程數(shù),當(dāng)需要處理的任務(wù)過(guò)多時(shí),線程池能夠擁有的最大線程數(shù)。這樣是為了保證不會(huì)有過(guò)多的線程被創(chuàng)建出來(lái),因?yàn)榫€程的運(yùn)行需要依賴于CPU資源和其它各種資源,當(dāng)線程過(guò)多時(shí),反而會(huì)降低性能。

在ThreadPoolExecutor和其相關(guān)的類型中,最小線程數(shù)被稱為線程池核心規(guī)模(Core Pool Size),在其它Java應(yīng)用服務(wù)器的實(shí)現(xiàn)中,這個(gè)數(shù)量也許被稱為最小線程數(shù)(MinThreads),但是它們的概念是相同的。

但是在對(duì)線程池進(jìn)行規(guī)模變更(Resizing)的時(shí)候,ThreadPoolExecutor和其它線程池的實(shí)現(xiàn)也許存在的很大的差別。

一個(gè)最簡(jiǎn)單的情況是:當(dāng)有新任務(wù)需要被執(zhí)行,且當(dāng)前所有的線程都被占用時(shí),ThreadPoolExecutor和其它實(shí)現(xiàn)通常都會(huì)新創(chuàng)建一個(gè)線程來(lái)執(zhí)行這個(gè)新任務(wù)(直到達(dá)到了最大線程數(shù))。

設(shè)置最大線程數(shù)

最合適的最大線程數(shù)該怎么確定,依賴以下兩個(gè)方面:

  1. 任務(wù)的特征
  2. 計(jì)算機(jī)的硬件情況

為了方便討論,下面假設(shè)JVM有4個(gè)可用的CPU。那么任務(wù)也很明確,就是要最大程度地“壓榨”它們的資源,千方百計(jì)的提高CPU的利用率。

那么,最大線程數(shù)最少需要被設(shè)置成4,因?yàn)橛?個(gè)可用的CPU,意味著最多能夠并行地執(zhí)行4個(gè)任務(wù)。當(dāng)然,垃圾回收(Garbage Collection)在這個(gè)過(guò)程中也會(huì)造成一些影響,但是它們往往不需要使用整個(gè)CPU。一個(gè)例外是,當(dāng)使用了CMS或者G1垃圾回收算法時(shí),需要有足夠的CPU資源進(jìn)行垃圾回收。

那么是否有必要將線程數(shù)量設(shè)置的更大呢?這就取決于任務(wù)的特征了。

假設(shè)當(dāng)任務(wù)是計(jì)算密集型的,意味著任務(wù)不需要執(zhí)行IO操作,例如讀取數(shù)據(jù)庫(kù),讀取文件等,因此它們不涉及到同步的問(wèn)題,任務(wù)之間完全是獨(dú)立的。比如使用一個(gè)批處理程序讀取Mock數(shù)據(jù)源的數(shù)據(jù),測(cè)試在不線程池?fù)碛胁煌€程數(shù)量時(shí)的性能,得到下表:

Java并發(fā)編程:線程及同步的性能——線程池

從上面中得到一些結(jié)論:

  1. 當(dāng)線程數(shù)為4時(shí),達(dá)到最優(yōu)性能,再增加線程數(shù)量時(shí)并沒(méi)有更好的性能,因?yàn)榇藭r(shí)CPU的利用率已經(jīng)達(dá)到了最高,在增加線程只會(huì)增加線程之間爭(zhēng)奪CPU資源的行為,因此反而降低了性能。
  2. 即使在CPU利用率達(dá)到最高時(shí),基線百分比也不是理想中的25%,這是因?yàn)殡m然在程序運(yùn)行過(guò)程中,CPU資源并不是只被應(yīng)用程序線程獨(dú)享的,一些后臺(tái)線程有時(shí)也會(huì)需要CPU資源,比如GC線程和系統(tǒng)的一些線程等。

當(dāng)計(jì)算是通過(guò)Servlet觸發(fā)的時(shí)候,性能數(shù)據(jù)是下面這個(gè)樣子的(Load Generator會(huì)同時(shí)發(fā)送20個(gè)請(qǐng)求):

Java并發(fā)編程:線程及同步的性能——線程池

從上表中可以得到的結(jié)論:

  1. 在線程數(shù)量為4時(shí),性能最優(yōu)。因?yàn)榇巳蝿?wù)的類型是計(jì)算密集型的,只有4個(gè)CPU,因此線程數(shù)量為4時(shí),達(dá)到最優(yōu)情況。
  2. 隨著線程數(shù)量逐漸增加,性能下降,因?yàn)榫€程之間會(huì)互相爭(zhēng)奪CPU資源,造成頻繁切換線程執(zhí)行上下文環(huán)境,而這些切換只會(huì)浪費(fèi)CPU資源。
  3. 性能下降的速度并不明顯,這也是因?yàn)槿蝿?wù)類型是計(jì)算密集型的緣故,如果性能瓶頸不是CPU提供的計(jì)算資源,而是外部的資源,如數(shù)據(jù)庫(kù),文件操作等,那么增加線程數(shù)量帶來(lái)的性能下降也許會(huì)更加明顯。

下面,從Client的角度考慮一下問(wèn)題,并發(fā)Client的數(shù)量對(duì)于Server的響應(yīng)時(shí)間會(huì)有什么影響呢?還是同樣地環(huán)境,當(dāng)并發(fā)Client數(shù)量逐漸增加時(shí),響應(yīng)時(shí)間會(huì)如下發(fā)生變化:

Java并發(fā)編程:線程及同步的性能——線程池

因?yàn)槿蝿?wù)類型是計(jì)算密集型的,當(dāng)并發(fā)Client數(shù)量時(shí)1,2,4時(shí),平均響應(yīng)時(shí)間都是最優(yōu)的,然而當(dāng)出現(xiàn)多余4個(gè)Client時(shí),性能會(huì)隨著Client的增加發(fā)生顯著地下降。

當(dāng)Client數(shù)量增加時(shí),你也許會(huì)想通過(guò)增加服務(wù)端線程池的線程數(shù)量來(lái)提高性能,可是在CPU密集型任務(wù)的情況下,這么做只會(huì)降低性能。因?yàn)橄到y(tǒng)的瓶頸就是CPU資源,冒然增加線程池的線程數(shù)量只會(huì)讓對(duì)于這種資源的競(jìng)爭(zhēng)更加激烈。

所以,在面對(duì)性能方面的問(wèn)題時(shí)。第一步永遠(yuǎn)是了解系統(tǒng)的瓶頸在哪里,這樣才能夠有的放矢。如果冒然進(jìn)行所謂的“調(diào)優(yōu)”,讓對(duì)瓶頸資源的競(jìng)爭(zhēng)更加激烈,那么帶來(lái)的只會(huì)是性能的進(jìn)一步下降。相反,如果讓對(duì)瓶頸資源的競(jìng)爭(zhēng)變的緩和,那么性能通常則會(huì)提高。

在上面的場(chǎng)景中,如果從ThreadPoolExecutor的角度進(jìn)行考慮,那么在任務(wù)隊(duì)列中一直會(huì)有任務(wù)處于掛起(Pending)的狀態(tài)(因?yàn)镃lient的每個(gè)請(qǐng)求對(duì)應(yīng)的就是一個(gè)任務(wù)),而所有的可用線程都在工作,CPU正在滿負(fù)荷運(yùn)轉(zhuǎn)。這個(gè)時(shí)候添加線程池的線程數(shù)量,讓這些添加的線程領(lǐng)取一些掛起的任務(wù),會(huì)發(fā)生什么事情呢?這時(shí)帶來(lái)的只會(huì)是線程之間對(duì)于CPU資源的爭(zhēng)奪更加激烈,降低了性能。

設(shè)置最小線程數(shù)

設(shè)置了最大線程數(shù)之后,還需要設(shè)置最小線程數(shù)。對(duì)于絕大部分場(chǎng)景,將它設(shè)置的和最大線程數(shù)相等就可以了。

將最小線程數(shù)設(shè)置的小于最大線程數(shù)的初衷是為了節(jié)省資源,因?yàn)槊慷鄤?chuàng)建一個(gè)線程都會(huì)耗費(fèi)一定量的資源,尤其是線程棧所需要的資源。但是在一個(gè)系統(tǒng)中,針對(duì)硬件資源以及任務(wù)特點(diǎn)選定了最大線程數(shù)之后,就表示這個(gè)系統(tǒng)總是會(huì)利用這些線程的,那么還不如在一開(kāi)始就讓線程池把需要的線程準(zhǔn)備好。然而,把最小線程數(shù)設(shè)置的小于最大線程數(shù)所帶來(lái)的影響也是非常小的,一般都不會(huì)察覺(jué)到有什么不同。

在批處理程序中,最小線程數(shù)是否等于最大線程數(shù)并不重要。因?yàn)樽詈缶€程總是需要被創(chuàng)建出來(lái)的,所以程序的運(yùn)行時(shí)間應(yīng)該幾乎相同。對(duì)于服務(wù)器程序而言,影響也不大,但是一般而言,線程池中的線程在“熱身”階段就應(yīng)該被創(chuàng)建出來(lái),所以這也是為什么建議將最小線程數(shù)設(shè)置的等于最大線程數(shù)的原因。

在一些場(chǎng)景中,也需要要設(shè)置一個(gè)不同的最小線程數(shù)。比如當(dāng)一個(gè)系統(tǒng)最大需要同時(shí)處理2000個(gè)任務(wù),而平均任務(wù)數(shù)量只是20個(gè)情況下,就需要將最小線程數(shù)設(shè)置成20,而不是等于其最大線程數(shù)2000。此時(shí)如果還是將最小線程數(shù)設(shè)置的等于最大線程數(shù)的話,那么閑置線程(Idle Thread)占用的資源就比較可觀了,尤其是當(dāng)使用了ThreadLocal類型的變量時(shí)。

線程池任務(wù)數(shù)量(Thread Pool Task Sizes)

線程池有一個(gè)列表或者隊(duì)列的數(shù)據(jù)結(jié)構(gòu)來(lái)存放需要被執(zhí)行的任務(wù)。顯然,在某些情況下,任務(wù)數(shù)量的增長(zhǎng)速度會(huì)大于其被執(zhí)行的速度。如果這個(gè)任務(wù)代表的是一個(gè)來(lái)自Client的請(qǐng)求,那么也就意味著該Client會(huì)等待比較長(zhǎng)的時(shí)間。顯然這是不可接受的,尤其對(duì)于提供Web服務(wù)的服務(wù)器程序而言。

所以,線程池會(huì)有機(jī)制來(lái)限制列表/隊(duì)列中任務(wù)的數(shù)量。但是,和設(shè)置最大線程數(shù)一樣,并沒(méi)有一個(gè)放之四海而皆準(zhǔn)的最優(yōu)任務(wù)數(shù)量。這還是要取決于具體的任務(wù)類型和不斷的進(jìn)行性能測(cè)試。

對(duì)于ThreadPoolExecutor而言,當(dāng)任務(wù)數(shù)量達(dá)到最大時(shí),再嘗試增加新的任務(wù)就會(huì)失敗。ThreadPoolExecutor有一個(gè)rejectedExecution方法用來(lái)拒絕該任務(wù)。這會(huì)導(dǎo)致應(yīng)用服務(wù)器返回一個(gè)HTTP狀態(tài)碼500,當(dāng)然這種信息最好以更友好的方式傳達(dá)給Client,比如解釋一下為什么你的請(qǐng)求被拒絕了。

定制ThreadPoolExecutor

線程池在同時(shí)滿足以下三個(gè)條件時(shí),就會(huì)創(chuàng)建一個(gè)新的線程:

  1. 有任務(wù)需要被執(zhí)行
  2. 當(dāng)前線程池中所有的線程都處于工作狀態(tài)
  3. 當(dāng)前線程池的線程數(shù)沒(méi)有達(dá)到最大線程數(shù)

至于線程池會(huì)如何創(chuàng)建這個(gè)新的線程,則是根據(jù)任務(wù)隊(duì)列的種類:

  1. 任務(wù)隊(duì)列是 SynchronousQueue 這個(gè)隊(duì)列的特點(diǎn)是,它并不能放置任何任務(wù)在其隊(duì)列中,當(dāng)有任務(wù)被提交時(shí),使用SynchronousQueue的線程池會(huì)立即為該任務(wù)創(chuàng)建一個(gè)線程(如果線程數(shù)量沒(méi)有達(dá)到最大時(shí),如果達(dá)到了最大,那么該任務(wù)會(huì)被拒絕)。這種隊(duì)列適合于當(dāng)任務(wù)數(shù)量較小時(shí)采用。也就是說(shuō),在使用這種隊(duì)列時(shí),未被執(zhí)行的任務(wù)沒(méi)有一個(gè)容器來(lái)暫時(shí)儲(chǔ)存。
  2. 任務(wù)隊(duì)列是 無(wú)限隊(duì)列(Unbound Queue) ×××限的隊(duì)列可以是諸如LinkedBlockingQueue這種類型,在這種情況下,任何被提交的任務(wù)都不會(huì)被拒絕。但是線程池會(huì)忽略最大線程數(shù)這一參數(shù),意味著線程池的最大線程數(shù)就變成了設(shè)置的最小線程數(shù)。所以在使用這種隊(duì)列時(shí),通常會(huì)將最大線程數(shù)設(shè)置的和最小線程數(shù)相等。這就相當(dāng)于使用了一個(gè)固定了線程數(shù)量的線程池。
  3. 任務(wù)隊(duì)列是 有限隊(duì)列(Bounded Queue) 當(dāng)使用的隊(duì)列是諸如ArrayBlockingQueue這種有限隊(duì)列的時(shí)候,來(lái)決定什么時(shí)候創(chuàng)建新線程的算法就相對(duì)復(fù)雜一些了。比如,最小線程數(shù)是4,最大線程數(shù)是8,任務(wù)隊(duì)列最多能夠容納10個(gè)任務(wù)。在這種情況下,當(dāng)任務(wù)逐漸被添加到隊(duì)列中,直到隊(duì)列被占滿(10個(gè)任務(wù)),此時(shí)線程池中的工作線程仍然只有4個(gè),即最小線程數(shù)。只有當(dāng)仍然有任務(wù)希望被放置到隊(duì)列中的時(shí)候,線程池才會(huì)新創(chuàng)建一個(gè)線程并從隊(duì)列頭部拿走一個(gè)任務(wù),以騰出位置來(lái)容納這個(gè)最新被提交的任務(wù)。

關(guān)于如何定制ThreadPoolExecutor,遵循KISS原則(Keep It Simple, Stupid)就好了。比如將最大線程數(shù)和最小線程數(shù)設(shè)置的相等,然后根據(jù)情況選擇有限隊(duì)列或者無(wú)限隊(duì)列。

總結(jié)

  1. 線程池是對(duì)象池的一個(gè)有用的例子,它能夠節(jié)省在創(chuàng)建它們時(shí)候的資源開(kāi)銷。并且線程池對(duì)系統(tǒng)中的線程數(shù)量也起到了很好的限制作用。

  2. 線程池中的線程數(shù)量必須仔細(xì)的設(shè)置,否則冒然增加線程數(shù)量只會(huì)帶來(lái)性能的下降。

  3. 在定制ThreadPoolExecutor時(shí),遵循KISS原則,通常情況下會(huì)提供最好的性能。

ForkJoinPool

在Java 7中引入了一種新的線程池:ForkJoinPool。

它同ThreadPoolExecutor一樣,也實(shí)現(xiàn)了Executor和ExecutorService接口。它使用了一個(gè)無(wú)限隊(duì)列來(lái)保存需要執(zhí)行的任務(wù),而線程的數(shù)量則是通過(guò)構(gòu)造函數(shù)傳入,如果沒(méi)有向構(gòu)造函數(shù)中傳入希望的線程數(shù)量,那么當(dāng)前計(jì)算機(jī)可用的CPU數(shù)量會(huì)被設(shè)置為線程數(shù)量作為默認(rèn)值。

ForkJoinPool主要用來(lái)使用分治法(Divide-and-Conquer Algorithm)來(lái)解決問(wèn)題。典型的應(yīng)用比如快速排序算法。這里的要點(diǎn)在于,F(xiàn)orkJoinPool需要使用相對(duì)少的線程來(lái)處理大量的任務(wù)。比如要對(duì)1000萬(wàn)個(gè)數(shù)據(jù)進(jìn)行排序,那么會(huì)將這個(gè)任務(wù)分割成兩個(gè)500萬(wàn)的排序任務(wù)和一個(gè)針對(duì)這兩組500萬(wàn)數(shù)據(jù)的合并任務(wù)。以此類推,對(duì)于500萬(wàn)的數(shù)據(jù)也會(huì)做出同樣的分割處理,到最后會(huì)設(shè)置一個(gè)閾值來(lái)規(guī)定當(dāng)數(shù)據(jù)規(guī)模到多少時(shí),停止這樣的分割處理。比如,當(dāng)元素的數(shù)量小于10時(shí),會(huì)停止分割,轉(zhuǎn)而使用插入排序?qū)λ鼈冞M(jìn)行排序。

那么到最后,所有的任務(wù)加起來(lái)會(huì)有大概2000000+個(gè)。問(wèn)題的關(guān)鍵在于,對(duì)于一個(gè)任務(wù)而言,只有當(dāng)它所有的子任務(wù)完成之后,它才能夠被執(zhí)行。

所以當(dāng)使用ThreadPoolExecutor時(shí),使用分治法會(huì)存在問(wèn)題,因?yàn)門(mén)hreadPoolExecutor中的線程無(wú)法像任務(wù)隊(duì)列中再添加一個(gè)任務(wù)并且在等待該任務(wù)完成之后再繼續(xù)執(zhí)行。而使用ForkJoinPool時(shí),就能夠讓其中的線程創(chuàng)建新的任務(wù),并掛起當(dāng)前的任務(wù),此時(shí)線程就能夠從隊(duì)列中選擇子任務(wù)執(zhí)行。

比如,我們需要統(tǒng)計(jì)一個(gè)double數(shù)組中小于0.5的元素的個(gè)數(shù),那么可以使用ForkJoinPool進(jìn)行實(shí)現(xiàn)如下:

public class ForkJoinTest {
    private double[] d;
    private class ForkJoinTask extends RecursiveTask<Integer> {
        private int first;
        private int last;
        public ForkJoinTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        protected Integer compute() {
            int subCount;
            if (last - first < 10) {
                subCount = 0;
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5)
                                            subCount++;
                }
            } else {
                int mid = (first + last) >>> 1;
                ForkJoinTask left = new ForkJoinTask(first, mid);
                left.fork();
                ForkJoinTask right = new ForkJoinTask(mid + 1, last);
                right.fork();
                subCount = left.join();
                subCount += right.join();
            }
            return subCount;
        }
    }
    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        int n = new ForkJoinPool().invoke(new ForkJoinTask(0, 9999999));
        System.out.println("Found " + n + " values");
    }
}

以上的關(guān)鍵是fork()和join()方法。在ForkJoinPool使用的線程中,會(huì)使用一個(gè)內(nèi)部隊(duì)列來(lái)對(duì)需要執(zhí)行的任務(wù)以及子任務(wù)進(jìn)行操作來(lái)保證它們的執(zhí)行順序。

那么使用ThreadPoolExecutor或者ForkJoinPool,會(huì)有什么性能的差異呢?

首先,使用ForkJoinPool能夠使用數(shù)量有限的線程來(lái)完成非常多的具有父子關(guān)系的任務(wù),比如使用4個(gè)線程來(lái)完成超過(guò)200萬(wàn)個(gè)任務(wù)。但是,使用ThreadPoolExecutor時(shí),是不可能完成的,因?yàn)門(mén)hreadPoolExecutor中的Thread無(wú)法選擇優(yōu)先執(zhí)行子任務(wù),需要完成200萬(wàn)個(gè)具有父子關(guān)系的任務(wù)時(shí),也需要200萬(wàn)個(gè)線程,顯然這是不可行的。

當(dāng)然,在上面的例子中,也可以不使用分治法,因?yàn)槿蝿?wù)之間的獨(dú)立性,可以將整個(gè)數(shù)組劃分為幾個(gè)區(qū)域,然后使用ThreadPoolExecutor來(lái)解決,這種辦法不會(huì)創(chuàng)建數(shù)量龐大的子任務(wù)。代碼如下:

public class ThreadPoolTest {
    private double[] d;
    private class ThreadPoolExecutorTask implements Callable<Integer> {
        private int first;
        private int last;
        public ThreadPoolExecutorTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        public Integer call() {
            int subCount = 0;
            for (int i = first; i <= last; i++) {
                if (d[i] < 0.5) {
                    subCount++;
                }
            }
            return subCount;
        }
    }
    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        ThreadPoolExecutor tpe = new ThreadPoolExecutor
        (4, 4, long.MAX_VALUE, TimeUnit.SECONDS, new LinkedBlockingQueue());
        Future[] f = new Future[4];
        int size = d.length / 4;
        for (int i = 0; i < 3; i++) {
            f[i] = tpe.submit(new ThreadPoolExecutorTask(i * size, (i + 1) * size - 1);
        }
        f[3] = tpe.submit(new ThreadPoolExecutorTask(3 * size, d.length - 1);
        int n = 0;
        for (int i = 0; i < 4; i++) {
            n += f.get();
        }
        System.out.println("Found " + n + " values");
    }
}

在分別使用ForkJoinPool和ThreadPoolExecutor時(shí),它們處理這個(gè)問(wèn)題的時(shí)間如下:

Java并發(fā)編程:線程及同步的性能——線程池

對(duì)執(zhí)行過(guò)程中的GC同樣也進(jìn)行了監(jiān)控,發(fā)現(xiàn)在使用ForkJoinPool時(shí),總的GC時(shí)間花去了1.2s,而ThreadPoolExecutor并沒(méi)有觸發(fā)任何的GC操作。這是因?yàn)樵贔orkJoinPool的運(yùn)行過(guò)程中,會(huì)創(chuàng)建大量的子任務(wù)。而當(dāng)他們執(zhí)行完畢之后,會(huì)被垃圾回收。反之,ThreadPoolExecutor則不會(huì)創(chuàng)建任何的子任務(wù),因此不會(huì)導(dǎo)致任何的GC操作。

ForkJoinPool的另外一個(gè)特性是它能夠?qū)崿F(xiàn)工作竊取(Work Stealing),在該線程池的每個(gè)線程中會(huì)維護(hù)一個(gè)隊(duì)列來(lái)存放需要被執(zhí)行的任務(wù)。當(dāng)線程自身隊(duì)列中的任務(wù)都執(zhí)行完畢后,它會(huì)從別的線程中拿到未被執(zhí)行的任務(wù)并幫助它執(zhí)行。

可以通過(guò)以下的代碼來(lái)測(cè)試ForkJoinPool的Work Stealing特性:

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < d.length - i; j++) {
        for (int k = 0; k < 100; k++) {
            dummy = j * k + i;
            // dummy is volatile, so multiple writes occur
            d[i] = dummy;
        }
    }
}

因?yàn)槔飳拥难h(huán)次數(shù)(j)是依賴于外層的i的值的,所以這段代碼的執(zhí)行時(shí)間依賴于i的值。當(dāng)i = 0時(shí),執(zhí)行時(shí)間最長(zhǎng),而i = last時(shí)執(zhí)行時(shí)間最短。也就意味著任務(wù)的工作量是不一樣的,當(dāng)i的值較小時(shí),任務(wù)的工作量大,隨著i逐漸增加,任務(wù)的工作量變小。因此這是一個(gè)典型的任務(wù)負(fù)載不均衡的場(chǎng)景。

這時(shí),選擇ThreadPoolExecutor就不合適了,因?yàn)樗渲械木€程并不會(huì)關(guān)注每個(gè)任務(wù)之間任務(wù)量的差異。當(dāng)執(zhí)行任務(wù)量最小的任務(wù)的線程執(zhí)行完畢后,它就會(huì)處于空閑的狀態(tài)(Idle),等待任務(wù)量最大的任務(wù)執(zhí)行完畢。

而ForkJoinPool的情況就不同了,即使任務(wù)的工作量有差別,當(dāng)某個(gè)線程在執(zhí)行工作量大的任務(wù)時(shí),其他的空閑線程會(huì)幫助它完成剩下的任務(wù)。因此,提高了線程的利用率,從而提高了整體性能。

這兩種線程池對(duì)于任務(wù)工作量不均衡時(shí)的執(zhí)行時(shí)間:

Java并發(fā)編程:線程及同步的性能——線程池

注意到當(dāng)線程數(shù)量為1時(shí),兩者的執(zhí)行時(shí)間差異并不明顯。這是因?yàn)榭偟挠?jì)算量是相同的,而ForkJoinPool慢的那一秒多是因?yàn)樗鼊?chuàng)建了非常多的任務(wù),同時(shí)也導(dǎo)致了GC的工作量增加。

當(dāng)線程數(shù)量增加到4時(shí),執(zhí)行時(shí)間的區(qū)別就較大了,F(xiàn)orkJoinPool的性能比ThreadPoolExecutor好將近50%,可見(jiàn)Work Stealing在應(yīng)對(duì)任務(wù)量不均衡的情況下,能夠保證資源的利用率。

所以一個(gè)結(jié)論就是:當(dāng)任務(wù)的任務(wù)量均衡時(shí),選擇ThreadPoolExecutor往往更好,反之則選擇ForkJoinPool。

另外,對(duì)于ForkJoinPool,還有一個(gè)因素會(huì)影響它的性能,就是停止進(jìn)行任務(wù)分割的那個(gè)閾值。比如在之前的快速排序中,當(dāng)剩下的元素?cái)?shù)量小于10的時(shí)候,就會(huì)停止子任務(wù)的創(chuàng)建。下表顯示了在不同閾值下,F(xiàn)orkJoinPool的性能:

Java并發(fā)編程:線程及同步的性能——線程池

可以發(fā)現(xiàn),當(dāng)閾值不同時(shí),對(duì)于性能也會(huì)有一定影響。因此,在使用ForkJoinPool時(shí),對(duì)此閾值進(jìn)行測(cè)試,使用一個(gè)最合適的值也有助于整體性能。

自動(dòng)并行化(Automatic Parallelization)

在Java 8中,引入了自動(dòng)并行化的概念。它能夠讓一部分Java代碼自動(dòng)地以并行的方式執(zhí)行,前提是使用了ForkJoinPool。

Java 8為ForkJoinPool添加了一個(gè)通用線程池,這個(gè)線程池用來(lái)處理那些沒(méi)有被顯式提交到任何線程池的任務(wù)。它是ForkJoinPool類型上的一個(gè)靜態(tài)元素,它擁有的默認(rèn)線程數(shù)量等于運(yùn)行計(jì)算機(jī)上的處理器數(shù)量。

當(dāng)調(diào)用Arrays類上添加的新方法時(shí),自動(dòng)并行化就會(huì)發(fā)生。比如用來(lái)排序一個(gè)數(shù)組的并行快速排序,用來(lái)對(duì)一個(gè)數(shù)組中的元素進(jìn)行并行遍歷。自動(dòng)并行化也被運(yùn)用在Java 8新添加的Stream API中。

比如下面的代碼用來(lái)遍歷列表中的元素并執(zhí)行需要的計(jì)算:

Stream<Integer> stream = arrayList.parallelStream();
stream.forEach(a -> {
    String symbol = StockPriceUtils.makeSymbol(a);
    StockPriceHistory sph = new StockPriceHistoryImpl(symbol, startDate, endDate, entityManager);
}
);

對(duì)于列表中的元素的計(jì)算都會(huì)以并行的方式執(zhí)行。forEach方法會(huì)為每個(gè)元素的計(jì)算操作創(chuàng)建一個(gè)任務(wù),該任務(wù)會(huì)被前文中提到的ForkJoinPool中的通用線程池處理。以上的并行計(jì)算邏輯當(dāng)然也可以使用ThreadPoolExecutor完成,但是就代碼的可讀性和代碼量而言,使用ForkJoinPool明顯更勝一籌。

對(duì)于ForkJoinPool通用線程池的線程數(shù)量,通常使用默認(rèn)值就可以了,即運(yùn)行時(shí)計(jì)算機(jī)的處理器數(shù)量。如果需要調(diào)整線程數(shù)量,可以通過(guò)設(shè)置系統(tǒng)屬性:-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

下面的一組數(shù)據(jù)用來(lái)比較使用ThreadPoolExecutor和ForkJoinPool中的通用線程池來(lái)完成上面簡(jiǎn)單計(jì)算時(shí)的性能:

Java并發(fā)編程:線程及同步的性能——線程池

注意到當(dāng)線程數(shù)為1,2,4時(shí),性能差異的比較明顯。線程數(shù)為1的ForkJoinPool通用線程池和線程數(shù)為2的ThreadPoolExecutor的性能十分接近。

出現(xiàn)這種現(xiàn)象的原因是,forEach方法用了一些小把戲。它會(huì)將執(zhí)行forEach本身的線程也作為線程池中的一個(gè)工作線程。因此,即使將ForkJoinPool的通用線程池的線程數(shù)量設(shè)置為1,實(shí)際上也會(huì)有2個(gè)工作線程。因此在使用forEach的時(shí)候,線程數(shù)為1的ForkJoinPool通用線程池和線程數(shù)為2的ThreadPoolExecutor是等價(jià)的。

所以當(dāng)ForkJoinPool通用線程池實(shí)際需要4個(gè)工作線程時(shí),可以將它設(shè)置成3,那么在運(yùn)行時(shí)可用的工作線程就是4了。

總結(jié)

  1. 當(dāng)需要處理遞歸分治算法時(shí),考慮使用ForkJoinPool。
  2. 仔細(xì)設(shè)置不再進(jìn)行任務(wù)劃分的閾值,這個(gè)閾值對(duì)性能有影響。
  3. Java 8中的一些特性會(huì)使用到ForkJoinPool中的通用線程池。在某些場(chǎng)合下,需要調(diào)整該線程池的默認(rèn)的線程數(shù)量。

————END————

Java并發(fā)編程:線程及同步的性能——線程池

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI