並行流背後使用的基礎架構是Java 7中引入的分支/合併框架。我們會在本文仔細研究分支/合併框架。
分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任務的結果合併起來生成整體結果。它是 ExecutorService 接口的一個實現,它把子任務分配給線程池(稱爲 ForkJoinPool )中的工作線程。
一、RecursiveTask
要把任務提交到這個池,必須創建 RecursiveTask<R> 的一個子類,其中 R 是並行化任務(以及所有子任務)產生的結果類型,或者如果任務不返回結果,則是 RecursiveAction 類型。
要定義 RecursiveTask, 只需實現它唯一的抽象方法compute :
protected abstract R compute();
在我們實現這個方法時,需要同時定義將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成
單個子任務結果的邏輯。
這個方法的實現類似於下面的僞代碼:
if (任務足夠小或不可分) {
順序計算該任務
} else {
將任務分成兩個子任務
遞歸調用本方法,拆分每個子任務,等待所有子任務完成
合併每個子任務的結果
}
遞歸任務拆分過程如下所示:
分支/合併框架實例:爲一個數字範圍Long[]求和
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;
import static com.cloud.bssp.java8.stream.TestStreamParallel.measureSumPerf;
/**
* @description: 使用ForkJoinPool
* @author:weirx
* @date:2021/10/25 14:10
* @version:3.0
*/
public class TestRecursiveTask extends RecursiveTask<Long> {
/**
* 要求和的數組
*/
private final long[] numbers;
/**
* 子任務求和的數組的開始位置
*/
private int start;
/**
* 子任務求和的數組的結束位置
*/
private int end;
/**
* 私有構造,用於以遞歸方式爲主任務創建子任務
*
* @param numbers
* @param start
* @param end
*/
private TestRecursiveTask(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
/**
* 公共函數用於構建主任務
*
* @param numbers
*/
public TestRecursiveTask(long[] numbers) {
this.numbers = numbers;
}
/**
* 任務拆分的數組最大值
*/
public static final long THRESHOLD = 10000L;
@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD) {
// 如果大小小於等於閾值,則順序計算
return computeSequentially();
} else {
//創建一個子任務,爲數組的前一半求和
TestRecursiveTask left = new TestRecursiveTask(numbers, start, start + length / 2);
//利用另一個ForkJoinPool線程異步執行新創建的子任務
left.fork();
//創建一個子任務,爲數組的後一半求和
TestRecursiveTask right = new TestRecursiveTask(numbers, start + length / 2, end);
// 同步執行第二個子任務
Long compute = right.compute();
//讀取第一個子任務的結果,沒有完成則等待
Long join = left.join();
//結果合併
return compute + join;
}
}
/**
* 當子任務不可拆分時計算結果的簡單算法
*
* @return
*/
private Long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
/**
* 並行對前n個自然數求和
*
* @param n
* @return
*/
public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1, n).toArray();
ForkJoinTask<Long> task = new TestRecursiveTask(numbers);
return new ForkJoinPool().invoke(task);
}
public static void main(String[] args) {
System.out.println("ForkJoin sum done in: " + measureSumPerf(
TestRecursiveTask::forkJoinSum, 10000000) + " msecs");
}
}
輸出結果:
ForkJoin sum done in: 64 msecs
這個性能看起來比用並行流的版本要差,但這只是因爲必須先要把整個數字流都放進一個long[] ,之後才能在任務中使用它。
二、Fork/join的最佳用法
雖然分支/合併框架還算簡單易用,不幸的是它也很容易被誤用。以下是幾個有效使用它的最佳做法:
1)對一個任務調用 join 方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,因爲每個子任務都必須等待另一個子任務完成才能啓動。
2)不應該在 RecursiveTask 內部使用 ForkJoinPool 的 invoke 方法。相反,你應該始終直接調用 compute 或 fork 方法,只有順序代碼才應該用 invoke 來啓動並行計算。
3) 對子任務調用 fork 方法可以把它排進 ForkJoinPool 。同時對左邊和右邊的子任務調用fork()似乎很自然,但這樣做的效率要比直接對其中一個調用 compute 低。調用compute你可以爲其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。
4)調試分支/合併框架的並行計算代碼可能有點棘手。特別是你平常都在你喜歡的IDE裏面看棧跟蹤(stack trace)來找問題,但放在分支/合併計算上就不行了,因爲調用 compute的線程並不是概念上的調用方,後者是調用 fork 的那個。
5)和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計算快。一個任務可以分解成多個獨立的子任務,才能讓性能在並行化時有所提升。所有這些子任務的運行時間都應該比分出新任務所花的時長。
三、工作竊取
工作竊取爲何被提出?
如前面的例子,我們指定數組的大小是10000L,即允許任務被拆分爲每個數組大小爲10000,共1000個任務。
在理想的情況下,每個任務完成的時間應該是相同的,這樣在多核cpu的前提下,我們能保證每個核處理的時間都是相同的。
實際情況中,每個子任務花費的時間可以說是天差地別,磁盤,網絡,或等等很多的因素導致。
Fork/Join框架爲了解決這個提出,提出了工作竊取(work stealing)的概念。
在實際應用中,這意味着這些任務差不多被平均分配到 ForkJoinPool 中的所有線程上。每個線程都爲分配給它的任務保存一個雙向鏈式隊列,每完成一個任務,就會從隊列頭上取出下一個任務開始執行。
基於前面所述的原因,某個線程可能早早完成了分配給它的所有任務,也就是它的隊列已經空了,而其他的線程還很忙。這時,這個線程並沒有閒下來,而是隨機選了一個別的線程,從隊列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的隊列都清空。這就是爲什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作線程之間平衡負載。
一般來說,這種工作竊取算法用於在池中的工作線程之間重新分配和平衡任務。如下圖展示了這個過程。當工作線程隊列中有一個任務被分成兩個子任務時,一個子任務就被閒置的工作線程“偷走”了。如前所述,這個過程可以不斷遞歸,直到規定子任務應順序執行的條件爲真。
四、Spliterator
那麼Stream是如何實現並行的呢?我們並不需要手動去實現Fork/join,這就意味着,肯定有一種自動機制來爲你拆分流。這種新的自動機制稱爲 Spliterator。
Spliterator 是Java 8中加入的另一個新接口;這個名字代表“可分迭代器”(splitableiterator)。和 Iterator 一樣, Spliterator 也用於遍歷數據源中的元素,但它是爲了並行執行而設計的。
public interface Spliterator<T> {
/**
* tryAdvance 方法的行爲類似於普通的Iterator ,因爲它會按順序一個一個使用 Spliterator 中的元素,
* 並且如果有其他元素要遍歷就返回 true
*/
boolean tryAdvance(Consumer<? super T> action);
/**
* 專爲 Spliterator 接口設計的,因爲它可以把一些元素劃出去分
* 給第二個 Spliterator (由該方法返回),讓它們兩個並行處理。
*/
Spliterator<T> trySplit();
/**
* estimateSize 方法估計還剩下多少元素要遍歷
*/
long estimateSize();
int characteristics();
}
4.1 拆分過程
將 Stream 拆分成多個部分的算法是一個遞歸過程,這個框架不斷對 Spliterator 調用 trySplit直到它返回 null ,表明它處理的數據結構不能再分割,流程如下描述。
1)第一步是對第一個Spliterator 調用 trySplit ,生成第二個 Spliterator 。
2)第二步對這兩個 Spliterator 調用trysplit ,這樣總共就有了四個 Spliterator 。
3)第三步,對當前所有的Spliterator 調用trysplit ,當所有的trysplit 都返回null,則表示拆分結束。
4.2 Spliterator特性
Spliterator的拆分過程也收到其本身的特性所影響,特性是通過characteristics()方法來聲明的。
Spliterator 接口聲明的最後一個抽象方法是 characteristics ,它將返回一個 int ,代表 Spliterator 本身特性集的編碼。
有如下特性:
/**
* 元素有既定的順序(例如 List ),因此 Spliterator 在遍歷和劃分時也會遵循這一順序
*/
public static final int ORDERED = 0x00000010;
/**
* 對於任意一對遍歷過的元素 x 和 y , x.equals(y) 返回 false
*/
public static final int DISTINCT = 0x00000001;
/**
* 遍歷的元素按照一個預定義的順序排序
*/
public static final int SORTED = 0x00000004;
/**
* 該 Spliterator 由一個已知大小的源建立(例如 Set ),因此 estimatedSize() 返回的是準確值
*/
public static final int SIZED = 0x00000040;
/**
* 保證遍歷的元素不會爲 null
*/
public static final int NONNULL = 0x00000100;
/**
* Spliterator 的數據源不能修改。這意味着在遍歷時不能添加、刪除或修改任何元素
*/
public static final int IMMUTABLE = 0x00000400;
/**
* 該 Spliterator 的數據源可以被其他線程同時修改而無需同步
*/
public static final int CONCURRENT = 0x00001000;
/**
* 該 Spliterator 和所有從它拆分出來的 Spliterator 都是 SIZED
*/
public static final int SUBSIZED = 0x00004000;