線程池和ThreadPoolExecutors
雖然在程序中可以直接使用Thread類(lèi)型來(lái)進(jìn)行線程操作浅乔,但是更多的情況是使用線程池倔喂,尤其是在Java EE應(yīng)用服務(wù)器中,一般會(huì)使用若干個(gè)線程池來(lái)處理來(lái)自客戶(hù)端的請(qǐng)求靖苇。Java中對(duì)于線程池的支持席噩,來(lái)自ThreadPoolExecutor。一些應(yīng)用服務(wù)器也確實(shí)是使用的ThreadPoolExecutor來(lái)實(shí)現(xiàn)線程池贤壁。
對(duì)于線程池的性能調(diào)優(yōu)悼枢,最重要的參數(shù)就是線程池的大小。
對(duì)于任何線程池而言脾拆,它們的工作方式幾乎都是相同的:
- 任務(wù)被投放到一個(gè)隊(duì)列中(隊(duì)列的數(shù)量不定)
- 線程從隊(duì)列中取得任務(wù)并執(zhí)行
- 線程完成任務(wù)后馒索,繼續(xù)嘗試從隊(duì)列中取得任務(wù),如果隊(duì)列為空名船,那么線程進(jìn)入等待狀態(tài)
線程池往往擁有最小和最大線程數(shù):
- 最小線程數(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)行處理百揭。
- 最大線程數(shù),當(dāng)需要處理的任務(wù)過(guò)多時(shí)蜓席,線程池能夠擁有的最大線程數(shù)器一。這樣是為了保證不會(huì)有過(guò)多的線程被創(chuàng)建出來(lái),因?yàn)榫€程的運(yùn)行需要依賴(lài)于CPU資源和其它各種資源瓮床,當(dāng)線程過(guò)多時(shí)盹舞,反而會(huì)降低性能产镐。
在ThreadPoolExecutor和其相關(guān)的類(lèi)型中,最小線程數(shù)被稱(chēng)為線程池核心規(guī)模(Core Pool Size)踢步,在其它Java應(yīng)用服務(wù)器的實(shí)現(xiàn)中癣亚,這個(gè)數(shù)量也許被稱(chēng)為最小線程數(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ù)該怎么確定鳍征,依賴(lài)以下兩個(gè)方面:
- 任務(wù)的特征
- 計(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í)的性能衷佃,得到下表:
從上面中得到一些結(jié)論:
- 當(dāng)線程數(shù)為4時(shí),達(dá)到最優(yōu)性能蹄葱,再增加線程數(shù)量時(shí)并沒(méi)有更好的性能氏义,因?yàn)榇藭r(shí)CPU的利用率已經(jīng)達(dá)到了最高锄列,在增加線程只會(huì)增加線程之間爭(zhēng)奪CPU資源的行為,因此反而降低了性能惯悠。
- 即使在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)求):
從上表中可以得到的結(jié)論:
- 在線程數(shù)量為4時(shí),性能最優(yōu)筋岛。因?yàn)榇巳蝿?wù)的類(lèi)型是計(jì)算密集型的娶视,只有4個(gè)CPU,因此線程數(shù)量為4時(shí)泉蝌,達(dá)到最優(yōu)情況歇万。
- 隨著線程數(shù)量逐漸增加,性能下降勋陪,因?yàn)榫€程之間會(huì)互相爭(zhēng)奪CPU資源,造成頻繁切換線程執(zhí)行上下文環(huán)境硫兰,而這些切換只會(huì)浪費(fèi)CPU資源诅愚。
- 性能下降的速度并不明顯,這也是因?yàn)槿蝿?wù)類(lèi)型是計(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ā)生變化:
因?yàn)槿蝿?wù)類(lèi)型是計(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正在滿(mǎn)負(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類(lèi)型的變量時(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ù)類(lèi)型和不斷的進(jìn)行性能測(cè)試誊薄。
對(duì)于ThreadPoolExecutor而言,當(dāng)任務(wù)數(shù)量達(dá)到最大時(shí)锰茉,再?lài)L試增加新的任務(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í)滿(mǎn)足以下三個(gè)條件時(shí),就會(huì)創(chuàng)建一個(gè)新的線程:
- 有任務(wù)需要被執(zhí)行
- 當(dāng)前線程池中所有的線程都處于工作狀態(tài)
- 當(dāng)前線程池的線程數(shù)沒(méi)有達(dá)到最大線程數(shù)
至于線程池會(huì)如何創(chuàng)建這個(gè)新的線程俏脊,則是根據(jù)任務(wù)隊(duì)列的種類(lèi):
- 任務(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ǔ)存子刮。
- 任務(wù)隊(duì)列是 無(wú)限隊(duì)列(Unbound Queue) 無(wú)界限的隊(duì)列可以是諸如LinkedBlockingQueue這種類(lèi)型,在這種情況下窑睁,任何被提交的任務(wù)都不會(huì)被拒絕挺峡。但是線程池會(huì)忽略最大線程數(shù)這一參數(shù),意味著線程池的最大線程數(shù)就變成了設(shè)置的最小線程數(shù)担钮。所以在使用這種隊(duì)列時(shí)橱赠,通常會(huì)將最大線程數(shù)設(shè)置的和最小線程數(shù)相等。這就相當(dāng)于使用了一個(gè)固定了線程數(shù)量的線程池箫津。
- 任務(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ì)列被占滿(mǎn)(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é)
線程池是對(duì)象池的一個(gè)有用的例子围肥,它能夠節(jié)省在創(chuàng)建它們時(shí)候的資源開(kāi)銷(xiāo)剿干。并且線程池對(duì)系統(tǒng)中的線程數(shù)量也起到了很好的限制作用。
線程池中的線程數(shù)量必須仔細(xì)的設(shè)置穆刻,否則冒然增加線程數(shù)量只會(huì)帶來(lái)性能的下降置尔。
在定制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ù)。以此類(lèi)推家破,對(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í)間如下:
對(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)是依賴(lài)于外層的i的值的颤练,所以這段代碼的執(zhí)行時(shí)間依賴(lài)于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í)間:
注意到當(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的性能:
可以發(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類(lèi)型上的一個(gè)靜態(tài)元素坚嗜,它擁有的默認(rèn)線程數(shù)量等于運(yùn)行計(jì)算機(jī)上的處理器數(shù)量。
當(dāng)調(diào)用Arrays類(lèi)上添加的新方法時(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í)的性能:
注意到當(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é)
- 當(dāng)需要處理遞歸分治算法時(shí)考婴,考慮使用ForkJoinPool贩虾。
- 仔細(xì)設(shè)置不再進(jìn)行任務(wù)劃分的閾值,這個(gè)閾值對(duì)性能有影響沥阱。
- Java 8中的一些特性會(huì)使用到ForkJoinPool中的通用線程池缎罢。在某些場(chǎng)合下,需要調(diào)整該線程池的默認(rèn)的線程數(shù)量考杉。