ForkJoinPool與ThreadPoolExecutor的對比與選擇

除了通用的ThreadPoolExecutor之外,Java還提供了一個有特殊用途的線程池,即ForkJoinPool。這個類跟ThreadPoolExecutor類大體相似,實現了Executor和ExecutorService接口。當使用這些接口的時候,ForkJoinPool會使用一個無界隊列來存儲任務,這些任務由線程池構造函數中指定的線程數執行。如果沒有設置線程數的話,則默認使用當前機器可用CPU數或者Docker容器中配置的CPU數大小的線程數量。

ForkJoinPool往往用於實現分治算法,將一個任務分解成多個具備可加性的子任務,然後可以並行執行這些子任務最後將子任務的運算結果進行一次聚合運算得到最終結果,比如快速排序算法。

對於分治算法的使用,有一點需要注意,就是它往往會創建大量任務,但是你不太可能創建跟任務數量相當的線程來執行它們。舉個例子,要對一個1000萬個元素的數組進行排序,那麼分解子任務的流程是這樣子:對數組對半拆分後進行排序,再對兩個子數組進行一次合併,這個過程可以遞歸化,直到子數組長度爲奇數或者說長度已經很小的時候。

假設分解直到子數組長度<=47的時候,那麼現在有262144個用於對子數組進行排序的任務,有131072個用於合併這些子數組的任務,合併後的子任務還需要額外的65536個任務進行再一次合併,以此類推,最終會產生524287個任務。

不難發現,直到子任務完成之前,其父任務是無法執行的,如果我們用ThreadPoolExecutor來實現該算法,性能會相當差。但是ForkJoinPool中的線程,則不需要在子任務完成之前保持等待,當任務被暫停的時候,它可以去執行其他待處理的任務。

舉個簡單的例子:現在有一個double類型的數組,需要計算數組中小於0.5的元素的數量,我們使用分治策略來完成這個任務。

public class TestForkJoinPool {
    private static 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;
        }
        @Override
        protected Integer compute() {
            int subCount = 0;
            if (last - first < 10) {
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5) {
                        subCount++;
                    }
                }
                return 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;
        }
    }
這裏的fork()和join()方法是關鍵,使用ThreadPoolExecutor的話是無法實現這種遞歸的。這兩個方法使用一系列內部的,每線程隊列來執行任務,以及實現線程所執行的任務的切換。這些細節對開發者來說是透明的。那麼,實際應用中ForkJoinPool和ThreadPoolExecutor類要如何做選擇呢?

首先,fork/join方法具備暫停執行中的任務的作用,這使得所有的任務只需要幾個線程就能運行。如果以上代碼中傳入一個200萬元素的數組,會產生多達400萬個任務,但是要運行它們,卻只需要幾個線程甚至是一個。如果使用ThreadPoolExecutor運行類似任務,則需要400萬個線程,因爲每個線程都必須等待其子任務完成,而這些子任務只有在線程池中有額外線程可用的時候纔可以完成。所以fork/join的暫停可以讓我們使用原本不能使用的算法,這是性能上的一大優勢。

當然,示例中的使用場景在實際生產中並不多見,實際上更多應用於以下場景:

合併結果集(非示例中的簡單累加)。
算法設計可以很好的限制任務數量時。
在其他情況下,將數組分割成多個然後用ThreadPoolExecutor開多個線程遍歷子數組會更簡單,例如使用一個核心線程數和最大線程數均爲4,以LinkedBlockingQueue爲任務隊列的線程池,將數組均分成4個,使用4個線程對這4個子數組進行遍歷,這樣子也不至於創建過多的任務,性能也會更可觀。下面是一個測試對比:

線程數量    ForkJoinPool    ThreadPoolExecutor
1    285±15ms             5ms
4    86±20ms             1ms
           
造成如此大差距的主要原因是分治算法生成了大量的任務對象,管理這些任務對象的開銷阻礙了ForkJoinPool的性能,在GC上也有影響。因此如果有其他可替代方案的話,應當避免這種情況。

 

工作竊取
如上所述,使用ForkJoinPool的第一個原則是確保任務拆分的合理性。它除了暫停任務之外還有另一個更強大的特性,就是它實現了工作竊取。它的池中的每一個線程,都有着自己的專屬任務隊列,線程會優先處理自己隊列中的任務,如果隊列是空的,就會去其他線程的隊列中尋找任務。因此,即便400萬個任務當中,某個任務執行時間很長,ForkJoinPool中的其他線程也可以完成其餘的任務。而ThreadPoolExecutor就做不到這點了,如果這種情況發生在它身上,其他線程無法接手額外的任務。

接下來改造下原先的例子,使得數組中的元素值會根據其下標發生變化。

 

 

 

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < i; j++) {
        d[i] += j;
    }
}
 

由於外循環是基於元素在數組中的位置的,所以計算的時長會與元素位置成正比,比如計算d[0]的值會非常快,但是計算d[d.length-1]就需要更多的時間。

在這個場景下,如果使用ThreadPoolExecutor,並將數組均分爲4份去計算的話,計算第四個子數組(假設順序切分)的時長要遠超過計算第一個子數組的時長。一旦計算第一個子數組的線程完成任務後,它將會進入空閒狀態。

而改用ForkJoinPool實現的話,雖然有一個線程會卡在第四個數組的計算上,但是其他的線程依然可以保持工作狀態,而不會無所事事。以下是測試對比的結果:

線程數量    ForkJoinPool    ThreadPoolExecutor
1    31±3s    30±3s
4    6±1s    10±2s
只用一個線程的話,兩者的結果是基本一致的。當線程數量達到4個的時候,ForkJoinPool就佔據了一定的優勢。當一系列任務中有些任務耗時會比其他任務更長的時候,會導致不平衡的情況,由此可以得出結論:當任務能被分割成一個執行效率平衡的集合時,分割並使用ThreadPoolExecutor會得到更好的性能,反之則是ForkJoinPool更適合。

這裏其實還可以做更進一步的性能調優,但是這就偏向於算法層面了:就是想清楚何時結束遞歸。在以上的例子中,是在數組大小小於10的時候結束遞歸。但是在執行效率平衡的情況下,在500000的時候結束是更合適的。

然而,在不平衡的情況下,更小的子數組則會獲得更好的性能,還是沿用上述的數組中的元素值會根據其下標發生變化的例子,下面是測試結果(爲了節約時間減少到20萬個元素):

子數組大小    ForkJoinPool
100000    17988±100ms
50000    10613±100ms
10000    4964±100ms
1000    3940±100ms
100    3735±100ms
10    3687±100ms
這種對葉值的調整在這類算法中是很常見的。Java的快速排序實現,葉值爲47。

 

自動並行
Java具備自動並行化某些類型代碼的能力,而這個能力依賴於ForkJoinPool。JVM會爲此創建一個通用的fork-join線程池,它是ForkJoinPool類的一個靜態對象,大小默認爲機器上的可用處理器數量。

在Arrays類的方法當中,這種自動並行很多見,比如使用快速排序算法對數組進行排序,對數組中的每個元素進行操作的方法等。在流處理當中也有用到,藉此可以對集合中的每個元素進行操作(串行或者並行)。

下面是一個例子,創建一個用戶對象的集合,然後計算每個用戶的活躍係數:

List<User> users= ...;
Stream<User> stream = users.parallelStream();
stream.forEach(u -> {
    int val=calculate(u);
    ...
});
foreach()方法會爲每個用戶對象創建一個任務,然後每個任務會交由JVM中通用的ForkJoinPool處理。

調整公共ForkJoinPool大小和調整其他線程池大小一樣重要。默認情況下,公共線程池的線程數與機器的可用CPU一樣多。如果一臺機器上運行了多個JVM,往往就需要考慮限制線程數量,這樣JVM之間就不會搶奪資源。同理,如果一臺服務器要並行執行其他的請求,但是你又想確保有足夠的CPU資源去做這個事情,就可以考慮減少公共線程池的線程數量。當然,如果公共池中的任務常常阻塞等待IO,就可能會需要增加公共池的大小。

要調整公共池大小的話,可以通過修改Java系統屬性Djava.util.concurrent.ForkJoinPool.common.parallelism=N來實現。這個跟版本有一定的關係,Java8的192版本之前,都需要去手動設置,你可以通過以下方法

ForkJoinPool.commonPool().getParallelism()
來查看當前公共池的大小,注意,運行過程中用這個方法調整是沒用的,你必須在ForkJoinPool類被加載之前進行修改。

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","20");
 

這裏還有一點需要額外注意,foreach()方法會同時使用執行語句中的線程和公共池中的線程來處理流中的元素。因此,如果在使用並行流或者其他自動並行化方法,且需要調整公共池大小的時候,可以把期望值減少1。
————————————————
版權聲明:本文爲CSDN博主「零薄獄」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/m0_37173810/article/details/109635794

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章