JAVA併發系列--ForkJoinPool初體驗

ForkJoinPool初體驗

ForkJoinPool是什麼

​ ForkJoinPool是JAVA中較新的線程池,先來嘗試一下學習使用。他主要用來處理能夠產生子任務的任務。

這個線程池是在 JDK 7 加入的,它的名字 ForkJoin 也描述了它的執行機制,主要用法和之前的線程池是相同的,也是把任務交給線程池去執行,線程池中也有任務隊列來存放任務。但是 ForkJoinPool 線程池和之前的線程池有兩點非常大的不同之處。第一點它非常適合執行可以產生子任務的任務。

如圖所示,我們有一個 Task,這個 Task 可以產生三個子任務,三個子任務並行執行完畢後將結果彙總給 Result,比如說主任務需要執行非常繁重的計算任務,我們就可以把計算拆分成三個部分,這三個部分是互不影響相互獨立的,這樣就可以利用 CPU 的多核優勢,並行計算,然後將結果進行彙總。這裏面主要涉及兩個步驟,第一步是拆分也就是 Fork,第二步是彙總也就是 Join,到這裏你應該已經瞭解到 ForkJoinPool 線程池名字的由來了。

// 摘自《Java 併發編程 78 講》

​ 以典型的斐波那契數列爲例,f(n)=f(n-1)+f(n-2)。一個任務,可以產生兩個子任務,最終彙總成這個任務的結果。經典的實現方式有那麼幾種:

  1. 遞歸,自頂向下的思想,一直遞歸到f(1)爲止。這樣會產生一顆高log(n)的樹,節點個數範圍大約2^n。對棧的消耗是恐怖的指數級。
  2. 迭代,自底向上的思想,可以一直從f(1)開始計算到f(n),循環中每次結果都是上一項加上再上一項的值,不僅不消耗棧,也沒有分裂產生任務的需求。
  3. 遞歸 + 記憶。 每次計算該項前先去map,或者array容器中查詢該項是否存在,若存在則直接返回,若不存在則取n-1以及n-2項,若有一項取不到,則將其遞歸取值,最後累加得到結果,放入容器,並返回。[因爲f(n-1)=f(n-2)+f(n-3),f(n-2)依次往下推都會被重複執行很多次]。
  4. 迭代 + 記憶。 自底向上計算,把每次循環的計算結果保存到容器中[這裏就不去容器取了,加法的耗時可比去map取短多了]。那放容器有啥用。。用處就是:
    1. 下次有要取 [1…n]範圍內的數的結果,可以直接取到,
    2. 下次要取的數m大於n時,可以從n開始自底向上計算,節約一大部分計算時間。

嗯。。有點扯遠了,我要實踐的是ForkJoinPool。那麼從上面的四種經典實現,以及Fork拆分任務的特性,肯定只能只用遞歸的方法了,那麼就基於第三種實現方式做改造。

先貼上第三種實現方式的代碼:

/**
 * 遞歸的方式 + 緩存
 */
public class Fib3 implements Fib{

    public Map<Integer, Integer> fib = new HashMap<>();
    @Override
    public int fib(int n) {
        Integer result = fib.get(n);
        if (result != null) {
            return result;
        } else {
            doFib(n);
        }
        return fib.get(n);
    }

    private void doFib(int n) {
        if (n <= 2) {
            fib.put(n, 1);
            return;
        }
        fib.put(n, fib(n - 1) + fib(n - 2));
    }
}

ForkJoinPool的使用

/**
 * FibForkJoin 遞歸 + 緩存
 */
public class FibForkJoin2 extends RecursiveTask<Integer> {
    private volatile static Map<Integer, Integer> map = new ConcurrentHashMap<Integer, Integer>(50);
    int n;

    public FibForkJoin2(int n) {
        this.n = n;
    }

    @Override
    protected Integer compute() {
        if (n <= 1) {
            return n;
        }
        if (map.get(n) != null) {
            return map.get(n);
        }
        // 分裂任務
        FibForkJoin2 f1 = new FibForkJoin2(n - 1);
        f1.fork();
        FibForkJoin2 f2 = new FibForkJoin2(n - 2);
        f2.fork();
        // 合併任務
        int result = f1.join() + f2.join();
        map.put(n, result);
        return result;
    }
}

MAIN方法測試

    public static void main(String[] args) {
        int n = 45;
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        long endTime;
        long startTime = System.currentTimeMillis();
        ForkJoinTask<Integer> task = forkJoinPool.submit(new FibForkJoin2(n));
        try {
            System.out.println(task.get());
            endTime = System.currentTimeMillis();
            System.out.println("forkJoin耗時 :" + (endTime - startTime) );
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        Fib3 fib3 = new Fib3();
        startTime = System.currentTimeMillis();
        System.out.println(fib3.fib(n));

        endTime = System.currentTimeMillis();
        System.out.println("Fib3耗時 :" + (endTime - startTime) );

    }

測試結果一

1134903170
forkJoin耗時 :4
1134903170
Fib3耗時 :0

Process finished with exit code 0
  • 結果很“感人”,用了ForkJoinPool反而變慢了,正常的遞歸+記憶,耗時1毫秒都不到。其實也很好理解的,斐波那契數列計算的一個函數耗時本來就很低,而且加了記憶化之後,就是一個get完事,這樣的消耗遠低於線程的創建、切換等開銷。

  • 實際業務場景上,用到線程池去處理的任務,他不會只是計算一點點東西,往往是十分耗時的,我們模擬sleep(30),來進行嘗試,再上方兩個代碼中都加上sleep,代碼如下

// FORK
    @Override
    protected Integer compute() {
        if (n <= 1) {
            return n;
        }
        if (map.get(n) != null) {
            try {
                Thread.sleep(30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return map.get(n);
        }
        FibForkJoin2 f1 = new FibForkJoin2(n - 1);
        f1.fork();
        FibForkJoin2 f2 = new FibForkJoin2(n - 2);
        f2.fork();
        int result = f1.join() + f2.join();
        map.put(n, result);
        return result;
    }

// FIB3
@Override
    public int fib(int n) {
        Integer result = fib.get(n);
        if (result != null) {
            try {
                Thread.sleep(30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return result;
        } else {

            doFib(n);
        }
        return fib.get(n);
    }

實驗結果二

1134903170
forkJoin耗時 :1252
1134903170
Fib3耗時 :1372

Process finished with exit code 0
  • 從實驗結果二來看,單個僅僅是任務耗時需要10毫秒時,用ForkJoinPool就開始體現優勢了。那麼再試試高時延的情況,比如改成500毫秒再來嘗試

實驗結果三

1134903170
forkJoin耗時 :19235
1134903170
Fib3耗時 :21231
  • 比較結果二以及結果三
    • 實驗二耗時比例是0.912,實驗三耗時比例是0.90。得出結論一:在高耗時的任務中,耗時越高,ForkJoinPool體現的優勢越大。

那麼想看看進一步增加耗時會如何,將sleep的時間調整到1000毫秒。

實驗結果四

1134903170
forkJoin耗時 :38345
1134903170
Fib3耗時 :42438

Process finished with exit code 0
  • 結果比較,
    • 耗時比例是0.90,變化沒有10毫秒改到500毫秒那麼大。
    • 忽略計算斐波那契數列耗時總體4ms不到的時間,比較實驗三和實驗四可以發現,Fib3耗時是原來的1.99(2)倍,而ForkJoinPool的方式也是原來的1.99(2)倍。個人之所以做這個比較,是認爲ForkJoinPool應該是更小的數值,不是剛好2倍的,可能是例子不夠正確吧。所以自己又增加了一組2000毫秒的,發現倍數兩者都是1.98,姑且認爲就是趨近線性的優化吧。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章