《為何服務(wù)器QPS上不去?Java線程調(diào)優(yōu)權(quán)威指南》要點(diǎn):
本文介紹了為何服務(wù)器QPS上不去?Java線程調(diào)優(yōu)權(quán)威指南,希望對您有用。如果有疑問,可以聯(lián)系我們。
從剛問世起,Java 的部分魅力就來自其多線程.即便在多核和多 CPU 系統(tǒng)司空見慣之前,能夠輕松編寫多線程程序也是 Java 的一個(gè)標(biāo)記性特征.
Java 性能方面的吸引力顯而易見:如果有兩個(gè) CPU 可用,那么一個(gè)應(yīng)用能夠完成的工作量可能是本來的 2 倍.當(dāng)然這是在假設(shè)任務(wù)可以分解成離散的片段的前提之下的,因?yàn)?Java 不能自動(dòng)找出算法性部分并實(shí)現(xiàn)并行化的語言.幸運(yùn)的是,今日所見之計(jì)算,往往是離散性的任務(wù).
下面探討的主題是,如何挖掘出 Java 線程和同步設(shè)施的最年夜性能.
線程池與 ThreadPoolExecutor
(Thread Pool 示意圖,來源 wikipedia)
在 Java 中,線程可以使用本身代碼來管理,也可以利用線程池,使用 ThreadPoolExecutor 并行執(zhí)行任務(wù).
在使用線程池時(shí),有一個(gè)因素非常關(guān)鍵:調(diào)節(jié)線程池的大小對獲得最好的性能至關(guān)重要.線程池的性能會(huì)隨線程池大小這一基本選擇而有所不同,在某些條件下,線程池過大對性能也有很大的晦氣影響.
所有線程池的工作方式本色是一樣的:
有一個(gè)隊(duì)列,任務(wù)被提交到這個(gè)隊(duì)列中.必定數(shù)量的線程會(huì)從該隊(duì)列中取任務(wù),然后執(zhí)行.
任務(wù)的結(jié)果可以發(fā)回客戶端(比如應(yīng)用服務(wù)器的情況下),或保留到數(shù)據(jù)庫中,或保留到某個(gè)內(nèi)部數(shù)據(jù)結(jié)構(gòu)中,等等.但是在執(zhí)行完任務(wù)后,這個(gè)線程會(huì)返回任務(wù)隊(duì)列,檢索另一個(gè)任務(wù)并執(zhí)行,如果沒有更多任務(wù)要執(zhí)行,該線程會(huì)等待下一個(gè)任務(wù).
線程池有最小線程數(shù)和最大線程數(shù).池中會(huì)有最小數(shù)目的線程隨時(shí)待命,等待任務(wù)指派給它們.因?yàn)閯?chuàng)建線程的成本非常昂揚(yáng),這樣可以提高任務(wù)提交時(shí)的整體性能:已有的線程會(huì)拿到該任務(wù)并處理.另一方面,線程需要一些系統(tǒng)資源,包括棧所需的原生內(nèi)存,如果空閑線程太多,就會(huì)消耗本來可以分配給其他進(jìn)程的資源.最大線程數(shù)還是一個(gè)必要的限流閥,防止一次執(zhí)行太多線程.
ThreadPoolExecutor 和相關(guān)的類將最小線程數(shù)稱作核心池大小,如果有個(gè)任務(wù)要執(zhí)行,而所有的并發(fā)線程都在忙于執(zhí)行另一個(gè)任務(wù),就啟動(dòng)一個(gè)新線程,直到創(chuàng)立的線程達(dá)到最大線程數(shù).
設(shè)置最年夜線程數(shù)
對于給定硬件上的給定負(fù)載,最年夜線程數(shù)設(shè)置為多少最好呢?
這個(gè)問題答復(fù)起來并不簡單;它取決于負(fù)載特性以及底層硬件.特別是,最優(yōu)線程數(shù)還與每個(gè)任務(wù)阻塞的頻率有關(guān).
為便利討論,假設(shè) JVM 有 4 個(gè) CPU 可用.我們的目標(biāo)就是最大化這 4 個(gè) CPU 的利用率.
很明顯,最大線程數(shù)至少要設(shè)置為 4.的確,除了處理這些任務(wù),JVM 中還有些線程要做其他的事,但是它們幾乎從來不會(huì)占用一個(gè)完整的 CPU.如果使用的是并發(fā)垃圾收集器,這是個(gè)例外,后臺(tái)線程必需有足夠的 CPU 來運(yùn)行,以免在處理堆這方面落后.
如果線程數(shù)多于 4,會(huì)有幫助嗎?這時(shí)就要看負(fù)載特性了.考慮最簡單的情況,假定任務(wù)都是計(jì)算密集型的:沒有外部網(wǎng)絡(luò)調(diào)用(比如不會(huì)拜訪數(shù)據(jù)庫),也不會(huì)激烈地競爭內(nèi)部鎖.在使用模實(shí)體管理器(mock entity manager)的情況下,股價(jià)歷史批處理程序就是一個(gè)這樣的應(yīng)用:實(shí)體上的數(shù)據(jù)完全可以并行計(jì)算.
下面就使用線程池計(jì)算一下 10,000 個(gè)模股票實(shí)體的歷史,假設(shè)機(jī)器有 4 個(gè) CPU,使用不同的線程數(shù)測試,具體的性能數(shù)據(jù)見表1.如果池中只有 1 個(gè)線程,計(jì)算數(shù)據(jù)集必要 255.6 秒;用 4 個(gè)線程,則只必要 77 秒.如果線程數(shù)超過 4 個(gè),隨著線程數(shù)的增加,必要的時(shí)間會(huì)稍多一些.
表1:計(jì)算 10,000 個(gè)模的價(jià)格歷史所需時(shí)間
如果應(yīng)用中的任務(wù)是完全并行的,則在有 2 個(gè)線程時(shí),“與基準(zhǔn)的百分比”這列為 50%;在有 4 個(gè)線程時(shí),這列為 25%.但是這種完全線性的比例不可能出現(xiàn),原因有這么幾點(diǎn):如果沒有其他線程贊助,這些線程必須自己來協(xié)同,實(shí)現(xiàn)從運(yùn)行隊(duì)列中選取任務(wù)(一般而言,通常會(huì)有更多同步).到了使用 4 個(gè)線程的時(shí)候,系統(tǒng)會(huì) 100% 消耗可用的 CPU,盡管機(jī)器可能沒有運(yùn)行其他用戶級的應(yīng)用,但是會(huì)有各種系統(tǒng)級的進(jìn)程進(jìn)來,并使用 CPU,從而使得 JVM 無法 100% 地使用所有 CPU 周期.
盡管如此,這個(gè)應(yīng)用在伸縮性方面表現(xiàn)還不錯(cuò),且即使池中的線程數(shù)被顯著高估,性能損失也比擬輕微.
不過在其他情況下,性能損失可能會(huì)很大.在 Servlet 版的股票歷史計(jì)算程序中,線程太多的話,影響會(huì)很大,如表2 所示.應(yīng)用服務(wù)器分別配置成不同的線程數(shù),有一個(gè)負(fù)載生成器會(huì)向該服務(wù)器發(fā)送 20 個(gè)同步的(simultaneous)哀求.
表2:每秒通過 Servlet 的操作
鑒于應(yīng)用服務(wù)器有 4 個(gè) CPU 可用,最年夜吞吐量可以通過將池中的線程數(shù)設(shè)置為 4 來實(shí)現(xiàn).
在研究性能問題時(shí)確定瓶頸在哪兒比較重要.在這個(gè)例子中,瓶頸很明顯是 CPU:4 個(gè)線程時(shí),CPU 利用率為 100%.不過加入更多線程的影響其實(shí)很小,至少當(dāng)線程數(shù)是本來的 8 倍時(shí)才會(huì)有明顯的差別.
如果瓶頸在其他地方呢?這個(gè)例子有點(diǎn)分歧尋常,任務(wù)完全是 CPU 密集型的:沒有 I/O.一般來說,線程有可能會(huì)調(diào)用數(shù)據(jù)庫,或者把輸出寫到某個(gè)地方,甚至是會(huì)合其他某些資源.在那種情況下,瓶頸未必是 CPU,而可能是外部資源.
對于此類情況,添加線程非常有害.雖然我們常常說數(shù)據(jù)庫總是瓶頸,但是瓶頸可能是任何外部資源.
仍以股票 Servlet 為例,我們把目標(biāo)變一下:如果目標(biāo)是最大限度地利用負(fù)載生成器機(jī)器,又會(huì)如何,是簡單地運(yùn)行一個(gè)多線程的 Java 法式嗎?
在典型的用法中,如果 Servlet 應(yīng)用運(yùn)行在一個(gè)有 4 個(gè) CPU 的應(yīng)用服務(wù)器上,而且只有一個(gè)客戶端哀求數(shù)據(jù),那么,應(yīng)用服務(wù)器大約會(huì) 25% 忙碌,客戶端機(jī)器幾乎總是空閑的.如果負(fù)載增加到 4 個(gè)并發(fā)的客戶端,則應(yīng)用服務(wù)器會(huì) 100% 忙碌,客戶端機(jī)器可能只有 20% 的忙碌.
只看客戶端,很容易得出這樣的結(jié)論:因?yàn)榭蛻舳?CPU 大量過剩,應(yīng)該可以添加更多線程,改善其伸縮性.表3 說明了這種假設(shè)何其錯(cuò)誤:當(dāng)客戶端再參加一些線程時(shí),性能會(huì)受到極大影響.
表3:計(jì)算模擬股票價(jià)格歷史的平均響應(yīng)時(shí)間
在這個(gè)例子中,一旦應(yīng)用服務(wù)器成為瓶頸(也便是說,線程數(shù)達(dá)到 4 個(gè)時(shí)),向服務(wù)器增加負(fù)載是非常有害的——即使只是在客戶端加了幾個(gè)線程.
這個(gè)例子看上去可能有點(diǎn)有意為之.如果服務(wù)器已經(jīng)是 CPU 密集型的,誰還會(huì)加入更多線程呢?之所以使用這個(gè)例子,只是因?yàn)樗菀桌斫?而且僅使用了 Java 程序.這意味著讀者本身就可以運(yùn)行,并理解它是如何工作的,而不必設(shè)置數(shù)據(jù)庫連接、模式(Schema)等選項(xiàng).
需要指出的是,對于還要向 CPU 密集型或 I/O 密集型的機(jī)器發(fā)送數(shù)據(jù)庫哀求的應(yīng)用服務(wù)器而言,同樣的原則也成立.你可能只關(guān)注應(yīng)用服務(wù)器 CPU,看到小于 100% 就感覺不錯(cuò);看到有多余的哀求要處理,就假定增加應(yīng)用服務(wù)器的線程數(shù)是個(gè)不錯(cuò)的主意.結(jié)果會(huì)讓人大吃一驚,因?yàn)樵谀欠N情況下增加線程數(shù),實(shí)際上會(huì)降低整體吞吐量(影響可能非常明顯),就像前面那個(gè)只有 Java 程序的例子一樣.
了解系統(tǒng)真正瓶頸之地點(diǎn)非常重要的另一個(gè)原因是:
如果還向瓶頸處增加負(fù)載,性能會(huì)顯著下降.
相反,如果減少了當(dāng)前瓶頸處的負(fù)載,性能可能會(huì)上升.
這也是設(shè)計(jì)自我調(diào)優(yōu)的線程池非常困難的原因所在.線程池通常對掛起了多少工作有所了解,甚至有多少 CPU 可用也可以知道,但是它們通常看不到所在的整個(gè)環(huán)境的其他方面.因此,當(dāng)有工作掛起時(shí),增加線程(這是很多自我調(diào)優(yōu)的線程池的一個(gè)核心特性,也是 ThreadPoolExecutor 的某些配置)往往是完全差錯(cuò)的.
遺憾的是,設(shè)置最大線程數(shù)更像是藝術(shù)而非科學(xué),原因也在于此.在現(xiàn)實(shí)中,測試條件下自我調(diào)優(yōu)的線程池會(huì)實(shí)現(xiàn)可能性能的 80%~90%;并且就算高估了所需線程數(shù),也可能只有很小的損失.但是當(dāng)設(shè)置線程數(shù)大小這方面出了問題時(shí),系統(tǒng)可能會(huì)在很大程度上出現(xiàn)問題.就此而言,充足的測試仍然非常關(guān)鍵.
設(shè)置最小線程數(shù)
一旦確定了線程池的最大線程數(shù),就該確定所需的最小線程數(shù)了.大部分情況下,開發(fā)者會(huì)直截了本地將它們設(shè)置為同一個(gè)值.
將最小線程數(shù)設(shè)置為其他某個(gè)值(比如 1),出發(fā)點(diǎn)是防止系統(tǒng)創(chuàng)建太多線程,以節(jié)省系統(tǒng)資源.因?yàn)槊總€(gè)線程都需要一定量的內(nèi)存,特別是線程的棧.根據(jù)一般原則之一,所設(shè)置的系統(tǒng)大小應(yīng)該能夠處理預(yù)期的最大吞吐量,而要達(dá)到最大吞吐量,系統(tǒng)將需要?jiǎng)?chuàng)建所有那些線程.如果系統(tǒng)做不到這一點(diǎn),那選擇一個(gè)最小線程數(shù)也沒什么贊助:如果系統(tǒng)達(dá)到了這樣的條件——需要按所設(shè)置的最大線程數(shù)啟動(dòng)所有線程,而又無法滿足,系統(tǒng)將陷入困境.創(chuàng)建最終可能會(huì)需要的所有線程,并確保系統(tǒng)可以處理預(yù)期的最大負(fù)載,這樣更好.
另一方面,指定一個(gè)最小線程數(shù)的負(fù)面影響相當(dāng)小.如果進(jìn)程一啟動(dòng)就有很多任務(wù)要執(zhí)行,會(huì)有負(fù)面影響:這時(shí)線程池需要?jiǎng)?chuàng)建新線程才能處理任務(wù).創(chuàng)建線程對性能不利,這也是為什么起初需要線程池的原因,不過這種一次性的本錢在性能測試中很可能察覺不到.
在批處理應(yīng)用中,線程是在創(chuàng)建線程池時(shí)分配(如果將最大線程數(shù)和最小線程數(shù)設(shè)置為同一個(gè)值,就會(huì)出現(xiàn)這種情況),還是按需分配,并不重要:執(zhí)行應(yīng)用所需的時(shí)間是一樣的.在其他應(yīng)用中,新線程可能會(huì)在預(yù)熱階段分配(分配線程的總時(shí)間還是一樣的),對性能的影響可以忽略不計(jì).即使線程創(chuàng)建產(chǎn)生在可以測量的周期內(nèi),只要此類操作有限,也很有可能測不出來.
另一個(gè)可以調(diào)優(yōu)的地方是線程的空閑時(shí)間.比如,某個(gè)線程池的最小線程數(shù)為 1,最大線程數(shù)為 4.現(xiàn)在假設(shè)一般會(huì)有一個(gè)線程在執(zhí)行,處理一個(gè)任務(wù);然后應(yīng)用進(jìn)入這樣一個(gè)循環(huán):每 15 秒,負(fù)載平均有 2 個(gè)任務(wù)要執(zhí)行.第一次進(jìn)入這個(gè)循環(huán)時(shí),線程池會(huì)創(chuàng)建第 2 個(gè)線程,此時(shí),讓這個(gè)新創(chuàng)建的線程在池中至少留存一段時(shí)間是有意義的.我們希望避免這種情況:第 2 個(gè)線程創(chuàng)建出來后,5 秒鐘內(nèi)結(jié)束其任務(wù),空閑 5 秒,然后退出了.而 5 秒之后又需要為下一個(gè)任務(wù)創(chuàng)建一個(gè)線程.一般而言,對于線程數(shù)為最小值的線程池,一個(gè)新線程一旦創(chuàng)建出來,至少應(yīng)該留存幾分鐘,以處理任何負(fù)載飆升.如果任務(wù)到達(dá)率有個(gè)比擬好的模型,可以基于這個(gè)模型設(shè)置空閑時(shí)間.另外,空閑時(shí)間應(yīng)該以分鐘計(jì),而且至少在 10 分鐘到 30 分鐘之間.
留存一些空閑線程,對應(yīng)用性能的影響通常微乎其微.一般而言,線程對象本身不會(huì)占用大量的堆空間.除非線程堅(jiān)持了大量的線程局部存儲(chǔ),或者線程的 Runnable 對象引用了大量內(nèi)存.不管是哪種情況,釋放這樣的線程都會(huì)顯著減少堆中的活數(shù)據(jù)(這反過來又會(huì)影響 GC 的效率).
不過對線程池而言,這些情況并不多見.當(dāng)池中的某個(gè)對象空閑時(shí),它就不應(yīng)該再引用任何 Runnable 對象(如果引用了,就說明哪個(gè)地方有 bug 了).根據(jù)線程池的實(shí)現(xiàn)情況,線程局部變量可能會(huì)繼續(xù)保存;盡管在某些情況下,線程局部變量可以有效促成對象重用,但是那些線程局部對象所占用的總的內(nèi)存量,應(yīng)該加以限制.
對于可能會(huì)增長到非常大(當(dāng)然也是運(yùn)行在規(guī)模很大的機(jī)器上)的線程池,這個(gè)規(guī)則有個(gè)重要的特例.舉例而言,假設(shè)某個(gè)線程池的任務(wù)隊(duì)列預(yù)計(jì)平均有 20 個(gè)任務(wù),那么 20 便是很好的最小值.再假設(shè)這個(gè)池運(yùn)行在一個(gè)規(guī)模很大的機(jī)器上,它被設(shè)計(jì)為可以處理 2000 個(gè)任務(wù)的峰值負(fù)載.如果在池中留存 2000 個(gè)空閑線程,則當(dāng)只有 20 個(gè)任務(wù)時(shí),對性能會(huì)有所影響:如果只有核心的 20 個(gè)線程忙碌,與有 1980 個(gè)空閑線程相比,前者的吞吐量可能是后者的 50%.線程池一般不會(huì)遇到這樣的問題,但如果遇到了,那就應(yīng)該確認(rèn)一下池的合適的最小值了.
線程池任務(wù)年夜小
等待線程池來執(zhí)行的任務(wù)會(huì)被保留到某類隊(duì)列或列表中;當(dāng)池中有線程可以執(zhí)行任務(wù)時(shí),就從隊(duì)列中拉出一個(gè).這會(huì)導(dǎo)致不均衡:隊(duì)列中任務(wù)的數(shù)量有可能變得非常大.如果隊(duì)列太大,其中的任務(wù)就必須等待很長時(shí)間,直到前面的任務(wù)執(zhí)行完畢.例如一個(gè)超負(fù)荷的 Web 服務(wù)器:如果有個(gè)任務(wù)被添加到隊(duì)列中,但是沒有在 3 秒鐘內(nèi)執(zhí)行,那用戶很可能就去看另一個(gè)頁面了.
因此,對于容納等待執(zhí)行任務(wù)的隊(duì)列,線程池通常會(huì)限制其大小.根據(jù)用于容納等待執(zhí)行任務(wù)的數(shù)據(jù)結(jié)構(gòu)的分歧,ThreadPoolExecutor 會(huì)有分歧的處理方式(下一節(jié)會(huì)更詳細(xì)地介紹);應(yīng)用服務(wù)器通常有一些調(diào)優(yōu)參數(shù),可以調(diào)整這個(gè)值.
就像線程池的最大線程數(shù),這個(gè)值應(yīng)該如何調(diào)優(yōu),并沒有一個(gè)通用的規(guī)則.舉例而言,假設(shè)某個(gè)應(yīng)用服務(wù)器的任務(wù)隊(duì)列中有 30 000 個(gè)任務(wù),有 4 個(gè) CPU 可用,如果執(zhí)行一個(gè)任務(wù)只必要 50 毫秒,同時(shí)假設(shè)這段時(shí)間不會(huì)到達(dá)新任務(wù),則清空任務(wù)隊(duì)列必要 6 分鐘.這可能是可以接受的,但如果每個(gè)任務(wù)必要 1 秒鐘,則清空任務(wù)隊(duì)列必要 2 小時(shí).因此,若要確定使用哪個(gè)值能帶來我們必要的性能,測量我們的真實(shí)應(yīng)用是唯一的途徑.
不管是哪種情況,如果達(dá)到了隊(duì)列數(shù)限制,再添加任務(wù)就會(huì)失敗.ThreadPoolExecutor 有一個(gè)rejectedExecution 辦法,用于處理這種情況(默認(rèn)會(huì)拋出 RejectedExecutionException).應(yīng)用服務(wù)器會(huì)向用戶返回某個(gè)錯(cuò)誤:或者是 HTTP 狀態(tài)碼 500(內(nèi)部錯(cuò)誤),或者是 Web 服務(wù)器捕獲錯(cuò)誤,并向用戶給出合理的解釋消息——其中后者是最理想的.
設(shè)置 ThreadPoolExecutor 的年夜小
線程池的一般行為是這樣的:
創(chuàng)立時(shí)準(zhǔn)備好最小數(shù)目的線程,如果來了一個(gè)任務(wù),而此時(shí)所有的線程都在忙碌,則啟動(dòng)一個(gè)新線程(一直到達(dá)到最大線程數(shù)),任務(wù)就可以立即執(zhí)行了.
不然,任務(wù)被加入等待隊(duì)列,如果任務(wù)隊(duì)列中已經(jīng)無法加入新任務(wù),則拒絕之.
不過,ThreadPoolExecutor的表現(xiàn)可能和這種尺度行為有點(diǎn)不同.
根據(jù)所選任務(wù)隊(duì)列的類型,ThreadPoolExecutor 會(huì)決定何時(shí)啟動(dòng)一個(gè)新線程.有以下 3 種可能.
1. SynchronousQueue
如果 ThreadPoolExecutor 搭配的是 SynchronousQueue,則線程池的行為會(huì)和我們預(yù)計(jì)的一樣,它會(huì)考慮線程數(shù):如果所有的線程都在忙碌,而且池中的線程數(shù)尚未達(dá)到最大,則新任務(wù)會(huì)啟動(dòng)一個(gè)新線程.然而,這個(gè)隊(duì)列沒方法保存等待的任務(wù):如果來了一個(gè)任務(wù),創(chuàng)建的線程數(shù)已經(jīng)達(dá)到最大值,而且所有線程都在忙碌,則新的任務(wù)總是會(huì)被拒絕.所以如果只是管理少量的任務(wù),這是個(gè)不錯(cuò)的選擇;但是對于其他情況,就不合適了.該類文檔建議將最大線程數(shù)指定為一個(gè)非常大的值,如果任務(wù)完全是 CPU 密集型的,這可能行得通,但是我們會(huì)看到,其他情況下可能會(huì)適得其反.另一方面,如果需要一個(gè)容易調(diào)整線程數(shù)的線程池,這種選擇會(huì)更好.
2. 無界隊(duì)列
如果 ThreadPoolExecutor 搭配的是無界隊(duì)列(好比 LinkedBlockedingQueue),則不會(huì)拒絕任何任務(wù)(因?yàn)殛?duì)列大小沒有限制).這種情況下,ThreadPoolExecutor 最多僅會(huì)按最小線程數(shù)創(chuàng)建線程,也就是說,最大線程池大小被忽略了.如果最大線程數(shù)和最小線程數(shù)相同,則這種選擇和配置了固定線程數(shù)的傳統(tǒng)線程池運(yùn)行機(jī)制最為接近.
3. 有界隊(duì)列
在決定何時(shí)啟動(dòng)一個(gè)新線程時(shí),使用了有界隊(duì)列(如 ArrayBlockingQueue)的ThreadPoolExecutor 會(huì)采用一個(gè)非常復(fù)雜的算法.好比,假設(shè)池的核心大小為 4,最大為 8,所用的 ArrayBlockingQueue 最大為 10.隨著任務(wù)到達(dá)并被放到隊(duì)列中,線程池中最多會(huì)運(yùn)行 4 個(gè)線程(也就是核心大小).即使隊(duì)列完全填滿,也就是說有 10 個(gè)處于等待狀態(tài)的任務(wù),ThreadPoolExecutor 也是只利用 4 個(gè)線程.
如果隊(duì)列已滿,而又有新任務(wù)加進(jìn)來,此時(shí)才會(huì)啟動(dòng)一個(gè)新線程.這里不會(huì)因?yàn)殛?duì)列已滿而拒絕該任務(wù),相反,會(huì)啟動(dòng)一個(gè)新線程.新線程會(huì)運(yùn)行隊(duì)列中的第一個(gè)任務(wù),為新來的任務(wù)騰出空間.
在這個(gè)例子中,池中會(huì)有 8 個(gè)線程(最大線程數(shù))的唯一一種情形是,有 7 個(gè)任務(wù)正在處置,隊(duì)列中有 10 個(gè)任務(wù),這時(shí)又來了一個(gè)新任務(wù).
這個(gè)算法背后的理念是,該池大部分時(shí)間僅使用核心線程(4 個(gè)),即使有適量的任務(wù)在隊(duì)列中等待運(yùn)行.這時(shí)線程池就可以用作節(jié)流閥(這是很有好處的).如果積壓的哀求變得非常多,該池就會(huì)嘗試運(yùn)行更多線程來清理;這時(shí)第二個(gè)節(jié)流閥——最大線程數(shù)——就起作用了.
如果系統(tǒng)沒有外部瓶頸,CPU 周期也足夠,那一切就都辦理了:加入新的線程可以更快地處理任務(wù)隊(duì)列,并很可能使其回到預(yù)期大小.該算法所適合的用例當(dāng)然也很容易構(gòu)造.
另一方面,該算法并不知道隊(duì)列為何會(huì)突然增大.如果是因?yàn)橥獠康娜蝿?wù)積壓,那么加入更多線程并非明智之舉.如果該線程所運(yùn)行的機(jī)器已經(jīng)是 CPU 密集型的,加入更多線程也是錯(cuò)誤的.只有當(dāng)任務(wù)積壓是由額外的負(fù)載進(jìn)入系統(tǒng)(比如有更多客戶端發(fā)起 HTTP 哀求)引發(fā)時(shí),增加線程才是有意義的.(如果是這種情況,為什么要等到隊(duì)列已經(jīng)接近某個(gè)邊界時(shí)才增加呢?如果有額外的資源供更多線程使用,則盡早增加線程將改善系統(tǒng)的整體性能.)
對于上面提到的每一種選擇,都能找到很多支持或反對的論據(jù),但是在嘗試獲得最好的性能時(shí),可以應(yīng)用 KISS 原則“Keep it simple, stupid”.可以將 ThreadPoolExecutor 的核心線程數(shù)和最大線程數(shù)設(shè)為相同,在保留等待任務(wù)方面,如果適合使用無界任務(wù)列表,則選擇 LinkedBlockingQueue;如果適合使用有界任務(wù)列表,則選擇 ArrayBlockingQueue.
快速小結(jié)
有時(shí)對象池也是不錯(cuò)的選擇,線程池就是情形之一:線程初始化的本錢很高,線程池使得系統(tǒng)上的線程數(shù)容易控制.
線程池必需仔細(xì)調(diào)優(yōu).盲目向池中添加新線程,在某些情況下對性能會(huì)有不利影響.
在使用 ThreadPoolExecutor 時(shí),選擇更簡單的選項(xiàng)通常會(huì)帶來最好的、最能預(yù)見的性能.
ForkJoinPool
Java 7 引入了一個(gè)新的線程池:ForkJoinPool 類.這個(gè)類看上去和其他任何線程池都很像;和 ThreadPoolExecutor 類一樣,它也實(shí)現(xiàn)了 Executor 和 ExecutorService 接口.在支持這些接口方面,ForkJoinPool 在內(nèi)部會(huì)使用一個(gè)無界任務(wù)列表,供構(gòu)造器中所指定數(shù)目(如果所選的是無參構(gòu)造器,則為該機(jī)器上的 CPU 數(shù))的線程來運(yùn)行.
ForkJoinPool 類是為配合分治算法的使用而設(shè)計(jì)的:任務(wù)可以遞歸地分解為子集.這些子集可以并行處理,然后每個(gè)子集的結(jié)果被歸并到一個(gè)結(jié)果中.一個(gè)經(jīng)典的例子便是快速排序算法.
分治算法的重點(diǎn)是,算法會(huì)創(chuàng)建大量的任務(wù),而這些任務(wù)只有相對較少的幾個(gè)線程來管理.比如要排序一個(gè)包括 1000 萬個(gè)元素的數(shù)組.首先創(chuàng)建單獨(dú)的任務(wù)來執(zhí)行 3 個(gè)操作:排序包括前面 500 萬個(gè)元素的子數(shù)組,再排序包括后面 500 萬個(gè)元素的子數(shù)組,然后合并兩個(gè)子數(shù)組.
類似地,要排序包括 500 萬個(gè)元素的數(shù)組,可以分別排序包括 250 萬個(gè)元素的子數(shù)組,然后合并子數(shù)組.一直遞歸到某個(gè)點(diǎn)(比如到子數(shù)組包括 10 個(gè)元素時(shí)),這時(shí)在子數(shù)組上使用插入排序直接處理更為高效.下圖演示了其工作方式.
遞歸快速排序中的任務(wù)
最后會(huì)有超過 100 萬個(gè)任務(wù)來排序葉子數(shù)組(每個(gè)數(shù)組少于 10 個(gè)元素,這時(shí)候直接排序即可;這里只是用 10 來舉例,實(shí)際值會(huì)隨實(shí)現(xiàn)的不同而有所變化.在目前的 Java 庫實(shí)現(xiàn)中,當(dāng)數(shù)組少于 47 個(gè)元素時(shí) ,會(huì)采用插入排序).必要 50 多萬個(gè)任務(wù)來歸并那些排好序的數(shù)組,歸并下一級又必要 25 萬個(gè)任務(wù),依此類推.最后會(huì)有 2,097,151 個(gè)任務(wù).
更大的問題是,所有任務(wù)都要等待它們派生出的任務(wù)先完成,然后才能完成.對于元素?cái)?shù)少于 10 的子數(shù)組,直接對它們做排序的任務(wù)必需優(yōu)先完成;在此之后,創(chuàng)建相應(yīng)子數(shù)組的任務(wù)才能歸并其子數(shù)組的結(jié)果,依此類推:鏈條上的所有任務(wù)依次歸并,直到整個(gè)數(shù)組被歸并為最終的、排序好的結(jié)果.
因?yàn)楦溉蝿?wù)必需等待子任務(wù)完成,所以無法使用 ThreadPoolExecutor 高效實(shí)現(xiàn)這個(gè)算法.ThreadPoolExecutor 內(nèi)的線程無法將另一個(gè)任務(wù)添加到隊(duì)列中并等待其完成:一旦線程進(jìn)入等待狀態(tài),就無法使用該線程執(zhí)行它的某個(gè)子任務(wù)了.另一方面,ForkJoinPool 則允許其中的線程創(chuàng)建新任務(wù),之后掛起當(dāng)前的任務(wù).當(dāng)任務(wù)被掛起時(shí),線程可以執(zhí)行其他等待的任務(wù).
舉個(gè)簡單的例子:比如說有個(gè) double 數(shù)組,我們想計(jì)算數(shù)組中小于 0.5 的元素的個(gè)數(shù).順序掃描比擬簡單(可能還有優(yōu)勢,本節(jié)后面會(huì)看到),但是為了說明問題,現(xiàn)在把數(shù)組劃分為子數(shù)組,并行掃描(模仿更復(fù)雜的快速排序和其他分治算法).使用 ForkJoinPool 實(shí)現(xiàn)這一功能的代碼如下:
fork 和 join 方法是這里的關(guān)鍵:沒有這些方法,實(shí)現(xiàn)這類遞歸會(huì)非常痛苦(在由ThreadPoolExecutor 執(zhí)行的任務(wù)中就沒有這些方法).這些方法使用了一系列內(nèi)部的、從屬于每個(gè)線程的隊(duì)列來把持任務(wù),并將線程從執(zhí)行一個(gè)任務(wù)切換到執(zhí)行另一個(gè).細(xì)節(jié)對開發(fā)者是透明的,不過如果對算法感興趣,其代碼讀起來也很有意思.這里我們重點(diǎn)關(guān)注的是性能:ForkJoinPool和 ThreadPoolExecutor 這兩個(gè)類之間有什么權(quán)衡取舍呢?
首先,fork/join 范型所實(shí)現(xiàn)的掛起,使得所有任務(wù)可以交由少量的線程執(zhí)行.使用該示例代碼計(jì)算包括 1000 萬個(gè)元素的數(shù)組中的 double 值,會(huì)創(chuàng)建 200 多萬個(gè)任務(wù),但這些任務(wù)很容易交由少量一些線程執(zhí)行(甚至是一個(gè)線程,如果這對運(yùn)行測試的機(jī)器有意義的話).使用ThreadPoolExecutor 運(yùn)行類似算法則需要 200 多萬個(gè)線程,因?yàn)槊總€(gè)線程必須等待其子任務(wù)完成,而且那些子任務(wù)只有在池中有可用線程時(shí)才能完成.有了 fork/join,我們可以實(shí)現(xiàn)用ThreadPoolExecutor 無法實(shí)現(xiàn)的算法,這就是一個(gè)性能優(yōu)勢.
盡管分治技術(shù)非常強(qiáng)年夜,但是濫用也可能會(huì)導(dǎo)致性能變糟糕.在計(jì)數(shù)的這個(gè)例子中,可以使用一個(gè)線程來掃描數(shù)組并計(jì)數(shù),雖然未必能像并行運(yùn)行 fork/join 算法那樣快.然而,把原數(shù)組劃分為多個(gè)斷,使用 ThreadPoolExecutor 讓多個(gè)線程掃描數(shù)組,也是非常容易的:
在一個(gè)配備了 4 個(gè) CPU 的機(jī)器上,這段代碼可以充足利用所有可用的 CPU,并行處理數(shù)組,同時(shí)避免像 fork/join 示例中那樣創(chuàng)建和排隊(duì)處理 200 萬個(gè)任務(wù).可以預(yù)見性能會(huì)快些,如表4 所示.
表4:對1億個(gè)元素做計(jì)數(shù)處置
測試所用的機(jī)器有 4 個(gè) CPU,4 GB 固定內(nèi)存.測試中,ThreadPoolExecutor 完全不必要 GC,而每個(gè) ForkJoinPool 測試會(huì)花 1.2 秒在 GC 上.對于性能差異而言,這一點(diǎn)所占比重很大,但這并非故事的全部:創(chuàng)建和管理任務(wù)對象的開銷也會(huì)傷害 ForkJoinPool 的性能.如果有類似的替代方案,很可能會(huì)更快,至少在這個(gè)簡單的例子中是這樣.
ForkJoinPool 還有一個(gè)額外的特性,它實(shí)現(xiàn)了工作竊取(work-stealing).這基本上就是一個(gè)實(shí)現(xiàn)細(xì)節(jié)了;這意味著池中的每個(gè)線程都有本身所創(chuàng)建任務(wù)的隊(duì)列.線程會(huì)優(yōu)先處理本身隊(duì)列中的任務(wù),但如果這個(gè)隊(duì)列已空,它會(huì)從其他線程的隊(duì)列中竊取任務(wù).其結(jié)果是,即使 200 萬個(gè)任務(wù)中有一個(gè)需要很長的執(zhí)行時(shí)間,ForkJoinPool 中的其他線程也可以完成其余的隨便什么任務(wù).ThreadPoolExecutor 則不會(huì)這樣:如果一個(gè)任務(wù)需要很長的時(shí)間,其他線程并不能處理額外的工作.
示例代碼先是計(jì)算數(shù)組中小于 0.5 的元素?cái)?shù).此外,如果代碼中還計(jì)算了一個(gè)新的值,并保留到數(shù)組中了,會(huì)發(fā)生什么?一個(gè)沒有實(shí)際意義但卻是 CPU 密集型的實(shí)現(xiàn)可以執(zhí)行以下代碼:
因?yàn)橛?j 索引的外部循環(huán)是基于元素在數(shù)組中的位置處理的,所以計(jì)算所必要的時(shí)間和元素位置成比例關(guān)系:計(jì)算 d[0] 的值必要很長的時(shí)間,而計(jì)算 d[d.length - 1] 則只必要很短的時(shí)間.
簡單地將數(shù)組分為 4 段,用 ThreadPoolExecutor 處理,這個(gè)測試有一個(gè)不好的地方.計(jì)算數(shù)組第 1 段的線程必要很長的時(shí)間才能完成,比處理數(shù)組最后一段的第 4 個(gè)線程所需的時(shí)間長得多.一旦第 4 個(gè)線程結(jié)束,它就會(huì)處于空閑狀態(tài):所有線程都要等第 1 個(gè)線程完成它的耗時(shí)較長的任務(wù).
在粒度為200萬個(gè)任務(wù)的 ForkJoinPool 中,盡管有一個(gè)線程會(huì)忙于針對數(shù)組中的前 10 個(gè)元素的非常耗時(shí)的計(jì)算,但是其余線程都有工作可做,在大部分測試過程中,CPU 會(huì)堅(jiān)持忙碌.區(qū)別如表5 所示.
表5:處理包括10 000個(gè)元素的數(shù)組的時(shí)間
當(dāng)池中只有一個(gè)線程時(shí),計(jì)算所花的時(shí)間基本一樣.這可以理解:不管池如何實(shí)現(xiàn),計(jì)算量是一樣的;而且因?yàn)槟切┯?jì)算絕對不會(huì)并行進(jìn)行,所以可以預(yù)計(jì)它們所需的時(shí)間是一樣的(盡管創(chuàng)建 200 萬個(gè)任務(wù)會(huì)有少量開銷).但是當(dāng)池中包括 4 個(gè)線程時(shí),ForkJoinPool 中任務(wù)的粒度會(huì)帶來一個(gè)決定性的優(yōu)勢:幾乎在測試的整個(gè)過程中,都能保持 CPU 的忙碌狀態(tài).
這種情況就叫作“不均衡”,因?yàn)槟承┤蝿?wù)所花的時(shí)間比其他任務(wù)長(因此前面例子中的任務(wù)可以說是“均衡的”).一般而言,如果任務(wù)是均衡的,使用分段的 ThreadPoolExecutor 性能更好;而如果任務(wù)是不均衡的,則使用 ForkJoinPool 性能更好.
還有一個(gè)更微妙的性能方面的建議:請仔細(xì)考慮 fork/join 范型應(yīng)該在哪個(gè)點(diǎn)結(jié)束遞歸.在這個(gè)例子中,我信手選擇了當(dāng)數(shù)組大小小于 10 時(shí)結(jié)束.如果在數(shù)組大小為 250 萬時(shí)停止遞歸,那么 fork/join 測試(在搭載 4 個(gè) CPU 的機(jī)器上,處置 1000 萬個(gè)元素的平衡代碼)會(huì)只創(chuàng)建 4 個(gè)任務(wù),其性能基本和 ThreadPoolExecutor 一樣.
另一方面,對于這個(gè)例子,在非平衡的測試中,繼續(xù)遞歸會(huì)有更好的性能,即使創(chuàng)立更多任務(wù).表6 給出了一些有代表性的數(shù)據(jù)點(diǎn).
表6:處理包括10 000個(gè)元素的數(shù)組的時(shí)間
Java 8 向 Java 中引入了自動(dòng)并行化特定種類代碼的才能.這種并行化就依賴于 ForkJoinPool 類的使用.Java 8 為這個(gè)類加入了一個(gè)新特性:
一個(gè)公共的池,可供任何沒有顯式指定給某個(gè)特定池的 ForkJoinTask 使用.
這個(gè)公共池是 ForkJoinPool 類的一個(gè) static 元素,其大小默認(rèn)設(shè)置為目標(biāo)機(jī)器上的處置器數(shù).
這種并行化在 Arrays 類的很多新辦法中都會(huì)發(fā)生,包括使用并行快速排序處理數(shù)組的辦法,操作數(shù)組的每個(gè)元素的辦法,等等.在 Java 8 的 Stream 特性中也有應(yīng)用,支持在集合中的每個(gè)元素上(或順序或并行地)執(zhí)行操作.這里不討論Stream 的一些基本的性能特性,而是看一下 Stream 是如何自動(dòng)地并行處理的.
給定一個(gè)包括一系列整型數(shù)的集合,下列代碼會(huì)計(jì)算與給定整型數(shù)匹配的股票代號(hào)的價(jià)格歷史:
這段代碼會(huì)并行計(jì)算模擬價(jià)格歷史:forEach 辦法將為數(shù)組列表中的每個(gè)元素創(chuàng)建一個(gè)任務(wù),每個(gè)任務(wù)都會(huì)由公共的 ForkJoinTask 池處理.它在功能上與開始所做的測試是等價(jià)的,那個(gè)測試是用一個(gè)線程池來并行計(jì)算價(jià)格歷史(不過與顯式使用線程池相比,這段代碼寫起來更容易).
設(shè)置 ForkJoinTask 池的年夜小和設(shè)置其他任何線程池同樣重要.默認(rèn)情況下,公共池的線程數(shù)等于機(jī)器上的 CPU 數(shù).如果在同一機(jī)器上運(yùn)行著多個(gè) JVM,則應(yīng)限制這個(gè)線程數(shù),以防這些 JVM 彼此爭用 CPU.類似地,如果 Servlet 代碼會(huì)執(zhí)行某個(gè)并行任務(wù),而我們想確保 CPU 可供其他任務(wù)使用,可以考慮減小公共池的線程數(shù).另外,如果公共池中的任務(wù)會(huì)阻塞等待 I/O 或其他數(shù)據(jù),也可以考慮增年夜線程數(shù).
這個(gè)值可以通過設(shè)置系統(tǒng)屬性 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 來指定.
前面的表1 中,曾經(jīng)對比過線程數(shù)對并行計(jì)算股票歷史價(jià)格的影響.表7 使用共同的ForkJoinPool(將 parallelism 系統(tǒng)屬性設(shè)置為給定的值)將那個(gè)數(shù)據(jù)與 forEach 構(gòu)造作了比擬.
表7:計(jì)算10 000支模擬股票價(jià)格歷史所需的時(shí)間
默認(rèn)情況下,公共池有 4 個(gè)線程(在這個(gè)配置了 4 個(gè) CPU 的機(jī)器上),所以表中的第 3 行為一般情況.在線程數(shù)為 1 和 2 時(shí),這類結(jié)果會(huì)讓性能工程師很不開心:它們看上去很不協(xié)調(diào),而當(dāng)某一項(xiàng)測試出現(xiàn)這樣的情況時(shí),最常見的原因是測試錯(cuò)誤.這里的原因是 forEach 辦法有些奇怪的行為:它使用了一個(gè)線程執(zhí)行語句,還使用了公共池中的線程處理來自 Stream 的數(shù)據(jù).即使在第 1 個(gè)測試中,公共池也是配置為使用一個(gè)線程,總的還是會(huì)使用兩個(gè)線程來計(jì)算結(jié)果.(因此,使用了 2 個(gè)線程的 ThreadPoolExecutor 和使用了 1 個(gè)線程的 ForkJoinPool 的耗時(shí)基本相同.)
在使用并行 Stream 構(gòu)造或其他自動(dòng)并行化特性時(shí),如果必要調(diào)整公共池的大小,可以考慮將所需的值減 1.
快速小結(jié)
ForkJoinPool 類應(yīng)該用于遞歸、分治算法.
應(yīng)該花些心思來確定,算法中的遞歸任務(wù)何時(shí)結(jié)束最為合適.創(chuàng)建太多任務(wù)會(huì)降低性能,但如果任務(wù)太少,而任務(wù)所需的執(zhí)行時(shí)間又長短紛歧,也會(huì)降低性能.
Java 8 中使用了自動(dòng)并行化的特性會(huì)用到一個(gè)公共的 ForkJoinPool 實(shí)例.我們可能必要根據(jù)實(shí)際情況調(diào)整這個(gè)實(shí)例的默認(rèn)大小.
相反,較好的線程性能是這么來的:遵循管理線程數(shù)、限制同步帶來的影響的一系列最佳實(shí)踐原則.借助適當(dāng)?shù)钠饰龉ぞ吆玩i分析工具,可以檢查并修改應(yīng)用,以避免線程和鎖的問題給性能帶來負(fù)面影響.
當(dāng)空間非常珍貴時(shí),可以調(diào)節(jié)線程所用的內(nèi)存.每個(gè)線程都有一個(gè)原生棧,操作系統(tǒng)用它來保存該線程的調(diào)用棧信息(比如,main() 辦法調(diào)用了 calculate() 辦法,而 calculate() 辦法又調(diào)用了 add() 辦法,棧會(huì)把這些信息記錄下來).
分歧的 JVM 版本,其線程棧的默認(rèn)大小也有所差別,具體如下所示.一表般而言,如果在 32 位 JVM 上有 128 KB 的棧,在 64 位 JVM 上有 256 KB 的棧,很多應(yīng)用實(shí)際就可以運(yùn)行了.如果這個(gè)值設(shè)置得太小,潛在的缺點(diǎn)是,當(dāng)某個(gè)線程的調(diào)用棧非常大時(shí),會(huì)拋出 StackOverflowError.
幾種 JVM 的默認(rèn)棧年夜小
在 64 位的 JVM 中,除非物理內(nèi)存非常有限,并且較小的棧可以防止耗盡原生內(nèi)存,否則沒有理由設(shè)置這個(gè)值.另一方面,在 32 位的 JVM 上,使用較小的棧(好比 128 KB)往往是個(gè)不錯(cuò)的選擇,因?yàn)檫@樣可以在進(jìn)程空間中釋放部分內(nèi)存,使得 JVM 的堆可以大一些.
耗盡原生內(nèi)存
沒有足夠的原生內(nèi)存來創(chuàng)建線程,也可能會(huì)拋出 OutOfMemoryError.這意味著可能呈現(xiàn)了以下 3 種情況之一.
在 32 位的 JVM 上,進(jìn)程所占空間到達(dá)了 4 GB 的最大值(或者小于 4 GB,取決于操作系統(tǒng)).
系統(tǒng)實(shí)際已經(jīng)耗盡了虛擬內(nèi)存.
在 Unix 風(fēng)格的系統(tǒng)上,用戶創(chuàng)立的進(jìn)程數(shù)已經(jīng)達(dá)到配額限制.這方面單獨(dú)的線程會(huì)被看作一個(gè)進(jìn)程.
減少棧的大小可以克服前兩個(gè)問題,但是對第三個(gè)問題沒什么效果.遺憾的是,我們無法從 JVM 報(bào)錯(cuò)看出到底是哪種情況,只能在遇到差錯(cuò)時(shí)依次排查.
要改變線程的棧大小,可以使用 -Xss=N 標(biāo)記(例如 -Xss=256k).
小結(jié)
在內(nèi)存比擬稀缺的機(jī)器上,可以減少線程棧大小.
在 32 位的 JVM 上,可以減少線程棧年夜小,以便在 4 GB 進(jìn)程空間限制的條件下,稍稍增加堆可以使用的內(nèi)存.
在對應(yīng)用中的線程和同步的效率作性能分析時(shí),有兩點(diǎn)必要注意:總的線程數(shù)(既不能太大,也不能太小)和線程花在等待鎖或其他資源上的時(shí)間.
幾乎所有的 JVM 監(jiān)控工具都提供了線程數(shù)(以及這些線程在干什么)相關(guān)的信息.像 jconsole這樣的交互式工具還能顯示 JVM 內(nèi)線程的狀態(tài).在 jconsole 的 Threads 面板上,可以實(shí)時(shí)察看程序執(zhí)行期間線程數(shù)的增減.下圖是一個(gè)例子.
在某個(gè)時(shí)間點(diǎn),應(yīng)用(NetBeans)最多使用了 45 個(gè)線程.圖中剛開始有一個(gè)爆發(fā)點(diǎn),最多會(huì)使用 38 個(gè)線程,后來線程數(shù)穩(wěn)定在 30 到 31 之間.jconsole 可以打印每個(gè)零丁線程的棧信息;如圖所示,Java2D Disposer 線程正在某個(gè)引用隊(duì)列的鎖上等待.
JConsole 中的活躍線程視圖
如果想了解應(yīng)用中有什么線程在運(yùn)行這類高層視圖,實(shí)時(shí)線程監(jiān)控會(huì)很有用,但至于那些線程在做什么,實(shí)際上沒有提供任何數(shù)據(jù).要確定線程的 CPU 周期都耗在哪兒了,則必要使用分析器(profiler).利用分析器可以很好地觀察哪些線程在執(zhí)行.而且分析器一般非常成熟,可以指出那些能夠通過更好的算法、更好的代碼選擇來加速整體執(zhí)行效果的代碼區(qū)域.
診斷阻塞的線程更為困難,盡管這類信息對應(yīng)用的整體執(zhí)行而言往往更為重要,特別是當(dāng)代碼運(yùn)行在多 CPU 系統(tǒng)上,但沒有利用起所有可用的 CPU 時(shí).一般有三種執(zhí)行此類診斷的辦法.辦法之一還是使用分析器,因?yàn)榇蟛糠址治龉ぞ叨紩?huì)提供線程執(zhí)行的時(shí)間線信息,這就可以看到線程被阻塞的時(shí)間點(diǎn).
被阻塞線程與JFR
要了解線程是何時(shí)被阻塞的,迄今為止最好的方式是使用可以窺探 JVM 內(nèi)部、并且可以在較低的層次確定線程被阻塞時(shí)間的工具.Java 飛行記錄器(Java Flight Recorder,JFR)就是一款這樣的工具.我們可以深入到 JFR 捕獲的事件中,并尋找那些引發(fā)線程阻塞的事件(好比等待獲取某個(gè) Monitor,或是等待讀寫 Socket,不過寫的情況較為少見).
借助 JMC 的直方圖面板可以很便利地查看這些事件,如下圖所示.
JFR 中被某個(gè) Monitor 阻塞的線程
在這個(gè)示例中,與 sun.awt.AppContext.get 方法中的 HashMap 關(guān)聯(lián)的鎖被競爭了 163 次(超過 66 秒),使得所測量的哀求響應(yīng)時(shí)間平均增加了 31 毫秒.棧軌跡表明競爭源于 JSP 寫java.util.Date 對象的方式.要改進(jìn)這段代碼的可伸縮性,可以使用線程局部的日期格式化對象,而不是簡單地調(diào)用日期對象的 toString 方法.
從直方圖中選擇阻塞變亂,然后檢查調(diào)用代碼,這個(gè)流程適合任何阻塞變亂;這款與 JVM 緊密集成的工具使這一流程就成為可能.
被阻塞線程與JStack
如果沒有商用的 JVM 可用,替代方案之一是從程序中拿到大量的線程棧并加以檢查.jstack、jcmd 和其他工具可以提供虛擬機(jī)中每個(gè)線程狀態(tài)相關(guān)的信息,包含線程是在運(yùn)行、等待鎖還是等待 I/O 等.對于確定應(yīng)用中正在進(jìn)行的是什么,這可能非常有用,不過輸出中也有很多我們不需要的.
在查看線程棧時(shí),有兩點(diǎn)需要注意.第一,JVM 只能在特定的位置(safepoint,平安點(diǎn))轉(zhuǎn)儲(chǔ)出一個(gè)線程的棧.第二,每次只能針對一個(gè)線程轉(zhuǎn)儲(chǔ)出棧信息,所以可能會(huì)看到彼此沖突的信息:比如兩個(gè)線程持有同一個(gè)鎖,或者一個(gè)線程正在等待的鎖并未被其他線程持有.
JStack 闡發(fā)器
人們很容易認(rèn)為,連續(xù)快速地抓取多個(gè)棧轉(zhuǎn)儲(chǔ)信息,就能將其用作一個(gè)簡單快速的分析器.畢竟,采樣分析器本質(zhì)上就是這么工作的:周期性地探測線程的執(zhí)行棧,基于這些信息推斷在方法上花了多少時(shí)間.但是在平安點(diǎn)和不一致的快照之間,這么做不是很有效;通過查看這些線程棧,有時(shí)可以從較高的層次上大概獲知執(zhí)行成本較高的方法,但是一款真正的分析器提供的信息要精確得多.
從線程棧可以看出線程阻塞的嚴(yán)重程度(因?yàn)樽枞木€程已經(jīng)在某個(gè)平安點(diǎn)上).如果有連續(xù)的線程轉(zhuǎn)儲(chǔ)信息表明大量的線程阻塞在某個(gè)鎖上,那么就可以斷定這個(gè)鎖上有嚴(yán)重的競爭.如果有連續(xù)的線程轉(zhuǎn)儲(chǔ)信息表明大量的線程在阻塞等待 I/O,則可以斷定需要優(yōu)化正在進(jìn)行的 I/O 讀操作(比如,如果是數(shù)據(jù)庫調(diào)用,應(yīng)該優(yōu)化 SQL 執(zhí)行,或者是優(yōu)化數(shù)據(jù)庫本身).
Jstack 的輸出有個(gè)問題,即不同版本之間可能會(huì)有變化,所以開發(fā)一個(gè)健壯的解析器比擬困難.不能保證這個(gè)解析器可以不加修改地應(yīng)用于你所使用的特定的 JVM.
jstack 解析器的基本輸出像下面這樣:
解析器聚合了所有的線程,可以顯示處于各種狀態(tài)的線程分別有多少.8 個(gè)線程正在運(yùn)行(它們碰巧正在獲取棧軌跡信息,這個(gè)操作本錢非常高,最好避免).
41 個(gè)線程被某個(gè)鎖阻塞了.所報(bào)告的辦法是棧軌跡中第一個(gè)非 JDK 辦法,在這個(gè)例子中是 GlassFish 的 EJBClassLoader.getResourceAsStream.下一步就是考慮棧軌跡信息,搜索這個(gè)辦法,看看線程是阻塞到什么資源上了.
在這個(gè)例子中,所有線程都被阻塞了,在等待讀取同一個(gè) JAR 文件;這些線程的棧軌跡表明,所有調(diào)用都來自實(shí)例化新 SAX 實(shí)例的操作.SAX 解析器可以通過列出應(yīng)用 JAR 文件中 manifest 文件內(nèi)的資源來動(dòng)態(tài)定義,這意味著 JDK 必需搜索整個(gè)類路徑來尋找那些條目,直到找到應(yīng)用想使用的一個(gè)(或者是找不到,回到系統(tǒng)解析器).因?yàn)樽x取這個(gè) JAR 文件需要一個(gè)同步鎖,所以所有嘗試創(chuàng)建一個(gè)解析器的線程最終都會(huì)競爭同一個(gè)鎖,這會(huì)極大影響應(yīng)用的吞吐量.(建議設(shè)置 -Djavax.xml.parsers.SAXParserFactory 屬性來避免這些查找,原因就在于此.)
更重要的一點(diǎn)是,年夜量被阻塞的線程會(huì)成為影響性能的問題.不管阻塞的根源是什么,都要對配置或應(yīng)用加以修改,以避免之.
等待通知的線程又是什么樣的情況呢?那些線程在等待其他事件發(fā)生.它們往往是在某個(gè)池中,等待任務(wù)就緒(比如,上面輸出中的 getTask() 方法在等待哀求)這類通知.系統(tǒng)線程會(huì)在處理像 RMI 分布式 GC 或 JMX 監(jiān)控這樣的事情,它們以棧中只有 JDK 類這類線程的形式出現(xiàn)在 jstack的輸出中.這些條件不一定表明有性能問題;對這些線程而言,等待通知是正常現(xiàn)象.
如果線程正在進(jìn)行的是阻塞式 I/O 讀取(通常是 socketRead0() 方法),也會(huì)導(dǎo)致問題.這也會(huì)影響吞吐量:線程正在等待某個(gè)后端資源回復(fù)其哀求.這時(shí)候應(yīng)該檢查數(shù)據(jù)庫或其他后端資源的性能.
小結(jié)
利用系統(tǒng)提供的線程基本信息,可以對正在運(yùn)行的線程的數(shù)目有個(gè)年夜致了解.
就性能分析而言,當(dāng)線程阻塞在某個(gè)資源或 I/O 上時(shí),能夠看到線程的相關(guān)細(xì)節(jié)就顯得比擬重要.
JFR 使得我們可以很便利地檢查引發(fā)線程阻塞的事件.
利用 jstack,必定程度上可以檢查線程是阻塞在什么資源上.
以上內(nèi)容整理自《Java性能權(quán)威指南》第 9 章
作者:Scott Oaks
譯者:柳飛,陸明剛,臧秀濤
《Java 性能權(quán)威指南》對 Java 7 和 Java 8 中影響性能的因素展開了全面深入的介紹,講解傳統(tǒng)上影響應(yīng)用性能的JVM特征.內(nèi)容包含:用 G1 垃圾收集器最大化應(yīng)用的吞吐量;使用 Java飛行記錄器查看性能細(xì)節(jié),而不必借助專業(yè)的分析工具;堆內(nèi)存與原生內(nèi)存最佳實(shí)踐;線程與同步的性能,以及數(shù)據(jù)庫性能最佳實(shí)踐等.
本文由人民郵電出版社授權(quán)「高可用架構(gòu)」頒發(fā).轉(zhuǎn)載請注明來自高可用架構(gòu)「ArchNotes」微信公眾號(hào)及包含以下二維碼.
高可用架構(gòu)
轉(zhuǎn)變互聯(lián)網(wǎng)的構(gòu)建方式
歡迎參與《為何服務(wù)器QPS上不去?Java線程調(diào)優(yōu)權(quán)威指南》討論,分享您的想法,維易PHP學(xué)院為您提供專業(yè)教程。
轉(zhuǎn)載請注明本頁網(wǎng)址:
http://www.snjht.com/jiaocheng/7841.html