Java 多線程中的任務分解機制-ForkJoinPool,以及CompletableFuture

ForkJoinPool的優勢在於,可以充分利用多cpu,多核cpu的優勢,把一個任務拆分成多個“小任務”,把多個“小任務”放到多個處理器核心上並行執行;當多個“小任務”執行完成之後,再將這些執行結果合併起來即可。

Java7 提供了ForkJoinPool來支持將一個任務拆分成多個“小任務”並行計算,再把多個“小任務”的結果合併成總的計算結果。

ForkJoinPool是ExecutorService的實現類,因此是一種特殊的線程池。

使用方法:創建了ForkJoinPool實例之後,就可以調用ForkJoinPool的submit(ForkJoinTask<T> task) 或invoke(ForkJoinTask<T> task)方法來執行指定任務了。

其中ForkJoinTask代表一個可以並行、合併的任務。ForkJoinTask是一個抽象類,它還有兩個抽象子類:RecusiveAction和RecusiveTask其中RecusiveTask代表有返回值的任務,而RecusiveAction代表沒有返回值的任務

package main;

import java.util.Random;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.TimeUnit;

public class ForkJoinPoolDemo extends RecursiveAction {

	private static final long serialVersionUID = 1L;
	// 定義一個分解任務的閾值——50,即一個任務最多承擔50個工作量
	private int THRESHOLD = 50;
	// 任務量
	private int task_Num = 0;

	ForkJoinPoolDemo(int Num) {
		this.task_Num = Num;
	}

	@Override
	protected void compute() {
		if (task_Num <= THRESHOLD) {
			System.out.println(
					Thread.currentThread().getName() + "承擔了" + task_Num + "份工作");
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		} else {
			// 隨機解成兩個任務
			Random m = new Random();
			int x = m.nextInt(50);

			ForkJoinPoolDemo left = new ForkJoinPoolDemo(x);
			ForkJoinPoolDemo right = new ForkJoinPoolDemo(task_Num - x);

			left.fork();
			right.fork();
		}
	}

	public static void main(String[] args) throws Exception {
		// 創建一個支持分解任務的線程池ForkJoinPool
		ForkJoinPool pool = new ForkJoinPool();
		ForkJoinPoolDemo task = new ForkJoinPoolDemo(120);

		pool.submit(task);
		pool.awaitTermination(20, TimeUnit.SECONDS);// 等待20s,觀察結果
		pool.shutdown();
	}
}

Output:
在這裏插入圖片描述

RecusiveTask的具體實現:


import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ForkJoinCalculator {
	private ForkJoinPool pool;

	public ForkJoinCalculator() {
		// 也可以使用公用的 ForkJoinPool:
		// pool = ForkJoinPool.commonPool()
		pool = new ForkJoinPool();
	}

	public static void main(String[] args) {
		ForkJoinCalculator forkJoinCalculator = new ForkJoinCalculator();
		long[] numbers = new long[20];
		for (int i = 0; i < numbers.length; i++) {
			numbers[i] = (i + 1);
		}
		System.out.println(Arrays.toString(numbers));
		long result = forkJoinCalculator.sumUp(numbers);
		System.out.println("result:" + result);
	}

	private static class SumTask extends RecursiveTask<Long> {
		private static final long serialVersionUID = -4641569091663495034L;
		
		private long[] numbers;
		private int from;
		private int to;

		public SumTask(long[] numbers, int from, int to) {
			this.numbers = numbers;
			this.from = from;
			this.to = to;
		}

		@Override
		protected Long compute() {
			// 當需要計算的數字小於6時,直接計算結果
			if (to - from < 4) {
				long total = 0;
				for (int i = from; i <= to; i++) {
					total += numbers[i];
				}
				System.out.println(
						String.format("currentThread:%s,total:%s,from:%s,to:%s",
								Thread.currentThread().getName(), total, from, to));

				return total;
				// 否則,把任務一分爲二,遞歸計算
			} else {
				int middle = (from + to) / 2;
				SumTask taskLeft = new SumTask(numbers, from, middle);
				SumTask taskRight = new SumTask(numbers, middle + 1, to);
				taskLeft.fork();
				taskRight.fork();
				return taskLeft.join() + taskRight.join();
			}
		}
	}

	public long sumUp(long[] numbers) {
		return pool.invoke(new SumTask(numbers, 0, numbers.length - 1));
	}
}

Output:
在這裏插入圖片描述

分析:

根據上面的示例代碼,可以看出 fork() 和 join() 是 Fork/Join Framework “魔法”的關鍵。我們可以根據函數名假設一下 fork() 和 join() 的作用:

  • fork():開啓一個新線程(或是重用線程池內的空閒線程),將任務交給該線程處理。
  • join():等待該任務的處理線程處理完畢,獲得返回值。

並不是每個 fork() 都會促成一個新線程被創建,而每個 join() 也不是一定會造成線程被阻塞。

Fork/Join Framework 的實現算法並不是那麼“顯然”,而是一個更加複雜的算法——這個算法的名字就叫做 work stealing 算法

  • ForkJoinPool 的每個工作線程都維護着一個工作隊列WorkQueue),這是一個雙端隊列(Deque),裏面存放的對象是任務ForkJoinTask)。
  • 每個工作線程在運行中產生新的任務(通常是因爲調用了 fork())時,會放入工作隊列的隊尾,並且工作線程在處理自己的工作隊列時,使用的是 LIFO 方式,也就是說每次從隊尾取出任務來執行。
  • 每個工作線程在處理自己的工作隊列同時,會嘗試竊取一個任務(或是來自於剛剛提交到 pool 的任務,或是來自於其他工作線程的工作隊列),竊取的任務位於其他線程的工作隊列的隊首,也就是說工作線程在竊取其他工作線程的任務時,使用的是 FIFO 方式。
  • 在遇到 join() 時,如果需要 join 的任務尚未完成,則會先處理其他任務,並等待其完成。
  • 在既沒有自己的任務,也沒有可以竊取的任務時,進入休眠。

fork() 做的工作只有一件事,既是把任務推入當前工作線程的工作隊列裏

join() 的工作則複雜得多,也是 join() 可以使得線程免於被阻塞的原因——不像同名的 Thread.join()

  1. 檢查調用 join() 的線程是否是 ForkJoinThread 線程。如果不是(例如 main 線程),則阻塞當前線程,等待任務完成。如果是,則不阻塞。
  2. 查看任務的完成狀態,如果已經完成,直接返回結果。
  3. 如果任務尚未完成,但處於自己的工作隊列內,則完成它。
  4. 如果任務已經被其他的工作線程偷走,則竊取這個小偷的工作隊列內的任務(以 FIFO 方式),執行,以期幫助它早日完成欲 join 的任務。
  5. 如果偷走任務的小偷也已經把自己的任務全部做完,正在等待需要 join 的任務時,則找到小偷的小偷,幫助它完成它的任務。
  6. 遞歸地執行第5步。

所謂work-stealing模式,即每個工作線程都會有自己的任務隊列。當工作線程完成了自己所有的工作後,就會去“偷”別的工作線程的任務。

假如我們需要做一個比較大的任務,我們可以把這個任務分割爲若干互不依賴的子任務,爲了減少線程間的競爭,於是把這些子任務分別放到不同的隊列裏,併爲每個隊列創建一個單獨的線程來執行隊列裏的任務,線程和隊列一一對應,比如A線程負責處理A隊列裏的任務。但是有的線程會先把自己隊列裏的任務幹完,而其他線程對應的隊列裏還有任務等待處理。幹完活的線程與其等着,不如去幫其他線程幹活,於是它就去其他線程的隊列裏竊取一個任務來執行。而在這時它們會訪問同一個隊列,所以爲了減少竊取任務線程和被竊取任務線程之間的競爭,通常會使用雙端隊列,被竊取任務線程永遠從雙端隊列的頭部拿任務執行,而竊取任務的線程永遠從雙端隊列的尾部拿任務執行。

submit

其實除了前面介紹過的每個工作線程自己擁有的工作隊列以外,ForkJoinPool 自身也擁有工作隊列,這些工作隊列的作用是用來接收由外部線程(非 ForkJoinThread 線程)提交過來的任務,而這些工作隊列被稱爲 submitting queue 。

submit() 和 fork() 其實沒有本質區別,只是提交對象變成了 submitting queue 而已(還有一些同步,初始化的操作)。submitting queue 和其他 work queue 一樣,是工作線程”竊取“的對象,因此當其中的任務被一個工作線程成功竊取時,就意味着提交的任務真正開始進入執行階段。

 

ForkJoinPool與ThreadPoolExecutor區別:

1.ForkJoinPool中的每個線程都會有一個隊列,而ThreadPoolExecutor只有一個隊列,並根據queue類型不同,細分出各種線程池

2.ForkJoinPool能夠使用數量有限的線程來完成非常多的具有父子關係的任務,ThreadPoolExecutor中根本沒有什麼父子關係任務

3.ForkJoinPool在使用過程中,會創建大量的子任務,會進行大量的gc,但是ThreadPoolExecutor不需要,因此單線程(或者任務分配平均)

4.ForkJoinPool在多任務,且任務分配不均是有優勢,但是在單線程或者任務分配均勻的情況下,效率沒有ThreadPoolExecutor高,畢竟要進行大量gc子任務

 

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

ThreadPoolExecutor因爲它其中的線程並不會關注每個任務之間任務量的差異。當執行任務量最小的任務的線程執行完畢後,它就會處於空閒的狀態(Idle),等待任務量最大的任務執行完畢。

因此多任務在多線程中分配不均時,ForkJoinPool效率高。

 

stream中應用ForkJoinPool

		Arrays.asList("a1", "a2", "b1", "c2", "c1")
        .parallelStream()
        .filter(s -> {
            System.out.format("filter: %s [%s]\n",
                    s, Thread.currentThread().getName());
            return true;
        })
        .map(s -> {
            System.out.format("map: %s [%s]\n",
                    s, Thread.currentThread().getName());
            return s.toUpperCase();
        })
        .sorted((s1, s2) -> {
            System.out.format("sort: %s <> %s [%s]\n",
                    s1, s2, Thread.currentThread().getName());
            return s1.compareTo(s2);
        })
        .forEach(s -> System.out.format("forEach: %s [%s]\n",
                s, Thread.currentThread().getName()));

parallelStream讓部分Java代碼自動地以並行的方式執行

最後:

有一點要注意,就是手動設置ForkJoinPool的線程數量時,實際線程數爲設置的線程數+1,因爲還有一個main主線程

即使將ForkJoinPool的通用線程池的線程數量設置爲1,實際上也會有2個工作線程。因此線程數爲1的ForkJoinPool通用線程池和線程數爲2的ThreadPoolExecutor是等價的。

與ForkJoinPool對應的是CompletableFuture

Future以及相關使用方法提供了異步執行任務的能力,但是對於結果的獲取卻是很不方便,只能通過阻塞或者輪詢的方式得到任務的結果。

阻塞的方式顯然和我們的異步編程的初衷相違背,輪詢的方式又會耗費無謂的CPU資源,而且也不能及時地得到計算結果

CompletableFuture就是利用觀察者設計模式當計算結果完成及時通知監聽者

在Java 8中, 新增加了一個包含50個方法左右的類: CompletableFuture,提供了非常強大的Future的擴展功能,可以幫助我們簡化異步編程的複雜性,提供了函數式編程的能力,可以通過回調的方式處理計算結果,並且提供了轉換和組合CompletableFuture的方法。


這個鏈接可以提高對fork-join的理解,不看也不影響使用:

https://www.ibm.com/developerworks/cn/java/j-jtp11137.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章