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)
。一個任務,可以產生兩個子任務,最終彙總成這個任務的結果。經典的實現方式有那麼幾種:
- 遞歸,自頂向下的思想,一直遞歸到f(1)爲止。這樣會產生一顆高log(n)的樹,節點個數範圍大約2^n。對棧的消耗是恐怖的指數級。
- 迭代,自底向上的思想,可以一直從f(1)開始計算到f(n),循環中每次結果都是上一項加上再上一項的值,不僅不消耗棧,也沒有分裂產生任務的需求。
- 遞歸 + 記憶。 每次計算該項前先去map,或者array容器中查詢該項是否存在,若存在則直接返回,若不存在則取n-1以及n-2項,若有一項取不到,則將其遞歸取值,最後累加得到結果,放入容器,並返回。[因爲f(n-1)=f(n-2)+f(n-3),f(n-2)依次往下推都會被重複執行很多次]。
- 迭代 + 記憶。 自底向上計算,把每次循環的計算結果保存到容器中[這裏就不去容器取了,加法的耗時可比去map取短多了]。那放容器有啥用。。用處就是:
- 下次有要取 [1…n]範圍內的數的結果,可以直接取到,
- 下次要取的數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,姑且認爲就是趨近線性的優化吧。