在java7之前,處理並行數據非常麻煩. 第一:你得明確的把包含的數據結構分成若干子部分. 第二:你要給每個子部分分配獨立的線程. 第三:你需要在恰當的時候對他們進行同步,來避免不希望出現的競爭條件,等待所有線程完成,最後把這些結果合併起來.
在前面的文章中,我們介紹了 Stream接口,讓你可以很方便的處理它的元素,可以調用ParallelStream 方法把集合轉換成並行流.
並行流就是把一個內容分成多個數據塊,並用不同線程分別處理每個數據塊的流.
這樣一來,你就可以把給定的工作負荷自動分配給多個處理器內核,讓他們都忙起來.
假設你需要寫一個方法,接受數字n作爲參數,並返回從1到給定參數的所有數字的和。一個 直接(也許有點土)的方法是生成一個無窮大的數字流,把它限制到給定的數目,然後用對兩個 數字求和的 BinaryOperator 來歸約這個流,如下所示:
//順序
public static Long sequentialSum(Long n){
return Stream.iterate(1L, i->i+1L)
.limit(n)
.reduce(0L,Long::sum);
}
用更爲傳統的Java術語來說,這段代碼與下面的迭代等價
//傳統迭代
public static Long iterativeSum(Long n){
Long result = 0L;
for (Long i=1L;i<n;i++){
result = result+i;
}
return result;
}
這似乎是利用並行流的好機會,特別是n很大的時候,那該怎樣做呢?
你要對結果變量進行同步嗎?用多少個線程呢?誰負責生成數呢?誰來做加法呢?
其實根本不必擔心,並行流已經幫我們做完了這些令人頭疼的工作
將順序流轉換爲並行流
//並行流
public static Long parallelSum(Long n){
return Stream.iterate(1L,i->i+1L)
.limit(n)
.parallel()
.reduce(0L,Long::sum);
}
在現實中,對順序流調用 parallel 方法並不意味着流本身有任何實際的變化。它 在內部實際上就是設了一個 boolean 標誌,表示你想讓調用 parallel 之後進行的所有操作都並 行執行.類似地,你只需要對並行流調用 sequential 方法就可以把它變成順序流.
測量流性能
我們說並行求和的方法應該比順序迭代的方法更好.但在軟件工程上, 靠猜絕對不是什麼好辦法,有時候經驗也靠不住. 你應該始終遵循三個黃金規則;測量,測量,再測量.
爲了簡化測量,我們寫個方法,專門用來測試 ParallelStreams類裏的三個求和方法: sequentialSum iterativeSum, parallelSum.
public Long measureSumPref(Function<Long, Long> addr, Long n) {
long fastest = Long.MAX_VALUE;
for (int i = 0; i < 10; i++) {
Long start = System.nanoTime();
Long sum = addr.apply(n);
Long druation = (System.nanoTime() - start)/1000000;
if (druation < fastest) {
fastest = druation;
}
}
return fastest;
}
這個方法會接收一個函數和一個Long類型參數.它會對傳給方法的參數應用函數10次,記錄每次執行的時間.
下面是測試結果
//順序
@Test
public void test4() {
Long fast = measureSumPref(ParallelStreams::sequentialSum, 1000 * 10000L);
System.out.println("sequentialSum= " + fast);//398毫秒
}
//迭代
@Test
public void test5() {
Long fast = measureSumPref(ParallelStreams::iterativeSum, 1000 * 10000L);
System.out.println("iterativeSum= "+ fast);//153毫秒
}
//並行
@Test
public void test6(){
Long fast = measureSumPref(ParallelStreams::parallelSum, 1000 * 10000L);
System.out.println("parallelSum= "+fast);//1309毫秒
}
看到結果,我們發現並行流操作相當令我們失望.
求和方法的並行版本比順序版本要慢很多!!! 其實對這個意外的結果,有兩方面的原因:
-
一:iterate 生成的是裝箱對象,必須拆箱成數字才能求和.
-
二:我們很難把iterate分成多個獨立的塊來執行.
對於第二個問題,很有意思,我們直覺上可能是這樣運行的,如圖:
但是,iterate 很難分割成能夠獨立執行的小塊,因爲每次應用這個函數都要依賴前一次應用的結果
也就是說,整張數字表在歸納過程開始時還沒準備好,因爲Stream在遇到終端操作纔會開始執行,因而無法有效的把流劃分爲小塊進行處理. 把流標記爲並行,其實是給順序處理增加了開銷,它還要把每次求和操作的結果分到一個不同的線程上.
這就說明了並行編程肯能很複雜,如果用得不對(比如採用了一個不易並行化的操作,如 iterate ),它甚至可能讓程序的整體性能更差. 所以在調用那個看似神奇的 parallel 操作時,瞭解背後到底發生了什麼是很有必要的。
並行流使用注意事項:使用更有針對性的方法
對於上面那種出人意料的結果,我們萬不可把鍋退給並行流,其實仔細分析,不難發現,這是我們使用了不恰當的的數據結構導致的.
對於上面的並行處理操作,我們可做如下改進.在之前的文章中,我們介紹過一個叫LongStream的流.這個流有個專門針對Long型的方法
- LongStream.rangeClosed 直接產生原始類型的long數字,沒有裝箱拆箱的開銷.
- LongStream.rangeClosed 會生成數字範圍,很容易查分爲獨立的小塊.
LongStream和Stream一樣都繼承了BaseStream
public interface LongStream extends BaseStream<Long, LongStream> {...}
public interface Stream<T> extends BaseStream<T, Stream<T>> {...}
這兩個流的用法基本完全相同,唯一的不同相比從名字就能看出來,LongStream 指明瞭流類型爲Long,類似的還有,IntStream,DoubleStream等
我們改進代碼如下:
//順序流改進版 LongStream.rangeClosed
public static Long sequentialSum2(Long n) {
return LongStream.rangeClosed(1, n)
.reduce(0L,Long::sum);
}
//並行流改進版
public static Long paraparallelSum2(Long n) {
return LongStream.rangeClosed(1, n)
.parallel()
.reduce(0L,Long::sum);
}
然後再次進行測量
//順序流(改進版)
@Test
public void test7(){
Long fast = measureSumPref(ParallelStreams::sequentialSum2, 1000 * 10000L);
System.out.println("順序流(改進版)="+fast);//56毫秒------改進之前:398毫秒
}
//並行流(改進版)
@Test
public void test8(){
Long fast = measureSumPref(ParallelStreams::paraparallelSum2, 1000 * 10000L);
System.out.println("並行流(改進版)="+fast);//14毫秒--------改進之前:1309毫秒
}
由此結果可得出結論:
-
使用LongStream比iterate效率提高 710%
-
在上面基礎上使用 並行流 比 順序流 效率提高 400%
可見:選擇適當的數據結構往往比並行算法正重要,使用正確的數據結構後再選擇並行算法能保證最佳的性能.
儘管如此,我們也必須知道,並行化不是沒有代價的.並行化本身需要對流做遞歸劃分,把每個子流的歸納操作分配到不同的線程,然後把這些操作的結果合併成一個值.
但在多個內核之間移動數據的代價也可能比你想的要大,所以在使用並行操作很重要的一點就是要保證並行執行的工作時間要比數據在內核之前移動的時間要長.
在使用並行Stream加速代碼之前,你必須確保用的對,如果用錯了,算得快就毫無意義了.讓我們看一個常見的陷阱.
高效使用並行流
如果有疑問,測量。把順序流轉成並行流輕而易舉,但卻不一定是好事
-
留意裝箱。自動裝箱和拆箱操作會大大降低性能。Java 8中有原始類型流( IntStream 、 LongStream 、 DoubleStream )來避免這種操作,但凡有可能都應該用這些流
-
有些操作本身在並行流上的性能就比順序流差。特別是 limit 和 findFirst 等依賴於元 素順序的操作,它們在並行流上執行的代價非常大。例如, findAny 會比 findFirst 性 能好,因爲它不一定要按順序來執行。
-
還要考慮流的操作流水線的總計算成本。設N是要處理的元素的總數,Q是一個元素通過 流水線的大致處理成本,則N*Q就是這個對成本的一個粗略的定性估計。Q值較高就意味 着使用並行流時性能好的可能性比較大。
-
對於較小的數據量,選擇並行流幾乎從來都不是一個好的決定。並行處理少數幾個元素 的好處還抵不上並行化造成的額外開銷
-
要考慮流背後的數據結構是否易於分解。例如, ArrayList 的拆分效率比 LinkedList 高得多,因爲前者用不着遍歷就可以平均拆分,而後者則必須遍歷
-
還要考慮終端操作中合併步驟的代價是大是小(例如 Collector 中的 combiner 方法)
需要強調的是:並行流背後使用的基礎架構是java7引入的分支/合併框架.我們想要正確高效的使用並行流,瞭解它的內部原理至關重要.
分支/合併框架詳解
分支框架的目的是以遞歸的方式將可以並行的任務拆分成更小的任務,然後將每個子任務的結果合併起來生成整體結果.
它是 ExecutorService 接口的一個實現,他把子任務分配給線程池(ForkJoinPool)中的線程.
使用 RecursiveTask
要把任務提交到池,必須創建 RecursiveTask 的一個子類,其中V是並行化任務產生的結果類型,
RecursiveTask類源碼:
public abstract class RecursiveTask<V> extends ForkJoinTask<V> {
private static final long serialVersionUID = 5232453952276485270L;
/**
* The result of the computation.
*/
V result;
/**
* The main computation performed by this task.
* @return the result of the computation
*/
protected abstract V compute();
public final V getRawResult() {
return result;
}
protected final void setRawResult(V value) {
result = value;
}
/**
* Implements execution conventions for RecursiveTask.
*/
protected final boolean exec() {
result = compute();
return true;
}
}
要定義 RecursiveTask, 只需實現它唯一的抽象方法compute :
@Override
protected Long compute() {
return null;
}
這個方法定義了將任務拆分成子任務的邏輯,以及無法再拆分或不便再拆分,生成單個子任務結果的邏輯.
即(僞代碼如下):
if (任務足夠小或不可分) {
順序計算該任務
} else {
將任務分成兩個子任務
遞歸調用本方法,拆分每個子任務,等待所有子任務完成
合併每個子任務的結果
}
遞歸的任務拆分過程如圖:
如果你瞭解著名的分治算法,會發現這不過是分支算法的並行版本而已.
接下來我們舉一個用分支/合併框架的實際例子,還以前面的例子爲基礎,讓我們試着用這個框架爲一個數字範圍(這裏用一個long[] 數組表示)求和
/**
* 分支合併框架測試
*
* @author itguang
* @create 2017-11-18 14:22
**/
public class ForkJoinTest extends RecursiveTask<Long> {
//要處理的任務數組
private final long[] numbers;
//子任務處理數組的起始和結束位置
private final int start;
private final int end;
//閥值,當數組小於10000就並行執行
public static final long THRESHOLD = 10000;
//公共構造函數,用於創建子任務
//私有構造函數,用於 以遞歸方式爲主任務創建子任務
public ForkJoinTest(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
public ForkJoinTest(long[] numbers) {
this(numbers, 0, numbers.length);
}
@Override
protected Long compute() {
int length = end - start;
//如果大小小於等於閥值,則順序計算結果
if (length <= THRESHOLD) {
return computeSequentially();
}
//否則,創建一個子任務爲數組的前一半求和
ForkJoinTest leftTask = new ForkJoinTest(numbers, start, start + length / 2);
//利用另一個 ForkJoinPool 裏的線程異步執行新創建的子任務.
leftTask.fork();//對子任務調用 fork 方法可以把它排進 ForkJoinPool 。
//創建一個任務爲數組的後一半求和
ForkJoinTest rightTask = new ForkJoinTest(numbers, start + length / 2, end);
//**遞歸操作**
long rightResult = rightTask.compute();
//遇到遞歸終止條件,讀取本次遞歸第一個子任務的結果,如果尚未完成就等待
long leftResult = leftTask.join();
//遞歸累加
return leftResult+rightResult;
}
//計算和
private long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
}
測試:創建一個 ForkJoinPool,並把任務傳遞給它的invoke()方法.在ForkPool中執行時,返回結果就是ForkJoinTest的並行遞歸求和結果
@Test
public void test9(){
long[] numbers = LongStream.rangeClosed(1, 1000*10000).toArray();
ForkJoinTest forkJoinTest = new ForkJoinTest(numbers);
Long sum = new ForkJoinPool().invoke(forkJoinTest);
System.out.println(sum);//50000005000000
}
請注意在實際應用時,使用多個 ForkJoinPool 是沒有什麼意義的。正是出於這個原因,一 般來說把它實例化一次,然後把實例保存在靜態字段中,使之成爲單例,這樣就可以在軟件中任 何部分方便地重用了。這裏創建時用了其默認的無參數構造函數,這意味着想讓線程池使用JVM 能夠使用的所有處理器。更確切地說,該構造函數將使用 Runtime.availableProcessors 的 返回值來決定線程池使用的線程數。請注意 availableProcessors 方法雖然看起來是處理器, 但它實際上返回的是可用內核的數量,包括超線程生成的虛擬內核。
當把一個ForkJoinTask 任務交給ForkJoinPool時,這個任務就由池中的一個線程執行,這個線程會調用任務的 compute 方法. 該方法會檢查任務是否小到足以順序執行,如果不夠小則會把要求和的數組分成兩半,分給兩個新的 ForkJoinTest ,而它們也由ForkJoinPool 安排執行.
因此這一過程可以遞歸重複,把原任務拆分成更小的任務執行,知道滿足不可拆分的條件,在上例中是拆分數組的大小小於閥值. 這時候會從遞歸終止開始順序計算每個任務的結果.然後由分支創建的二叉樹遍歷回它的根.接下來會合並每個子任務的部分結果,從而得到總任務的結果.
如圖:
使用分支/合併框架的最佳做法
-
對一個任務調用 join 方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子 任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜, 因爲每個子任務都必須等待另一個子任務完成才能啓動。
-
不應該在 RecursiveTask 內部使用 ForkJoinPool 的 invoke 方法。相反,你應該始終直 接調用 compute 或 fork 方法,只有順序代碼才應該用 invoke 來啓動並行計算。
-
對子任務調用 fork 方法可以把它排進 ForkJoinPool 。同時對左邊和右邊的子任務調用 它似乎很自然,但這樣做的效率要比直接對其中一個調用 compute 低。這樣做你可以爲 其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷
-
和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計 算快。我們已經說過,一個任務可以分解成多個獨立的子任務,才能讓性能在並行化時 有所提升。所有這些子任務的運行時間都應該比分出新任務所花的時間長;一個慣用方 法是把輸入/輸出放在一個子任務裏,計算放在另一個裏,這樣計算就可以和輸入/輸出 同時進行。此外,在比較同一算法的順序和並行版本的性能時還有別的因素要考慮。就 像任何其他Java代碼一樣,分支/合併框架需要“預熱”或者說要執行幾遍纔會被JIT編 譯器優化。這就是爲什麼在測量性能之前跑幾遍程序很重要,我們的測試框架就是這麼 做的。同時還要知道,編譯器內置的優化可能會爲順序版本帶來一些優勢(例如執行死 碼分析——刪去從未被使用的計算)。
ForkJoinTask工作竊取算法
在 ForkJoinSumCalculator 的例子中,我們決定在要求和的數組中最多包含10 000個項目 時就不再創建子任務了。這個選擇是很隨意的,但大多數情況下也很難找到一個好的啓發式方法 來確定它,只能試幾個不同的值來嘗試優化它。在我們的測試案例中,我們先用了一個有1000 萬項目的數組,意味着 ForkJoinSumCalculator 至少會分出1000個子任務來。這似乎有點浪費 資源,因爲我們用來運行它的機器上只有四個內核。在這個特定例子中可能確實是這樣,因爲所 有的任務都受CPU約束,預計所花的時間也差不多。
但分出大量的小任務一般來說都是一個好的選擇。這是因爲,理想情況下,劃分並行任務時, 應該讓每個任務都用完全相同的時間完成,讓所有的CPU內核都同樣繁忙。不幸的是,實際中,每 個子任務所花的時間可能天差地別,要麼是因爲劃分策略效率低,要麼是有不可預知的原因,比如 磁盤訪問慢,或是需要和外部服務協調執行。
分支/合併框架工程用一種稱爲工作竊取(work stealing)的技術來解決這個問題。在實際應 用中,這意味着這些任務差不多被平均分配到 ForkJoinPool 中的所有線程上。每個線程都爲分 配給它的任務保存一個雙向鏈式隊列,每完成一個任務,就會從隊列頭上取出下一個任務開始執 行。基於前面所述的原因,某個線程可能早早完成了分配給它的所有任務,也就是它的隊列已經 空了,而其他的線程還很忙。這時,這個線程並沒有閒下來,而是隨機選了一個別的線程,從隊 列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的隊 列都清空。這就是爲什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作線程 之間平衡負載。
一般來說,這種工作竊取算法用於在池中的工作線程之間重新分配和平衡任務。如圖展示 了這個過程。當工作線程隊列中有一個任務被分成兩個子任務時,一個子任務就被閒置的工作線 程“偷走”了。如前所述,這個過程可以不斷遞歸,直到規定子任務應順序執行的條件爲真。
小結:
-
內部迭代讓你可以並行處理一個流,而無需在代碼中顯式使用和協調不同的線程。
-
雖然並行處理一個流很容易,卻不能保證程序在所有情況下都運行得更快。並行軟件的 行爲和性能有時是違反直覺的,因此一定要測量,確保你並沒有把程序拖得更慢。
-
從性能角度來看,使用正確的數據結構,如儘可能利用原始流而不是一般化的流,幾乎 總是比嘗試並行化某些操作更爲重要
-
分支/合併框架讓你得以用遞歸方式將可以並行的任務拆分成更小的任務,在不同的線程 上執行,然後將各個子任務的結果合併起來生成整體結果
-
Spliterator 定義了並行流如何拆分它要遍歷的數據。
引用 《java8實戰》 github