【轉】線程及同步的性能 - 線程池 / ThreadPoolExecutors / ForkJoinPool


線程池和ThreadPoolExecutors

雖然在程序中可以直接使用Thread類型來進行線程操作,但是更多的情況是使用線程池,尤其是在Java EE應用服務器中,一般會使用若干個線程池來處理來自客戶端的請求。Java中對於線程池的支持,來自ThreadPoolExecutor。一些應用服務器也確實是使用的ThreadPoolExecutor來實現線程池。

對於線程池的性能調優,最重要的參數就是線程池的大小。

對於任何線程池而言,它們的工作方式幾乎都是相同的:

  • 任務被投放到一個隊列中(隊列的數量不定)
  • 線程從隊列中取得任務並執行
  • 線程完成任務后,繼續嘗試從隊列中取得任務,如果隊列為空,那么線程進入等待狀態

線程池往往擁有最小和最大線程數:

  • 最小線程數,即當任務隊列為空時,線程池中最少需要保持的線程數量,這樣做是考慮到創建線程是一個相對耗費資源的操作,應當盡可能地避免,當有新任務被投入隊列時,總會有線程能夠立即對它進行處理。
  • 最大線程數,當需要處理的任務過多時,線程池能夠擁有的最大線程數。這樣是為了保證不會有過多的線程被創建出來,因為線程的運行需要依賴於CPU資源和其它各種資源,當線程過多時,反而會降低性能。

在ThreadPoolExecutor和其相關的類型中,最小線程數被稱為線程池核心規模(Core Pool Size),在其它Java應用服務器的實現中,這個數量也許被稱為最小線程數(MinThreads),但是它們的概念是相同的。

但是在對線程池進行規模變更(Resizing)的時候,ThreadPoolExecutor和其它線程池的實現也許存在的很大的差別。

一個最簡單的情況是:當有新任務需要被執行,且當前所有的線程都被占用時,ThreadPoolExecutor和其它實現通常都會新創建一個線程來執行這個新任務(直到達到了最大線程數)。

設置最大線程數

最合適的最大線程數該怎么確定,依賴以下兩個方面:

  • 任務的特征
  • 計算機的硬件情況

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

那么,最大線程數最少需要被設置成4,因為有4個可用的CPU,意味着最多能夠並行地執行4個任務。當然,垃圾回收(Garbage Collection)在這個過程中也會造成一些影響,但是它們往往不需要使用整個CPU。一個例外是,當使用了CMS或者G1垃圾回收算法時,需要有足夠的CPU資源進行垃圾回收。

那么是否有必要將線程數量設置的更大呢?這就取決於任務的特征了。

假設當任務是計算密集型的,意味着任務不需要執行IO操作,例如讀取數據庫,讀取文件等,因此它們不涉及到同步的問題,任務之間完全是獨立的。比如使用一個批處理程序讀取Mock數據源的數據,測試在不線程池擁有不同線程數量時的性能,得到下表:

線程數 執行時間(秒) 基線百分比
1 255.6 100%
2 134.8 52.7%
4 77.0 30.1%
8 81.7 31.9%
16 85.6 33.5%

從上面中得到一些結論:

  • 當線程數為4時,達到最優性能,再增加線程數量時並沒有更好的性能,因為此時CPU的利用率已經達到了最高,在增加線程只會增加線程之間爭奪CPU資源的行為,因此反而降低了性能。
  • 即使在CPU利用率達到最高時,基線百分比也不是理想中的25%,這是因為雖然在程序運行過程中,CPU資源並不是只被應用程序線程獨享的,一些后台線程有時也會需要CPU資源,比如GC線程和系統的一些線程等。

當計算是通過Servlet觸發的時候,性能數據是下面這個樣子的(Load Generator會同時發送20個請求):

線程數 每秒操作數(OPS) 基線百分比
4 77.43 100%
8 75.93 98.8%
16 71.65 92.5%
32 69.34 89.5%
64 60.44 78.1%

從上表中可以得到的結論:

  • 在線程數量為4時,性能最優。因為此任務的類型是計算密集型的,只有4個CPU,因此線程數量為4時,達到最優情況。
  • 隨着線程數量逐漸增加,性能下降,因為線程之間會互相爭奪CPU資源,造成頻繁切換線程執行上下文環境,而這些切換只會浪費CPU資源。
  • 性能下降的速度並不明顯,這也是因為任務類型是計算密集型的緣故,如果性能瓶頸不是CPU提供的計算資源,而是外部的資源,如數據庫,文件操作等,那么增加線程數量帶來的性能下降也許會更加明顯。

下面,從Client的角度考慮一下問題,並發Client的數量對於Server的響應時間會有什么影響呢?還是同樣地環境,當並發Client數量逐漸增加時,響應時間會如下發生變化:

並發Client線程數 平均響應時間(秒) 基線百分比
1 0.05 100%
2 0.05 100%
4 0.05 100%
6 0.076 152%
8 0.104 208%
16 0.212 424%
32 0.437 874%
64 0.909 1818%

因為任務類型是計算密集型的,當並發Client數量時1,2,4時,平均響應時間都是最優的,然而當出現多余4個Client時,性能會隨着Client的增加發生顯著地下降。

當Client數量增加時,你也許會想通過增加服務端線程池的線程數量來提高性能,可是在CPU密集型任務的情況下,這么做只會降低性能。因為系統的瓶頸就是CPU資源,冒然增加線程池的線程數量只會讓對於這種資源的競爭更加激烈。

所以,在面對性能方面的問題時。第一步永遠是了解系統的瓶頸在哪里,這樣才能夠有的放矢。如果冒然進行所謂的“調優”,讓對瓶頸資源的競爭更加激烈,那么帶來的只會是性能的進一步下降。相反,如果讓對瓶頸資源的競爭變的緩和,那么性能通常則會提高。

在上面的場景中,如果從ThreadPoolExecutor的角度進行考慮,那么在任務隊列中一直會有任務處於掛起(Pending)的狀態(因為Client的每個請求對應的就是一個任務),而所有的可用線程都在工作,CPU正在滿負荷運轉。這個時候添加線程池的線程數量,讓這些添加的線程領取一些掛起的任務,會發生什么事情呢?這時帶來的只會是線程之間對於CPU資源的爭奪更加激烈,降低了性能。

設置最小線程數

設置了最大線程數之后,還需要設置最小線程數。對於絕大部分場景,將它設置的和最大線程數相等就可以了。

將最小線程數設置的小於最大線程數的初衷是為了節省資源,因為每多創建一個線程都會耗費一定量的資源,尤其是線程棧所需要的資源。但是在一個系統中,針對硬件資源以及任務特點選定了最大線程數之后,就表示這個系統總是會利用這些線程的,那么還不如在一開始就讓線程池把需要的線程准備好。然而,把最小線程數設置的小於最大線程數所帶來的影響也是非常小的,一般都不會察覺到有什么不同。

在批處理程序中,最小線程數是否等於最大線程數並不重要。因為最后線程總是需要被創建出來的,所以程序的運行時間應該幾乎相同。對於服務器程序而言,影響也不大,但是一般而言,線程池中的線程在“熱身”階段就應該被創建出來,所以這也是為什么建議將最小線程數設置的等於最大線程數的原因。

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

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

線程池有一個列表或者隊列的數據結構來存放需要被執行的任務。顯然,在某些情況下,任務數量的增長速度會大於其被執行的速度。如果這個任務代表的是一個來自Client的請求,那么也就意味着該Client會等待比較長的時間。顯然這是不可接受的,尤其對於提供Web服務的服務器程序而言。

所以,線程池會有機制來限制列表/隊列中任務的數量。但是,和設置最大線程數一樣,並沒有一個放之四海而皆准的最優任務數量。這還是要取決於具體的任務類型和不斷的進行性能測試。

對於ThreadPoolExecutor而言,當任務數量達到最大時,再嘗試增加新的任務就會失敗。ThreadPoolExecutor有一個rejectedExecution方法用來拒絕該任務。這會導致應用服務器返回一個HTTP狀態碼500,當然這種信息最好以更友好的方式傳達給Client,比如解釋一下為什么你的請求被拒絕了。

定制ThreadPoolExecutor

線程池在同時滿足以下三個條件時,就會創建一個新的線程:

  • 有任務需要被執行
  • 當前線程池中所有的線程都處於工作狀態
  • 當前線程池的線程數沒有達到最大線程數

至於線程池會如何創建這個新的線程,則是根據任務隊列的種類:

  • 任務隊列是 SynchronousQueue 這個隊列的特點是,它並不能放置任何任務在其隊列中,當有任務被提交時,使用SynchronousQueue的線程池會立即為該任務創建一個線程(如果線程數量沒有達到最大時,如果達到了最大,那么該任務會被拒絕)。這種隊列適合於當任務數量較小時采用。也就是說,在使用這種隊列時,未被執行的任務沒有一個容器來暫時儲存。

  • 任務隊列是 無限隊列(Unbound Queue) 無界限的隊列可以是諸如LinkedBlockingQueue這種類型,在這種情況下,任何被提交的任務都不會被拒絕。但是線程池會忽略最大線程數這一參數,意味着線程池的最大線程數就變成了設置的最小線程數。所以在使用這種隊列時,通常會將最大線程數設置的和最小線程數相等。這就相當於使用了一個固定了線程數量的線程池。

  • 任務隊列是 有限隊列(Bounded Queue) 當使用的隊列是諸如ArrayBlockingQueue這種有限隊列的時候,來決定什么時候創建新線程的算法就相對復雜一些了。比如,最小線程數是4,最大線程數是8,任務隊列最多能夠容納10個任務。在這種情況下,當任務逐漸被添加到隊列中,直到隊列被占滿(10個任務),此時線程池中的工作線程仍然只有4個,即最小線程數。只有當仍然有任務希望被放置到隊列中的時候,線程池才會新創建一個線程並從隊列頭部拿走一個任務,以騰出位置來容納這個最新被提交的任務。

關於如何定制ThreadPoolExecutor,遵循KISS原則(Keep It Simple, Stupid)就好了。比如將最大線程數和最小線程數設置的相等,然后根據情況選擇有限隊列或者無限隊列。

總結

  1. 線程池是對象池的一個有用的例子,它能夠節省在創建它們時候的資源開銷。並且線程池對系統中的線程數量也起到了很好的限制作用。
  2. 線程池中的線程數量必須仔細的設置,否則冒然增加線程數量只會帶來性能的下降。
  3. 在定制ThreadPoolExecutor時,遵循KISS原則,通常情況下會提供最好的性能。

ForkJoinPool

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

它同ThreadPoolExecutor一樣,也實現了Executor和ExecutorService接口。它使用了一個無限隊列來保存需要執行的任務,而線程的數量則是通過構造函數傳入,如果沒有向構造函數中傳入希望的線程數量,那么當前計算機可用的CPU數量會被設置為線程數量作為默認值。

ForkJoinPool主要用來使用分治法(Divide-and-Conquer Algorithm)來解決問題。典型的應用比如快速排序算法。這里的要點在於,ForkJoinPool需要使用相對少的線程來處理大量的任務。比如要對1000萬個數據進行排序,那么會將這個任務分割成兩個500萬的排序任務和一個針對這兩組500萬數據的合並任務。以此類推,對於500萬的數據也會做出同樣的分割處理,到最后會設置一個閾值來規定當數據規模到多少時,停止這樣的分割處理。比如,當元素的數量小於10時,會停止分割,轉而使用插入排序對它們進行排序。

那么到最后,所有的任務加起來會有大概2000000+個。問題的關鍵在於,對於一個任務而言,只有當它所有的子任務完成之后,它才能夠被執行。

所以當使用ThreadPoolExecutor時,使用分治法會存在問題,因為ThreadPoolExecutor中的線程無法像任務隊列中再添加一個任務並且在等待該任務完成之后再繼續執行。而使用ForkJoinPool時,就能夠讓其中的線程創建新的任務,並掛起當前的任務,此時線程就能夠從隊列中選擇子任務執行。

比如,我們需要統計一個double數組中小於0.5的元素的個數,那么可以使用ForkJoinPool進行實現如下:

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");}}

以上的關鍵是fork()和join()方法。在ForkJoinPool使用的線程中,會使用一個內部隊列來對需要執行的任務以及子任務進行操作來保證它們的執行順序。

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

首先,使用ForkJoinPool能夠使用數量有限的線程來完成非常多的具有父子關系的任務,比如使用4個線程來完成超過200萬個任務。但是,使用ThreadPoolExecutor時,是不可能完成的,因為ThreadPoolExecutor中的Thread無法選擇優先執行子任務,需要完成200萬個具有父子關系的任務時,也需要200萬個線程,顯然這是不可行的。

當然,在上面的例子中,也可以不使用分治法,因為任務之間的獨立性,可以將整個數組划分為幾個區域,然后使用ThreadPoolExecutor來解決,這種辦法不會創建數量龐大的子任務。代碼如下:

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時,它們處理這個問題的時間如下:

線程數 ForkJoinPool ThreadPoolExecutor
1 3.2s 0.31s
4 1.9s 0.15s

對執行過程中的GC同樣也進行了監控,發現在使用ForkJoinPool時,總的GC時間花去了1.2s,而ThreadPoolExecutor並沒有觸發任何的GC操作。這是因為在ForkJoinPool的運行過程中,會創建大量的子任務。而當他們執行完畢之后,會被垃圾回收。反之,ThreadPoolExecutor則不會創建任何的子任務,因此不會導致任何的GC操作。

ForkJoinPool的另外一個特性是它能夠實現工作竊取(Work Stealing),在該線程池的每個線程中會維護一個隊列來存放需要被執行的任務。當線程自身隊列中的任務都執行完畢后,它會從別的線程中拿到未被執行的任務並幫助它執行。

可以通過以下的代碼來測試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 occurd[i] = dummy;}}}

因為里層的循環次數(j)是依賴於外層的i的值的,所以這段代碼的執行時間依賴於i的值。當i = 0時,執行時間最長,而i = last時執行時間最短。也就意味着任務的工作量是不一樣的,當i的值較小時,任務的工作量大,隨着i逐漸增加,任務的工作量變小。因此這是一個典型的任務負載不均衡的場景。

這時,選擇ThreadPoolExecutor就不合適了,因為它其中的線程並不會關注每個任務之間任務量的差異。當執行任務量最小的任務的線程執行完畢后,它就會處於空閑的狀態(Idle),等待任務量最大的任務執行完畢。

而ForkJoinPool的情況就不同了,即使任務的工作量有差別,當某個線程在執行工作量大的任務時,其他的空閑線程會幫助它完成剩下的任務。因此,提高了線程的利用率,從而提高了整體性能。

這兩種線程池對於任務工作量不均衡時的執行時間:

線程數 ForkJoinPool ThreadPoolExecutor
1 54.5s 53.3s
4 16.6s 24.2s

注意到當線程數量為1時,兩者的執行時間差異並不明顯。這是因為總的計算量是相同的,而ForkJoinPool慢的那一秒多是因為它創建了非常多的任務,同時也導致了GC的工作量增加。

當線程數量增加到4時,執行時間的區別就較大了,ForkJoinPool的性能比ThreadPoolExecutor好將近50%,可見Work Stealing在應對任務量不均衡的情況下,能夠保證資源的利用率。

所以一個結論就是:當任務的任務量均衡時,選擇ThreadPoolExecutor往往更好,反之則選擇ForkJoinPool。

另外,對於ForkJoinPool,還有一個因素會影響它的性能,就是停止進行任務分割的那個閾值。比如在之前的快速排序中,當剩下的元素數量小於10的時候,就會停止子任務的創建。下表顯示了在不同閾值下,ForkJoinPool的性能:

線程數 ForkJoinPool
20 17.8s
10 16.6s
5 15.6s
1 16.8s

可以發現,當閾值不同時,對於性能也會有一定影響。因此,在使用ForkJoinPool時,對此閾值進行測試,使用一個最合適的值也有助於整體性能。

自動並行化(Automatic Parallelization)

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

Java 8為ForkJoinPool添加了一個通用線程池,這個線程池用來處理那些沒有被顯式提交到任何線程池的任務。它是ForkJoinPool類型上的一個靜態元素,它擁有的默認線程數量等於運行計算機上的處理器數量。

當調用Arrays類上添加的新方法時,自動並行化就會發生。比如用來排序一個數組的並行快速排序,用來對一個數組中的元素進行並行遍歷。自動並行化也被運用在Java 8新添加的Stream API中。

比如下面的代碼用來遍歷列表中的元素並執行需要的計算:

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

對於列表中的元素的計算都會以並行的方式執行。forEach方法會為每個元素的計算操作創建一個任務,該任務會被前文中提到的ForkJoinPool中的通用線程池處理。以上的並行計算邏輯當然也可以使用ThreadPoolExecutor完成,但是就代碼的可讀性和代碼量而言,使用ForkJoinPool明顯更勝一籌。

對於ForkJoinPool通用線程池的線程數量,通常使用默認值就可以了,即運行時計算機的處理器數量。如果需要調整線程數量,可以通過設置系統屬性:-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

下面的一組數據用來比較使用ThreadPoolExecutor和ForkJoinPool中的通用線程池來完成上面簡單計算時的性能:

線程數 ThreadPoolExecutor(秒) ForkJoinPool Common Pool(秒)
1 255.6 135.4
2 134.8 110.2
4 77.0 96.5
8 81.7 84.0
16 85.6 84.6

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

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

所以當ForkJoinPool通用線程池實際需要4個工作線程時,可以將它設置成3,那么在運行時可用的工作線程就是4了。

總結

  1. 當需要處理遞歸分治算法時,考慮使用ForkJoinPool。
  2. 仔細設置不再進行任務划分的閾值,這個閾值對性能有影響。
  3. Java 8中的一些特性會使用到ForkJoinPool中的通用線程池。在某些場合下,需要調整該線程池的默認的線程數量。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM