溫馨提示×

溫馨提示×

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

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

Java線程的調(diào)優(yōu)方法

發(fā)布時間:2021-09-04 16:41:23 來源:億速云 閱讀:182 作者:chen 欄目:服務(wù)器

這篇文章主要介紹“Java線程的調(diào)優(yōu)方法”,在日常操作中,相信很多人在Java線程的調(diào)優(yōu)方法問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”Java線程的調(diào)優(yōu)方法”的疑惑有所幫助!接下來,請跟著小編一起來學(xué)習(xí)吧!

下面探討的主題是,如何挖掘出 Java 線程和同步設(shè)施的最大性能。

線程池與 ThreadPoolExecutor

Java線程的調(diào)優(yōu)方法

(Thread Pool 示意圖,來源 wikipedia)

在 Java 中,線程可以使用自己代碼來管理,也可以利用線程池,使用 ThreadPoolExecutor 并行執(zhí)行任務(wù)。

在使用線程池時,有一個因素非常關(guān)鍵:調(diào)節(jié)線程池的大小對獲得最好的性能至關(guān)重要。線程池的性能會隨線程池大小這一基本選擇而有所不同,在某些條件下,線程池過大對性能也有很大的不利影響。

所有線程池的工作方式本質(zhì)是一樣的:

有一個隊列,任務(wù)被提交到這個隊列中。一定數(shù)量的線程會從該隊列中取任務(wù),然后執(zhí)行。

任務(wù)的結(jié)果可以發(fā)回客戶端(比如應(yīng)用服務(wù)器的情況下),或保存到數(shù)據(jù)庫中,或保存到某個內(nèi)部數(shù)據(jù)結(jié)構(gòu)中,等等。但是在執(zhí)行完任務(wù)后,這個線程會返回任務(wù)隊列,檢索另一個任務(wù)并執(zhí)行,如果沒有更多任務(wù)要執(zhí)行,該線程會等待下一個任務(wù)。

線程池有最小線程數(shù)和最大線程數(shù)。池中會有最小數(shù)目的線程隨時待命,等待任務(wù)指派給它們。因為創(chuàng)建線程的成本非常高昂,這樣可以提高任務(wù)提交時的整體性能:已有的線程會拿到該任務(wù)并處理。另一方面,線程需要一些系統(tǒng)資源,包括棧所需的原生內(nèi)存,如果空閑線程太多,就會消耗本來可以分配給其他進程的資源。最大線程數(shù)還是一個必要的限流閥,防止一次執(zhí)行太多線程。

ThreadPoolExecutor  和相關(guān)的類將最小線程數(shù)稱作核心池大小,如果有個任務(wù)要執(zhí)行,而所有的并發(fā)線程都在忙于執(zhí)行另一個任務(wù),就啟動一個新線程,直到創(chuàng)建的線程達到最大線程數(shù)。

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

對于給定硬件上的給定負載,最大線程數(shù)設(shè)置為多少最好呢?

這個問題回答起來并不簡單;它取決于負載特性以及底層硬件。特別是,最優(yōu)線程數(shù)還與每個任務(wù)阻塞的頻率有關(guān)。

為方便討論,假設(shè) JVM 有 4 個 CPU 可用。我們的目標就是最大化這 4 個 CPU 的利用率。

很明顯,最大線程數(shù)至少要設(shè)置為 4。的確,除了處理這些任務(wù),JVM 中還有些線程要做其他的事,但是它們幾乎從來不會占用一個完整的  CPU。如果使用的是并發(fā)垃圾收集器,這是個例外,后臺線程必須有足夠的 CPU 來運行,以免在處理堆這方面落后。

如果線程數(shù)多于  4,會有幫助嗎?這時就要看負載特性了??紤]最簡單的情況,假定任務(wù)都是計算密集型的:沒有外部網(wǎng)絡(luò)調(diào)用(比如不會訪問數(shù)據(jù)庫),也不會激烈地競爭內(nèi)部鎖。在使用模實體管理器(mock  entity manager)的情況下,股價歷史批處理程序就是一個這樣的應(yīng)用:實體上的數(shù)據(jù)完全可以并行計算。

下面就使用線程池計算一下 10,000 個模股票實體的歷史,假設(shè)機器有 4 個 CPU,使用不同的線程數(shù)測試,具體的性能數(shù)據(jù)見表1。如果池中只有 1  個線程,計算數(shù)據(jù)集需要 255.6 秒;用 4 個線程,則只需要 77 秒。如果線程數(shù)超過 4 個,隨著線程數(shù)的增加,需要的時間會稍多一些。

表1:計算 10,000 個模的價格歷史所需時間

Java線程的調(diào)優(yōu)方法

如果應(yīng)用中的任務(wù)是完全并行的,則在有 2 個線程時,“與基準的百分比”這列為 50%;在有 4 個線程時,這列為  25%。但是這種完全線性的比例不可能出現(xiàn),原因有這么幾點:如果沒有其他線程幫助,這些線程必須自己來協(xié)同,實現(xiàn)從運行隊列中選取任務(wù)(一般而言,通常會有更多同步)。到了使用  4 個線程的時候,系統(tǒng)會 100% 消耗可用的 CPU,盡管機器可能沒有運行其他用戶級的應(yīng)用,但是會有各種系統(tǒng)級的進程進來,并使用 CPU,從而使得 JVM  無法 100% 地使用所有 CPU 周期。

盡管如此,這個應(yīng)用在伸縮性方面表現(xiàn)還不錯,且即使池中的線程數(shù)被顯著高估,性能損失也比較輕微。

不過在其他情況下,性能損失可能會很大。在 Servlet 版的股票歷史計算程序中,線程太多的話,影響會很大,如表2  所示。應(yīng)用服務(wù)器分別配置成不同的線程數(shù),有一個負載生成器會向該服務(wù)器發(fā)送 20 個同步的(simultaneous)請求。

表2:每秒通過 Servlet 的操作

Java線程的調(diào)優(yōu)方法

鑒于應(yīng)用服務(wù)器有 4 個 CPU 可用,最大吞吐量可以通過將池中的線程數(shù)設(shè)置為 4 來實現(xiàn)。

在研究性能問題時確定瓶頸在哪兒比較重要。在這個例子中,瓶頸很明顯是 CPU:4 個線程時,CPU 利用率為  100%。不過加入更多線程的影響其實很小,至少當線程數(shù)是原來的 8 倍時才會有明顯的差別。

如果瓶頸在其他地方呢?這個例子有點不同尋常,任務(wù)完全是 CPU 密集型的:沒有  I/O。一般來說,線程有可能會調(diào)用數(shù)據(jù)庫,或者把輸出寫到某個地方,甚至是會合其他某些資源。在那種情況下,瓶頸未必是 CPU,而可能是外部資源。

對于此類情況,添加線程非常有害。雖然我們經(jīng)常說數(shù)據(jù)庫總是瓶頸,但是瓶頸可能是任何外部資源。

仍以股票 Servlet 為例,我們把目標變一下:如果目標是最大限度地利用負載生成器機器,又會如何,是簡單地運行一個多線程的 Java 程序嗎?

在典型的用法中,如果 Servlet 應(yīng)用運行在一個有 4 個 CPU 的應(yīng)用服務(wù)器上,而且只有一個客戶端請求數(shù)據(jù),那么,應(yīng)用服務(wù)器大約會 25%  忙碌,客戶端機器幾乎總是空閑的。如果負載增加到 4 個并發(fā)的客戶端,則應(yīng)用服務(wù)器會 100% 忙碌,客戶端機器可能只有 20% 的忙碌。

只看客戶端,很容易得出這樣的結(jié)論:因為客戶端 CPU 大量過剩,應(yīng)該可以添加更多線程,改善其伸縮性。表3  說明了這種假設(shè)何其錯誤:當客戶端再加入一些線程時,性能會受到極大影響。

表3:計算模擬股票價格歷史的平均響應(yīng)時間

Java線程的調(diào)優(yōu)方法

在這個例子中,一旦應(yīng)用服務(wù)器成為瓶頸(也就是說,線程數(shù)達到 4 個時),向服務(wù)器增加負載是非常有害的——即使只是在客戶端加了幾個線程。

這個例子看上去可能有點有意為之。如果服務(wù)器已經(jīng)是 CPU 密集型的,誰還會加入更多線程呢?之所以使用這個例子,只是因為它容易理解,而且僅使用了 Java  程序。這意味著讀者自己就可以運行,并理解它是如何工作的,而不必設(shè)置數(shù)據(jù)庫連接、模式(Schema)等選項。

需要指出的是,對于還要向 CPU 密集型或 I/O 密集型的機器發(fā)送數(shù)據(jù)庫請求的應(yīng)用服務(wù)器而言,同樣的原則也成立。你可能只關(guān)注應(yīng)用服務(wù)器 CPU,看到小于  100%  就感覺不錯;看到有多余的請求要處理,就假定增加應(yīng)用服務(wù)器的線程數(shù)是個不錯的主意。結(jié)果會讓人大吃一驚,因為在那種情況下增加線程數(shù),實際上會降低整體吞吐量(影響可能非常明顯),就像前面那個只有  Java 程序的例子一樣。

了解系統(tǒng)真正瓶頸之所在非常重要的另一個原因是:

  • 如果還向瓶頸處增加負載,性能會顯著下降。

  • 相反,如果減少了當前瓶頸處的負載,性能可能會上升。

這也是設(shè)計自我調(diào)優(yōu)的線程池非常困難的原因所在。線程池通常對掛起了多少工作有所了解,甚至有多少 CPU  可用也可以知道,但是它們通??床坏剿诘恼麄€環(huán)境的其他方面。因此,當有工作掛起時,增加線程(這是很多自我調(diào)優(yōu)的線程池的一個核心特性,也是  ThreadPoolExecutor 的某些配置)往往是完全錯誤的。

遺憾的是,設(shè)置最大線程數(shù)更像是藝術(shù)而非科學(xué),原因也在于此。在現(xiàn)實中,測試條件下自我調(diào)優(yōu)的線程池會實現(xiàn)可能性能的  80%~90%;而且就算高估了所需線程數(shù),也可能只有很小的損失。但是當設(shè)置線程數(shù)大小這方面出了問題時,系統(tǒng)可能會在很大程度上出現(xiàn)問題。就此而言,充足的測試仍然非常關(guān)鍵。

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

一旦確定了線程池的最大線程數(shù),就該確定所需的最小線程數(shù)了。大部分情況下,開發(fā)者會直截了當?shù)貙⑺鼈冊O(shè)置為同一個值。

將最小線程數(shù)設(shè)置為其他某個值(比如  1),出發(fā)點是防止系統(tǒng)創(chuàng)建太多線程,以節(jié)省系統(tǒng)資源。因為每個線程都需要一定量的內(nèi)存,特別是線程的棧。根據(jù)一般原則之一,所設(shè)置的系統(tǒng)大小應(yīng)該能夠處理預(yù)期的最大吞吐量,而要達到最大吞吐量,系統(tǒng)將需要創(chuàng)建所有那些線程。如果系統(tǒng)做不到這一點,那選擇一個最小線程數(shù)也沒什么幫助:如果系統(tǒng)達到了這樣的條件——需要按所設(shè)置的最大線程數(shù)啟動所有線程,而又無法滿足,系統(tǒng)將陷入困境。創(chuàng)建最終可能會需要的所有線程,并確保系統(tǒng)可以處理預(yù)期的最大負載,這樣更好。

另一方面,指定一個最小線程數(shù)的負面影響相當小。如果進程一啟動就有很多任務(wù)要執(zhí)行,會有負面影響:這時線程池需要創(chuàng)建新線程才能處理任務(wù)。創(chuàng)建線程對性能不利,這也是為什么起初需要線程池的原因,不過這種一次性的成本在性能測試中很可能察覺不到。

在批處理應(yīng)用中,線程是在創(chuàng)建線程池時分配(如果將最大線程數(shù)和最小線程數(shù)設(shè)置為同一個值,就會出現(xiàn)這種情況),還是按需分配,并不重要:執(zhí)行應(yīng)用所需的時間是一樣的。在其他應(yīng)用中,新線程可能會在預(yù)熱階段分配(分配線程的總時間還是一樣的),對性能的影響可以忽略不計。即使線程創(chuàng)建發(fā)生在可以測量的周期內(nèi),只要此類操作有限,也很有可能測不出來。

另一個可以調(diào)優(yōu)的地方是線程的空閑時間。比如,某個線程池的最小線程數(shù)為 1,最大線程數(shù)為  4?,F(xiàn)在假設(shè)一般會有一個線程在執(zhí)行,處理一個任務(wù);然后應(yīng)用進入這樣一個循環(huán):每 15 秒,負載平均有 2 個任務(wù)要執(zhí)行。第一次進入這個循環(huán)時,線程池會創(chuàng)建第 2  個線程,此時,讓這個新創(chuàng)建的線程在池中至少留存一段時間是有意義的。我們希望避免這種情況:第 2 個線程創(chuàng)建出來后,5 秒鐘內(nèi)結(jié)束其任務(wù),空閑 5  秒,然后退出了。而 5  秒之后又需要為下一個任務(wù)創(chuàng)建一個線程。一般而言,對于線程數(shù)為最小值的線程池,一個新線程一旦創(chuàng)建出來,至少應(yīng)該留存幾分鐘,以處理任何負載飆升。如果任務(wù)到達率有個比較好的模型,可以基于這個模型設(shè)置空閑時間。另外,空閑時間應(yīng)該以分鐘計,而且至少在  10 分鐘到 30 分鐘之間。

留存一些空閑線程,對應(yīng)用性能的影響通常微乎其微。一般而言,線程對象本身不會占用大量的堆空間。除非線程保持了大量的線程局部存儲,或者線程的 Runnable  對象引用了大量內(nèi)存。不管是哪種情況,釋放這樣的線程都會顯著減少堆中的活數(shù)據(jù)(這反過來又會影響 GC 的效率)。

不過對線程池而言,這些情況并不多見。當池中的某個對象空閑時,它就不應(yīng)該再引用任何 Runnable 對象(如果引用了,就說明哪個地方有 bug  了)。根據(jù)線程池的實現(xiàn)情況,線程局部變量可能會繼續(xù)保留;盡管在某些情況下,線程局部變量可以有效促成對象重用,但是那些線程局部對象所占用的總的內(nèi)存量,應(yīng)該加以限制。

對于可能會增長到非常大(當然也是運行在規(guī)模很大的機器上)的線程池,這個規(guī)則有個重要的特例。舉例而言,假設(shè)某個線程池的任務(wù)隊列預(yù)計平均有 20 個任務(wù),那么  20 就是很好的最小值。再假設(shè)這個池運行在一個規(guī)模很大的機器上,它被設(shè)計為可以處理 2000 個任務(wù)的峰值負載。如果在池中留存 2000 個空閑線程,則當只有  20 個任務(wù)時,對性能會有所影響:如果只有核心的 20 個線程忙碌,與有 1980 個空閑線程相比,前者的吞吐量可能是后者的  50%。線程池一般不會遇到這樣的問題,但如果遇到了,那就應(yīng)該確認一下池的合適的最小值了。

線程池任務(wù)大小

等待線程池來執(zhí)行的任務(wù)會被保存到某類隊列或列表中;當池中有線程可以執(zhí)行任務(wù)時,就從隊列中拉出一個。這會導(dǎo)致不均衡:隊列中任務(wù)的數(shù)量有可能變得非常大。如果隊列太大,其中的任務(wù)就必須等待很長時間,直到前面的任務(wù)執(zhí)行完畢。例如一個超負荷的  Web 服務(wù)器:如果有個任務(wù)被添加到隊列中,但是沒有在 3 秒鐘內(nèi)執(zhí)行,那用戶很可能就去看另一個頁面了。

因此,對于容納等待執(zhí)行任務(wù)的隊列,線程池通常會限制其大小。根據(jù)用于容納等待執(zhí)行任務(wù)的數(shù)據(jù)結(jié)構(gòu)的不同,ThreadPoolExecutor  會有不同的處理方式(下一節(jié)會更詳細地介紹);應(yīng)用服務(wù)器通常有一些調(diào)優(yōu)參數(shù),可以調(diào)整這個值。

就像線程池的最大線程數(shù),這個值應(yīng)該如何調(diào)優(yōu),并沒有一個通用的規(guī)則。舉例而言,假設(shè)某個應(yīng)用服務(wù)器的任務(wù)隊列中有 30 000 個任務(wù),有 4 個 CPU  可用,如果執(zhí)行一個任務(wù)只需要 50 毫秒,同時假設(shè)這段時間不會到達新任務(wù),則清空任務(wù)隊列需要 6 分鐘。這可能是可以接受的,但如果每個任務(wù)需要 1  秒鐘,則清空任務(wù)隊列需要 2 小時。因此,若要確定使用哪個值能帶來我們需要的性能,測量我們的真實應(yīng)用是唯一的途徑。

不管是哪種情況,如果達到了隊列數(shù)限制,再添加任務(wù)就會失敗。ThreadPoolExecutor 有一個rejectedExecution  方法,用于處理這種情況(默認會拋出 RejectedExecutionException)。應(yīng)用服務(wù)器會向用戶返回某個錯誤:或者是 HTTP 狀態(tài)碼  500(內(nèi)部錯誤),或者是 Web 服務(wù)器捕獲錯誤,并向用戶給出合理的解釋消息——其中后者是最理想的。

設(shè)置 ThreadPoolExecutor 的大小

線程池的一般行為是這樣的:

  • 創(chuàng)建時準備好最小數(shù)目的線程,如果來了一個任務(wù),而此時所有的線程都在忙碌,則啟動一個新線程(一直到達到最大線程數(shù)),任務(wù)就可以立即執(zhí)行了。

  • 否則,任務(wù)被加入等待隊列,如果任務(wù)隊列中已經(jīng)無法加入新任務(wù),則拒絕之。

不過,ThreadPoolExecutor的表現(xiàn)可能和這種標準行為有點不同。

根據(jù)所選任務(wù)隊列的類型,ThreadPoolExecutor 會決定何時啟動一個新線程。有以下 3 種可能。

1. SynchronousQueue

如果 ThreadPoolExecutor 搭配的是  SynchronousQueue,則線程池的行為會和我們預(yù)計的一樣,它會考慮線程數(shù):如果所有的線程都在忙碌,而且池中的線程數(shù)尚未達到最大,則新任務(wù)會啟動一個新線程。然而,這個隊列沒辦法保存等待的任務(wù):如果來了一個任務(wù),創(chuàng)建的線程數(shù)已經(jīng)達到最大值,而且所有線程都在忙碌,則新的任務(wù)總是會被拒絕。所以如果只是管理少量的任務(wù),這是個不錯的選擇;但是對于其他情況,就不合適了。該類文檔建議將最大線程數(shù)指定為一個非常大的值,如果任務(wù)完全是  CPU 密集型的,這可能行得通,但是我們會看到,其他情況下可能會適得其反。另一方面,如果需要一個容易調(diào)整線程數(shù)的線程池,這種選擇會更好。

2. 無界隊列

如果 ThreadPoolExecutor 搭配的是無界隊列(比如  LinkedBlockedingQueue),則不會拒絕任何任務(wù)(因為隊列大小沒有限制)。這種情況下,ThreadPoolExecutor  最多僅會按最小線程數(shù)創(chuàng)建線程,也就是說,最大線程池大小被忽略了。如果最大線程數(shù)和最小線程數(shù)相同,則這種選擇和配置了固定線程數(shù)的傳統(tǒng)線程池運行機制最為接近。

3. 有界隊列

在決定何時啟動一個新線程時,使用了有界隊列(如 ArrayBlockingQueue)的ThreadPoolExecutor  會采用一個非常復(fù)雜的算法。比如,假設(shè)池的核心大小為 4,最大為 8,所用的 ArrayBlockingQueue 最大為  10。隨著任務(wù)到達并被放到隊列中,線程池中最多會運行 4 個線程(也就是核心大小)。即使隊列完全填滿,也就是說有 10  個處于等待狀態(tài)的任務(wù),ThreadPoolExecutor 也是只利用 4 個線程。

如果隊列已滿,而又有新任務(wù)加進來,此時才會啟動一個新線程。這里不會因為隊列已滿而拒絕該任務(wù),相反,會啟動一個新線程。新線程會運行隊列中的第一個任務(wù),為新來的任務(wù)騰出空間。

在這個例子中,池中會有 8 個線程(最大線程數(shù))的唯一一種情形是,有 7 個任務(wù)正在處理,隊列中有 10 個任務(wù),這時又來了一個新任務(wù)。

這個算法背后的理念是,該池大部分時間僅使用核心線程(4  個),即使有適量的任務(wù)在隊列中等待運行。這時線程池就可以用作節(jié)流閥(這是很有好處的)。如果積壓的請求變得非常多,該池就會嘗試運行更多線程來清理;這時第二個節(jié)流閥——最大線程數(shù)——就起作用了。

如果系統(tǒng)沒有外部瓶頸,CPU  周期也足夠,那一切就都解決了:加入新的線程可以更快地處理任務(wù)隊列,并很可能使其回到預(yù)期大小。該算法所適合的用例當然也很容易構(gòu)造。

另一方面,該算法并不知道隊列為何會突然增大。如果是因為外部的任務(wù)積壓,那么加入更多線程并非明智之舉。如果該線程所運行的機器已經(jīng)是 CPU  密集型的,加入更多線程也是錯誤的。只有當任務(wù)積壓是由額外的負載進入系統(tǒng)(比如有更多客戶端發(fā)起 HTTP  請求)引發(fā)時,增加線程才是有意義的。(如果是這種情況,為什么要等到隊列已經(jīng)接近某個邊界時才增加呢?如果有額外的資源供更多線程使用,則盡早增加線程將改善系統(tǒng)的整體性能。)

對于上面提到的每一種選擇,都能找到很多支持或反對的論據(jù),但是在嘗試獲得最好的性能時,可以應(yīng)用 KISS 原則“Keep it simple,  stupid”。可以將 ThreadPoolExecutor 的核心線程數(shù)和最大線程數(shù)設(shè)為相同,在保存等待任務(wù)方面,如果適合使用無界任務(wù)列表,則選擇  LinkedBlockingQueue;如果適合使用有界任務(wù)列表,則選擇 ArrayBlockingQueue。

快速小結(jié)

有時對象池也是不錯的選擇,線程池就是情形之一:線程初始化的成本很高,線程池使得系統(tǒng)上的線程數(shù)容易控制。

線程池必須仔細調(diào)優(yōu)。盲目向池中添加新線程,在某些情況下對性能會有不利影響。

在使用 ThreadPoolExecutor 時,選擇更簡單的選項通常會帶來最好的、最能預(yù)見的性能。

ForkJoinPool

Java 7 引入了一個新的線程池:ForkJoinPool 類。這個類看上去和其他任何線程池都很像;和 ThreadPoolExecutor  類一樣,它也實現(xiàn)了 Executor 和 ExecutorService 接口。在支持這些接口方面,F(xiàn)orkJoinPool  在內(nèi)部會使用一個無界任務(wù)列表,供構(gòu)造器中所指定數(shù)目(如果所選的是無參構(gòu)造器,則為該機器上的 CPU 數(shù))的線程來運行。

ForkJoinPool  類是為配合分治算法的使用而設(shè)計的:任務(wù)可以遞歸地分解為子集。這些子集可以并行處理,然后每個子集的結(jié)果被歸并到一個結(jié)果中。一個經(jīng)典的例子就是快速排序算法。

分治算法的重點是,算法會創(chuàng)建大量的任務(wù),而這些任務(wù)只有相對較少的幾個線程來管理。比如要排序一個包含 1000 萬個元素的數(shù)組。首先創(chuàng)建單獨的任務(wù)來執(zhí)行 3  個操作:排序包含前面 500 萬個元素的子數(shù)組,再排序包含后面 500 萬個元素的子數(shù)組,然后合并兩個子數(shù)組。

類似地,要排序包含 500 萬個元素的數(shù)組,可以分別排序包含 250 萬個元素的子數(shù)組,然后合并子數(shù)組。一直遞歸到某個點(比如到子數(shù)組包含 10  個元素時),這時在子數(shù)組上使用插入排序直接處理更為高效。下圖演示了其工作方式。

Java線程的調(diào)優(yōu)方法

遞歸快速排序中的任務(wù)

最后會有超過 100 萬個任務(wù)來排序葉子數(shù)組(每個數(shù)組少于 10 個元素,這時候直接排序即可;這里只是用 10  來舉例,實際值會隨實現(xiàn)的不同而有所變化。在目前的 Java 庫實現(xiàn)中,當數(shù)組少于 47 個元素時 ,會采用插入排序)。需要 50  多萬個任務(wù)來歸并那些排好序的數(shù)組,歸并下一級又需要 25 萬個任務(wù),依此類推。最后會有 2,097,151 個任務(wù)。

更大的問題是,所有任務(wù)都要等待它們派生出的任務(wù)先完成,然后才能完成。對于元素數(shù)少于 10  的子數(shù)組,直接對它們做排序的任務(wù)必須優(yōu)先完成;在此之后,創(chuàng)建相應(yīng)子數(shù)組的任務(wù)才能歸并其子數(shù)組的結(jié)果,依此類推:鏈條上的所有任務(wù)依次歸并,直到整個數(shù)組被歸并為最終的、排序好的結(jié)果。

因為父任務(wù)必須等待子任務(wù)完成,所以無法使用 ThreadPoolExecutor 高效實現(xiàn)這個算法。ThreadPoolExecutor  內(nèi)的線程無法將另一個任務(wù)添加到隊列中并等待其完成:一旦線程進入等待狀態(tài),就無法使用該線程執(zhí)行它的某個子任務(wù)了。另一方面,F(xiàn)orkJoinPool  則允許其中的線程創(chuàng)建新任務(wù),之后掛起當前的任務(wù)。當任務(wù)被掛起時,線程可以執(zhí)行其他等待的任務(wù)。

舉個簡單的例子:比如說有個 double 數(shù)組,我們想計算數(shù)組中小于 0.5  的元素的個數(shù)。順序掃描比較簡單(可能還有優(yōu)勢,本節(jié)后面會看到),但是為了說明問題,現(xiàn)在把數(shù)組劃分為子數(shù)組,并行掃描(模仿更復(fù)雜的快速排序和其他分治算法)。使用  ForkJoinPool 實現(xiàn)這一功能的代碼如下:

Java線程的調(diào)優(yōu)方法

fork 和 join 方法是這里的關(guān)鍵:沒有這些方法,實現(xiàn)這類遞歸會非常痛苦(在由ThreadPoolExecutor  執(zhí)行的任務(wù)中就沒有這些方法)。這些方法使用了一系列內(nèi)部的、從屬于每個線程的隊列來操縱任務(wù),并將線程從執(zhí)行一個任務(wù)切換到執(zhí)行另一個。細節(jié)對開發(fā)者是透明的,不過如果對算法感興趣,其代碼讀起來也很有意思。這里我們重點關(guān)注的是性能:ForkJoinPool和  ThreadPoolExecutor 這兩個類之間有什么權(quán)衡取舍呢?

首先,fork/join 范型所實現(xiàn)的掛起,使得所有任務(wù)可以交由少量的線程執(zhí)行。使用該示例代碼計算包含 1000 萬個元素的數(shù)組中的 double  值,會創(chuàng)建 200  多萬個任務(wù),但這些任務(wù)很容易交由少量一些線程執(zhí)行(甚至是一個線程,如果這對運行測試的機器有意義的話)。使用ThreadPoolExecutor  運行類似算法則需要 200 多萬個線程,因為每個線程必須等待其子任務(wù)完成,而且那些子任務(wù)只有在池中有可用線程時才能完成。有了  fork/join,我們可以實現(xiàn)用ThreadPoolExecutor 無法實現(xiàn)的算法,這就是一個性能優(yōu)勢。

盡管分治技術(shù)非常強大,但是濫用也可能會導(dǎo)致性能變糟糕。在計數(shù)的這個例子中,可以使用一個線程來掃描數(shù)組并計數(shù),雖然未必能像并行運行 fork/join  算法那樣快。然而,把原數(shù)組劃分為多個斷,使用 ThreadPoolExecutor 讓多個線程掃描數(shù)組,也是非常容易的:

Java線程的調(diào)優(yōu)方法

在一個配備了 4 個 CPU 的機器上,這段代碼可以充分利用所有可用的 CPU,并行處理數(shù)組,同時避免像 fork/join 示例中那樣創(chuàng)建和排隊處理  200 萬個任務(wù)。可以預(yù)見性能會快些,如表4 所示。

表4:對1億個元素做計數(shù)處理

Java線程的調(diào)優(yōu)方法

測試所用的機器有 4 個 CPU,4 GB 固定內(nèi)存。測試中,ThreadPoolExecutor 完全不需要 GC,而每個 ForkJoinPool  測試會花 1.2 秒在 GC 上。對于性能差異而言,這一點所占比重很大,但這并非故事的全部:創(chuàng)建和管理任務(wù)對象的開銷也會傷害 ForkJoinPool  的性能。如果有類似的替代方案,很可能會更快,至少在這個簡單的例子中是這樣。

ForkJoinPool  還有一個額外的特性,它實現(xiàn)了工作竊取(work-stealing)。這基本上就是一個實現(xiàn)細節(jié)了;這意味著池中的每個線程都有自己所創(chuàng)建任務(wù)的隊列。線程會優(yōu)先處理自己隊列中的任務(wù),但如果這個隊列已空,它會從其他線程的隊列中竊取任務(wù)。其結(jié)果是,即使  200 萬個任務(wù)中有一個需要很長的執(zhí)行時間,F(xiàn)orkJoinPool 中的其他線程也可以完成其余的隨便什么任務(wù)。ThreadPoolExecutor  則不會這樣:如果一個任務(wù)需要很長的時間,其他線程并不能處理額外的工作。

示例代碼先是計算數(shù)組中小于 0.5 的元素數(shù)。此外,如果代碼中還計算了一個新的值,并保存到數(shù)組中了,會發(fā)生什么?一個沒有實際意義但卻是 CPU  密集型的實現(xiàn)可以執(zhí)行以下代碼:

Java線程的調(diào)優(yōu)方法

因為用 j 索引的外部循環(huán)是基于元素在數(shù)組中的位置處理的,所以計算所需要的時間和元素位置成比例關(guān)系:計算 d[0] 的值需要很長的時間,而計算  d[d.length - 1] 則只需要很短的時間。

簡單地將數(shù)組分為 4 段,用 ThreadPoolExecutor 處理,這個測試有一個不好的地方。計算數(shù)組第 1  段的線程需要很長的時間才能完成,比處理數(shù)組最后一段的第 4 個線程所需的時間長得多。一旦第 4 個線程結(jié)束,它就會處于空閑狀態(tài):所有線程都要等第 1  個線程完成它的耗時較長的任務(wù)。

在粒度為200萬個任務(wù)的 ForkJoinPool 中,盡管有一個線程會忙于針對數(shù)組中的前 10  個元素的非常耗時的計算,但是其余線程都有工作可做,在大部分測試過程中,CPU 會保持忙碌。區(qū)別如表5 所示。

表5:處理包含10 000個元素的數(shù)組的時間

Java線程的調(diào)優(yōu)方法

當池中只有一個線程時,計算所花的時間基本一樣。這可以理解:不管池如何實現(xiàn),計算量是一樣的;而且因為那些計算絕對不會并行進行,所以可以預(yù)計它們所需的時間是一樣的(盡管創(chuàng)建  200 萬個任務(wù)會有少量開銷)。但是當池中包含 4 個線程時,F(xiàn)orkJoinPool 中任務(wù)的粒度會帶來一個決定性的優(yōu)勢:幾乎在測試的整個過程中,都能保持  CPU 的忙碌狀態(tài)。

這種情況就叫作“不均衡”,因為某些任務(wù)所花的時間比其他任務(wù)長(因此前面例子中的任務(wù)可以說是“均衡的”)。一般而言,如果任務(wù)是均衡的,使用分段的  ThreadPoolExecutor 性能更好;而如果任務(wù)是不均衡的,則使用 ForkJoinPool 性能更好。

還有一個更微妙的性能方面的建議:請仔細考慮 fork/join 范型應(yīng)該在哪個點結(jié)束遞歸。在這個例子中,我信手選擇了當數(shù)組大小小于 10  時結(jié)束。如果在數(shù)組大小為 250 萬時停止遞歸,那么 fork/join 測試(在搭載 4 個 CPU 的機器上,處理 1000 萬個元素的平衡代碼)會只創(chuàng)建  4 個任務(wù),其性能基本和 ThreadPoolExecutor 一樣。

另一方面,對于這個例子,在非平衡的測試中,繼續(xù)遞歸會有更好的性能,即使創(chuàng)建更多任務(wù)。表6 給出了一些有代表性的數(shù)據(jù)點。

表6:處理包含10 000個元素的數(shù)組的時間

Java線程的調(diào)優(yōu)方法

自動并行化

Java 8 向 Java 中引入了自動并行化特定種類代碼的能力。這種并行化就依賴于 ForkJoinPool 類的使用。Java 8  為這個類加入了一個新特性:

一個公共的池,可供任何沒有顯式指定給某個特定池的 ForkJoinTask 使用。

這個公共池是 ForkJoinPool 類的一個 static 元素,其大小默認設(shè)置為目標機器上的處理器數(shù)。

這種并行化在 Arrays 類的很多新方法中都會發(fā)生,包括使用并行快速排序處理數(shù)組的方法,操作數(shù)組的每個元素的方法,等等。在 Java 8 的  Stream 特性中也有應(yīng)用,支持在集合中的每個元素上(或順序或并行地)執(zhí)行操作。這里不討論Stream 的一些基本的性能特性,而是看一下 Stream  是如何自動地并行處理的。

給定一個包含一系列整型數(shù)的集合,下列代碼會計算與給定整型數(shù)匹配的股票代號的價格歷史:

Java線程的調(diào)優(yōu)方法

這段代碼會并行計算模擬價格歷史:forEach 方法將為數(shù)組列表中的每個元素創(chuàng)建一個任務(wù),每個任務(wù)都會由公共的 ForkJoinTask  池處理。它在功能上與開始所做的測試是等價的,那個測試是用一個線程池來并行計算價格歷史(不過與顯式使用線程池相比,這段代碼寫起來更容易)。

設(shè)置 ForkJoinTask 池的大小和設(shè)置其他任何線程池同樣重要。默認情況下,公共池的線程數(shù)等于機器上的 CPU 數(shù)。如果在同一機器上運行著多個  JVM,則應(yīng)限制這個線程數(shù),以防這些 JVM 彼此爭用 CPU。類似地,如果 Servlet 代碼會執(zhí)行某個并行任務(wù),而我們想確保 CPU  可供其他任務(wù)使用,可以考慮減小公共池的線程數(shù)。另外,如果公共池中的任務(wù)會阻塞等待 I/O 或其他數(shù)據(jù),也可以考慮增大線程數(shù)。

這個值可以通過設(shè)置系統(tǒng)屬性 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N  來指定。

前面的表1 中,曾經(jīng)對比過線程數(shù)對并行計算股票歷史價格的影響。表7 使用共同的ForkJoinPool(將 parallelism  系統(tǒng)屬性設(shè)置為給定的值)將那個數(shù)據(jù)與 forEach 構(gòu)造作了比較。

表7:計算10 000支模擬股票價格歷史所需的時間

Java線程的調(diào)優(yōu)方法

默認情況下,公共池有 4 個線程(在這個配置了 4 個 CPU 的機器上),所以表中的第 3 行為一般情況。在線程數(shù)為 1 和 2  時,這類結(jié)果會讓性能工程師很不開心:它們看上去很不協(xié)調(diào),而當某一項測試出現(xiàn)這樣的情況時,最常見的原因是測試錯誤。這里的原因是 forEach  方法有些奇怪的行為:它使用了一個線程執(zhí)行語句,還使用了公共池中的線程處理來自 Stream 的數(shù)據(jù)。即使在第 1  個測試中,公共池也是配置為使用一個線程,總的還是會使用兩個線程來計算結(jié)果。(因此,使用了 2 個線程的 ThreadPoolExecutor 和使用了 1  個線程的 ForkJoinPool 的耗時基本相同。)

在使用并行 Stream 構(gòu)造或其他自動并行化特性時,如果需要調(diào)整公共池的大小,可以考慮將所需的值減 1。

快速小結(jié)

ForkJoinPool 類應(yīng)該用于遞歸、分治算法。

應(yīng)該花些心思來確定,算法中的遞歸任務(wù)何時結(jié)束最為合適。創(chuàng)建太多任務(wù)會降低性能,但如果任務(wù)太少,而任務(wù)所需的執(zhí)行時間又長短不一,也會降低性能。

Java 8 中使用了自動并行化的特性會用到一個公共的 ForkJoinPool 實例。我們可能需要根據(jù)實際情況調(diào)整這個實例的默認大小。

理解線程如何運作,可以獲得很大的性能優(yōu)勢。不過就線程的性能而言,其實沒有太多可以調(diào)優(yōu)的:可以修改的 JVM  標志相當少,而且那些標志的效果也很有限。

相反,較好的線程性能是這么來的:遵循管理線程數(shù)、限制同步帶來的影響的一系列實踐原則。借助適當?shù)钠饰龉ぞ吆玩i分析工具,可以檢查并修改應(yīng)用,以避免線程和鎖的問題給性能帶來負面影響。

調(diào)節(jié)線程棧大小

當空間非常珍貴時,可以調(diào)節(jié)線程所用的內(nèi)存。每個線程都有一個原生棧,操作系統(tǒng)用它來保存該線程的調(diào)用棧信息(比如,main() 方法調(diào)用了  calculate() 方法,而 calculate() 方法又調(diào)用了 add() 方法,棧會把這些信息記錄下來)。

不同的 JVM 版本,其線程棧的默認大小也有所差別,具體如下所示。一表般而言,如果在 32 位 JVM 上有 128 KB 的棧,在 64 位 JVM  上有 256 KB 的棧,很多應(yīng)用實際就可以運行了。如果這個值設(shè)置得太小,潛在的缺點是,當某個線程的調(diào)用棧非常大時,會拋出  StackOverflowError。

幾種 JVM 的默認棧大小

在 64 位的 JVM 中,除非物理內(nèi)存非常有限,并且較小的棧可以防止耗盡原生內(nèi)存,否則沒有理由設(shè)置這個值。另一方面,在 32 位的 JVM  上,使用較小的棧(比如 128 KB)往往是個不錯的選擇,因為這樣可以在進程空間中釋放部分內(nèi)存,使得 JVM 的堆可以大一些。

耗盡原生內(nèi)存

沒有足夠的原生內(nèi)存來創(chuàng)建線程,也可能會拋出 OutOfMemoryError。這意味著可能出現(xiàn)了以下 3 種情況之一。

在 32 位的 JVM 上,進程所占空間達到了 4 GB 的最大值(或者小于 4 GB,取決于操作系統(tǒng))。

系統(tǒng)實際已經(jīng)耗盡了虛擬內(nèi)存。

在 Unix 風格的系統(tǒng)上,用戶創(chuàng)建的進程數(shù)已經(jīng)達到配額限制。這方面單獨的線程會被看作一個進程。

減少棧的大小可以克服前兩個問題,但是對第三個問題沒什么效果。遺憾的是,我們無法從 JVM 報錯看出到底是哪種情況,只能在遇到錯誤時依次排查。

要改變線程的棧大小,可以使用 -Xss=N 標志(例如 -Xss=256k)。

小結(jié)

在內(nèi)存比較稀缺的機器上,可以減少線程棧大小。

在 32 位的 JVM 上,可以減少線程棧大小,以便在 4 GB 進程空間限制的條件下,稍稍增加堆可以使用的內(nèi)存。

監(jiān)控線程與鎖

在對應(yīng)用中的線程和同步的效率作性能分析時,有兩點需要注意:總的線程數(shù)(既不能太大,也不能太小)和線程花在等待鎖或其他資源上的時間。

1. 查看線程

幾乎所有的 JVM 監(jiān)控工具都提供了線程數(shù)(以及這些線程在干什么)相關(guān)的信息。像 jconsole這樣的交互式工具還能顯示 JVM 內(nèi)線程的狀態(tài)。在  jconsole 的 Threads 面板上,可以實時觀察程序執(zhí)行期間線程數(shù)的增減。下圖是一個例子。

在某個時間點,應(yīng)用(NetBeans)最多使用了 45 個線程。圖中剛開始有一個爆發(fā)點,最多會使用 38 個線程,后來線程數(shù)穩(wěn)定在 30 到 31  之間。jconsole 可以打印每個單獨線程的棧信息;如圖所示,Java2D Disposer 線程正在某個引用隊列的鎖上等待。

Java線程的調(diào)優(yōu)方法

JConsole 中的活躍線程視圖

2. 查看阻塞線程

如果想了解應(yīng)用中有什么線程在運行這類高層視圖,實時線程監(jiān)控會很有用,但至于那些線程在做什么,實際上沒有提供任何數(shù)據(jù)。要確定線程的 CPU  周期都耗在哪兒了,則需要使用分析器(profiler)。利用分析器可以很好地觀察哪些線程在執(zhí)行。而且分析器一般非常成熟,可以指出那些能夠通過更好的算法、更好的代碼選擇來加速整體執(zhí)行效果的代碼區(qū)域。

診斷阻塞的線程更為困難,盡管這類信息對應(yīng)用的整體執(zhí)行而言往往更為重要,特別是當代碼運行在多 CPU 系統(tǒng)上,但沒有利用起所有可用的 CPU  時。一般有三種執(zhí)行此類診斷的方法。方法之一還是使用分析器,因為大部分分析工具都會提供線程執(zhí)行的時間線信息,這就可以看到線程被阻塞的時間點。

  • 被阻塞線程與JFR

要了解線程是何時被阻塞的,迄今為止最好的方式是使用可以窺探 JVM 內(nèi)部、并且可以在較低的層次確定線程被阻塞時間的工具。Java 飛行記錄器(Java  Flight Recorder,JFR)就是一款這樣的工具。我們可以深入到 JFR 捕獲的事件中,并尋找那些引發(fā)線程阻塞的事件(比如等待獲取某個  Monitor,或是等待讀寫 Socket,不過寫的情況較為少見)。

借助 JMC 的直方圖面板可以很方便地查看這些事件,如下圖所示。

Java線程的調(diào)優(yōu)方法

JFR 中被某個 Monitor 阻塞的線程

在這個示例中,與 sun.awt.AppContext.get 方法中的 HashMap 關(guān)聯(lián)的鎖被競爭了 163 次(超過 66  秒),使得所測量的請求響應(yīng)時間平均增加了 31 毫秒。棧軌跡表明競爭源于 JSP 寫java.util.Date  對象的方式。要改進這段代碼的可伸縮性,可以使用線程局部的日期格式化對象,而不是簡單地調(diào)用日期對象的 toString 方法。

從直方圖中選擇阻塞事件,然后檢查調(diào)用代碼,這個流程適合任何阻塞事件;這款與 JVM 緊密集成的工具使這一流程就成為可能。

  • 被阻塞線程與JStack

如果沒有商用的 JVM 可用,替代方案之一是從程序中拿到大量的線程棧并加以檢查。jstack、jcmd  和其他工具可以提供虛擬機中每個線程狀態(tài)相關(guān)的信息,包括線程是在運行、等待鎖還是等待 I/O  等。對于確定應(yīng)用中正在進行的是什么,這可能非常有用,不過輸出中也有很多我們不需要的。

在查看線程棧時,有兩點需要注意。第一,JVM  只能在特定的位置(safepoint,安全點)轉(zhuǎn)儲出一個線程的棧。第二,每次只能針對一個線程轉(zhuǎn)儲出棧信息,所以可能會看到彼此沖突的信息:比如兩個線程持有同一個鎖,或者一個線程正在等待的鎖并未被其他線程持有。

JStack 分析器

人們很容易認為,連續(xù)快速地抓取多個棧轉(zhuǎn)儲信息,就能將其用作一個簡單快速的分析器。畢竟,采樣分析器本質(zhì)上就是這么工作的:周期性地探測線程的執(zhí)行棧,基于這些信息推斷在方法上花了多少時間。但是在安全點和不一致的快照之間,這么做不是很有效;通過查看這些線程棧,有時可以從較高的層次上大概獲知執(zhí)行成本較高的方法,但是一款真正的分析器提供的信息要精確得多。

從線程??梢钥闯鼍€程阻塞的嚴重程度(因為阻塞的線程已經(jīng)在某個安全點上)。如果有連續(xù)的線程轉(zhuǎn)儲信息表明大量的線程阻塞在某個鎖上,那么就可以斷定這個鎖上有嚴重的競爭。如果有連續(xù)的線程轉(zhuǎn)儲信息表明大量的線程在阻塞等待  I/O,則可以斷定需要優(yōu)化正在進行的 I/O 讀操作(比如,如果是數(shù)據(jù)庫調(diào)用,應(yīng)該優(yōu)化 SQL 執(zhí)行,或者是優(yōu)化數(shù)據(jù)庫本身)。

Jstack 的輸出有個問題,即不同版本之間可能會有變化,所以開發(fā)一個健壯的解析器比較困難。不能保證這個解析器可以不加修改地應(yīng)用于你所使用的特定的  JVM。

jstack 解析器的基本輸出像下面這樣:

Java線程的調(diào)優(yōu)方法

解析器聚合了所有的線程,可以顯示處于各種狀態(tài)的線程分別有多少。8 個線程正在運行(它們碰巧正在獲取棧軌跡信息,這個操作成本非常高,最好避免)。

41 個線程被某個鎖阻塞了。所報告的方法是棧軌跡中第一個非 JDK 方法,在這個例子中是 GlassFish 的  EJBClassLoader.getResourceAsStream。下一步就是考慮棧軌跡信息,搜索這個方法,看看線程是阻塞到什么資源上了。

在這個例子中,所有線程都被阻塞了,在等待讀取同一個 JAR 文件;這些線程的棧軌跡表明,所有調(diào)用都來自實例化新 SAX 實例的操作。SAX  解析器可以通過列出應(yīng)用 JAR 文件中 manifest 文件內(nèi)的資源來動態(tài)定義,這意味著 JDK  必須搜索整個類路徑來尋找那些條目,直到找到應(yīng)用想使用的一個(或者是找不到,回到系統(tǒng)解析器)。因為讀取這個 JAR  文件需要一個同步鎖,所以所有嘗試創(chuàng)建一個解析器的線程最終都會競爭同一個鎖,這會極大影響應(yīng)用的吞吐量。(建議設(shè)置  -Djavax.xml.parsers.SAXParserFactory 屬性來避免這些查找,原因就在于此。)

更重要的一點是,大量被阻塞的線程會成為影響性能的問題。不管阻塞的根源是什么,都要對配置或應(yīng)用加以修改,以避免之。

等待通知的線程又是什么樣的情況呢?那些線程在等待其他事件發(fā)生。它們往往是在某個池中,等待任務(wù)就緒(比如,上面輸出中的 getTask()  方法在等待請求)這類通知。系統(tǒng)線程會在處理像 RMI 分布式 GC 或 JMX 監(jiān)控這樣的事情,它們以棧中只有 JDK 類這類線程的形式出現(xiàn)在  jstack的輸出中。這些條件不一定表明有性能問題;對這些線程而言,等待通知是正常現(xiàn)象。

如果線程正在進行的是阻塞式 I/O 讀取(通常是 socketRead0()  方法),也會導(dǎo)致問題。這也會影響吞吐量:線程正在等待某個后端資源回復(fù)其請求。這時候應(yīng)該檢查數(shù)據(jù)庫或其他后端資源的性能。

到此,關(guān)于“Java線程的調(diào)優(yōu)方法”的學(xué)習(xí)就結(jié)束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學(xué)習(xí),快去試試吧!若想繼續(xù)學(xué)習(xí)更多相關(guān)知識,請繼續(xù)關(guān)注億速云網(wǎng)站,小編會繼續(xù)努力為大家?guī)砀鄬嵱玫奈恼拢?/p>

向AI問一下細節(jié)

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

AI