《Java8實戰》-第七章筆記(並行資料處理與效能)

雷俠發表於2018-10-07

並行資料處理與效能

在前面三章中,我們已經看到了新的 Stream 介面可以讓你以宣告性方式處理資料集。我們還解釋了將外部迭代換為內部迭代能夠讓原生Java庫控制流元素的處理。這種方法讓Java程式設計師無需顯式實現優化來為資料集的處理加速。到目前為止,最重要的好處是可以對這些集合執行操作流水線,能夠自動利用計算機上的多個核心。

例如,在Java 7之前,並行處理資料集合非常麻煩。第一,你得明確地把包含資料的資料結構分成若干子部分。第二,你要給每個子部分分配一個獨立的執行緒。第三,你需要在恰當的時候對它們進行同步來避免不希望出現的競爭條件,等待所有執行緒完成,最後把這些部分結果合併起來。Java 7引入了一個叫作分支/合併的框架,讓這些操作更穩定、更不易出錯。

在本章中,我們將瞭解 Stream 介面如何讓你不用太費力氣就能對資料集執行並行操作。它允許你宣告性地將順序流變為並行流。此外,你將看到Java是如何變戲法的,或者更實際地來說,流是如何在幕後應用Java 7引入的分支/合併框架的。你還會發現,瞭解並行流內部是如何工作的很重要,因為如果你忽視這一方面,就可能因誤用而得到意外的(很可能是錯的)結果。

我們會特別演示,在並行處理資料塊之前,並行流被劃分為資料塊的方式在某些情況下恰恰是這些錯誤且無法解釋的結果的根源。因此,我們將會學習如何通過實現和使用你自己的Spliterator 來控制這個劃分過程。

並行流

在第4章的筆記中,我們簡要地瞭解到了 Stream 介面可以讓你非常方便地處理它的元素:可以通過對收集源呼叫 parallelStream 方法來把集合轉換為並行流。並行流就是一個把內容分成多個資料塊,並用不同的執行緒分別處理每個資料塊的流。這樣一來,你就可以自動把給定操作的工作負荷分配給多核處理器的所有核心,讓它們都忙起來。讓我們用一個簡單的例子來試驗一下這個思想。

假設你需要寫一個方法,接受數字n作為引數,並返回從1到給定引數的所有數字的和。一個直接(也許有點土)的方法是生成一個無窮大的數字流,把它限制到給定的數目,然後用對兩個數字求和的 BinaryOperator 來歸約這個流,如下所示:

public static long sequentialSum(long n) {
    // 生成自然數無限流
    return Stream.iterate(1L, i -> i + 1)
            // 限制到前n個數
            .limit(n)
            // 對所有數字求和來歸納流
            .reduce(0L, Long::sum);
}
複製程式碼

用更為傳統的Java術語來說,這段程式碼與下面的迭代等價:

public static long iterativeSum(long n) {
    long result = 0;
    for (long i = 0; i <= n; i++) {
        result += i;
    }
    return result;
}
複製程式碼

這似乎是利用並行處理的好機會,特別是n很大的時候。那怎麼入手呢?你要對結果變數進行同步嗎?用多少個執行緒呢?誰負責生成數呢?誰來做加法呢?根本用不著擔心啦。用並行流的話,這問題就簡單多了!

將順序流轉換為並行流

我們可以把流轉換成並行流,從而讓前面的函式歸約過程(也就是求和)並行執行——對順序流呼叫 parallel 方法:

public static long parallelSum(long n) {
    // 生成自然數無限流
    return Stream.iterate(1L, i -> i + 1)
            // 限制到前n個數
            .limit(n)
            // 將流轉為並行流
            .parallel()
            // 對所有數字求和來歸納流
            .reduce(0L, Long::sum);
}
複製程式碼

並行流的執行過程:

並行流執行

請注意,在現實中,對順序流呼叫 parallel 方法並不意味著流本身有任何實際的變化。它在內部實際上就是設了一個 boolean 標誌,表示你想讓呼叫 parallel 之後進行的所有操作都並行執行。類似地,你只需要對並行流呼叫 sequential 方法就可以把它變成順序流。請注意,你可能以為把這兩個方法結合起來,就可以更細化地控制在遍歷流時哪些操作要並行執行,哪些要順序執行。例如,你可以這樣做:

stream.parallel()
        .filter(...)
        .sequential()
        .map(...)
        .parallel()
        .reduce();
複製程式碼

但最後一次 parallel 或 sequential 呼叫會影響整個流水線。在本例中,流水線會並行執行,因為最後呼叫的是它。

回到我們的數字求和練習,我們說過,在多核處理器上執行並行版本時,會有顯著的效能提升。現在你有三個方法,用三種不同的方式(迭代式、順序歸納和並行歸納)做完全相同的操作,讓我們看看誰最快吧!

測量流效能

我們聲稱並行求和方法應該比順序和迭代方法效能好。然而在軟體工程上,靠猜絕對不是什麼好辦法!特別是在優化效能時,你應該始終遵循三個黃金規則:測量,測量,再測量。

測量對前n個自然數求和的函式的效能

public static long measurePerf(Function<Long, Long> adder, long n) {
    long fastest = Long.MAX_VALUE;
    for (int i = 0; i < 10; i++) {
        long start = System.nanoTime();
        long sum = adder.apply(n);
        long duration = (System.nanoTime() - start) / 1_000_000;
        System.out.println("Result: " + sum);
        if (duration < fastest) {
            fastest = duration;
        }
    }
    return fastest;
}
複製程式碼

這個方法接受一個函式和一個 long 作為引數。它會對傳給方法的 long 應用函式10次,記錄每次執行的時間(以毫秒為單位),並返回最短的一次執行時間。假設你把先前開發的所有方法都放進了一個名為 ParallelStreams 的類,你就可以用這個框架來測試順序加法器函式對前一千萬個自然數求和要用多久:

System.out.println("Sequential sum done in:" +
                measurePerf(ParallelStreams::sequentialSum, 10_000_000) + " msecs");
複製程式碼

請注意,我們對這個結果應持保留態度。影響執行時間的因素有很多,比如你的電腦支援多少個核心。你可以在自己的機器上跑一下這些程式碼。在一臺i5 6200U 的筆記本上執行它,輸出是這樣的:

Sequential sum done in:110 msecs
複製程式碼

用傳統 for 迴圈的迭代版本執行起來應該會快很多,因為它更為底層,更重要的是不需要對原始型別做任何裝箱或拆箱操作。如果你試著測量它的效能:

System.out.println("Iterative sum done in:" +
                measurePerf(ParallelStreams::iterativeSum, 10_000_000) + " msecs");
複製程式碼

將得到:

Iterative sum done in:4 msecs
複製程式碼

現在我們來對函式的並行版本做測試:

System.out.println("Parallel sum done in: " +
                measurePerf(ParallelStreams::parallelSum, 10_000_000) + " msecs");
複製程式碼

看看會出現什麼情況:

Parallel sum done in: 525 msecs
複製程式碼

這相當令人失望,求和方法的並行版本比順序版本要慢很多。你如何解釋這個意外的結果呢?這裡實際上有兩個問題:

  • iterate 生成的是裝箱的物件,必須拆箱成數字才能求和
  • 我們很難把 iterate 分成多個獨立塊來並行執行。

第二個問題更有意思一點,因為你必須意識到某些流操作比其他操作更容易並行化。具體來說, iterate 很難分割成能夠獨立執行的小塊,因為每次應用這個函式都要依賴前一次應用的結果。

image

這意味著,在這個特定情況下,歸納程式不是像上圖那樣進行的;整張數字列表在歸納過程開始時沒有準備好,因而無法有效地把流劃分為小塊來並行處理。把流標記成並行,你其實是給順序處理增加了開銷,它還要把每次求和操作分到一個不同的執行緒上。

這就說明了並行程式設計可能很複雜,有時候甚至有點違反直覺。如果用得不對(比如採用了一個不易並行化的操作,如 iterate ),它甚至可能讓程式的整體效能更差,所以在呼叫那個看似神奇的 parallel 操作時,瞭解背後到底發生了什麼是很有必要的。

使用更有針對性的方法

那到底要怎麼利用多核處理器,用流來高效地並行求和呢?我們在第5章中討論了一個叫LongStream.rangeClosed 的方法。這個方法與 iterate 相比有兩個優點。

  • LongStream.rangeClosed 直接產生原始型別的 long 數字,沒有裝箱拆箱的開銷。
  • LongStream.rangeClosed 會生成數字範圍,很容易拆分為獨立的小塊。例如,範圍1~20可分為1~5、6~10、11~15和16~20。

讓我們先看一下它用於順序流時的效能如何,看看拆箱的開銷到底要不要緊:

public static long rangedSum(long n) {
    return LongStream.rangeClosed(1, n)
            .reduce(0L, Long::sum);
}
複製程式碼

這一次的輸出是:

Ranged sum done in: 5 msecs
複製程式碼

這個數值流比前面那個用 iterate 工廠方法生成數字的順序執行版本要快得多,因為數值流避免了非針對性流那些沒必要的自動裝箱和拆箱操作。由此可見,選擇適當的資料結構往往比並行化演算法更重要。但要是對這個新版本應用並行流呢?

public static long parallelRangedSum(long n) {
    return LongStream.rangeClosed(1, n)
            .parallel()
            .reduce(0L, Long::sum);
}
複製程式碼

現在把這個函式傳給的測試方法:

System.out.println("Parallel range sum done in:" +
                measurePerf(ParallelStreams::parallelRangedSum, 10_000_000) +
                " msecs");
複製程式碼

你會得到:

Parallel range sum done in:2 msecs
複製程式碼

amazing!終於,我們得到了一個比順序執行更快的並行歸納,因為這一次歸納操作可以像並行流執行圖那樣執行了。這也表明,使用正確的資料結構然後使其並行工作能夠保證最佳的效能。

儘管如此,請記住,並行化並不是沒有代價的。並行化過程本身需要對流做遞迴劃分,把每個子流的歸納操作分配到不同的執行緒,然後把這些操作的結果合併成一個值。但在多個核心之間移動資料的代價也可能比你想的要大,所以很重要的一點是要保證在核心中並行執行工作的時間比在核心之間傳輸資料的時間長。總而言之,很多情況下不可能或不方便並行化。然而,在使用並行 Stream 加速程式碼之前,你必須確保用得對;如果結果錯了,算得快就毫無意義了。讓我們來看一個常見的陷阱。

正確使用並行流

錯用並行流而產生錯誤的首要原因,就是使用的演算法改變了某些共享狀態。下面是另一種實現對前n個自然數求和的方法,但這會改變一個共享累加器:

public static long sideEffectSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n)
            .forEach(accumulator::add);
    return accumulator.total;
}

public static class Accumulator {
    private long total = 0;

    public void add(long value) {
        total += value;
    }
}
複製程式碼

這種程式碼非常普遍,特別是對那些熟悉指令式程式設計正規化的程式設計師來說。這段程式碼和你習慣的那種指令式迭代數字列表的方式很像:初始化一個累加器,一個個遍歷列表中的元素,把它們和累加器相加。

那這種程式碼又有什麼問題呢?不幸的是,它真的無可救藥,因為它在本質上就是順序的。每次訪問 total 都會出現資料競爭。如果你嘗試用同步來修復,那就完全失去並行的意義了。為了說明這一點,讓我們試著把 Stream 變成並行的:

public static long sideEffectParallelSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n)
            .parallel()
            .forEach(accumulator::add);
    return accumulator.total;
}
複製程式碼

執行測試方法,並列印每次執行的結果:

System.out.println("SideEffect parallel sum done in: " +
                measurePerf(ParallelStreams::sideEffectParallelSum, 10_000_000L) + " msecs");
複製程式碼

你可能會得到類似於下面這種輸出:

Result: 9869563545574
Result: 12405006536090
Result: 8268141260766
Result: 11208597038187
Result: 12358062322272
Result: 19218969315182
Result: 11255083226412
Result: 25746147125980
Result: 13327069088874
SideEffect parallel sum done in: 4 msecs
複製程式碼

這回方法的效能無關緊要了,唯一要緊的是每次執行都會返回不同的結果,都離正確值50000005000000 差很遠。這是由於多個執行緒在同時訪問累加器,執行 total += value ,而這一句雖然看似簡單,卻不是一個原子操作。問題的根源在於, forEach 中呼叫的方法有副作用,它會改變多個執行緒共享的物件的可變狀態。要是你想用並行 Stream 又不想引發類似的意外,就必須避免這種情況。

現在你知道了,共享可變狀態會影響並行流以及平行計算。現在,記住要避免共享可變狀態,確保並行 Stream 得到正確的結果。接下來,我們會看到一些實用建議,你可以由此判斷什麼時候可以利用並行流來提升效能。

高效使用並行流

一般而言,想給出任何關於什麼時候該用並行流的定量建議都是不可能也毫無意義的,因為任何類似於“僅當至少有一千個(或一百萬個或隨便什麼數字)元素的時候才用並行流)”的建議對於某臺特定機器上的某個特定操作可能是對的,但在略有差異的另一種情況下可能就是大錯特錯。儘管如此,我們至少可以提出一些定性意見,幫你決定某個特定情況下是否有必要使用並行流。

  • 如果有疑問,測量。把順序流轉成並行流輕而易舉,但卻不一定是好事。我們在本節中已經指出,並行流並不總是比順序流快。此外,並行流有時候會和你的直覺不一致,所以在考慮選擇順序流還是並行流時,第一個也是最重要的建議就是用適當的基準來檢查其效能。
  • 留意裝箱。自動裝箱和拆箱操作會大大降低效能。Java 8中有原始型別流( IntStream 、LongStream 、 DoubleStream )來避免這種操作,但凡有可能都應該用這些流。
  • 有些操作本身在並行流上的效能就比順序流差。特別是 limit 和 findFirst 等依賴於元素順序的操作,它們在並行流上執行的代價非常大。例如, findAny 會比 findFirst 效能好,因為它不一定要按順序來執行。你總是可以呼叫 unordered 方法來把有序流變成無序流。那麼,如果你需要流中的n個元素而不是專門要前n個的話,對無序並行流呼叫limit 可能會比單個有序流(比如資料來源是一個 List )更高效。
  • 還要考慮流的操作流水線的總計算成本。設N是要處理的元素的總數,Q是一個元素通過流水線的大致處理成本,則N*Q就是這個對成本的一個粗略的定性估計。Q值較高就意味著使用並行流時效能好的可能性比較大。
  • 對於較小的資料量,選擇並行流幾乎從來都不是一個好的決定。並行處理少數幾個元素的好處還抵不上並行化造成的額外開銷。
  • 要考慮流背後的資料結構是否易於分解。例如, ArrayList 的拆分效率比 LinkedList高得多,因為前者用不著遍歷就可以平均拆分,而後者則必須遍歷。另外,用 range 工廠方法建立的原始型別流也可以快速分解。
  • 流自身的特點,以及流水線中的中間操作修改流的方式,都可能會改變分解過程的效能。例如,一個 SIZED 流可以分成大小相等的兩部分,這樣每個部分都可以比較高效地並行處理,但篩選操作可能丟棄的元素個數卻無法預測,導致流本身的大小未知。
  • 還要考慮終端操作中合併步驟的代價是大是小(例如 Collector 中的 combiner 方法)。如果這一步代價很大,那麼組合每個子流產生的部分結果所付出的代價就可能會超出通過並行流得到的效能提升。

最後,我們還要強調並行流背後使用的基礎架構是Java 7中引入的分支/合併框架。並行彙總的示例證明了要想正確使用並行流,瞭解它的內部原理至關重要,所以我們會在下一節仔細研究分支/合併框架。

分支/合併框架

分支/合併框架的目的是以遞迴方式將可以並行的任務拆分成更小的任務,然後將每個子任務的結果合併起來生成整體結果。它是 ExecutorService 介面的一個實現,它把子任務分配給執行緒池(稱為 ForkJoinPool )中的工作執行緒。首先來看看如何定義任務和子任務。

使用 RecursiveTask

要把任務提交到這個池,必須建立 RecursiveTask 的一個子類,其中 R 是並行化任務(以及所有子任務)產生的結果型別,或者如果任務不返回結果,則是 RecursiveAction 型別(當然它可能會更新其他非區域性機構)。要定義 RecursiveTask, 只需實現它唯一的抽象方法compute :

protected abstract R compute();
複製程式碼

這個方法同時定義了將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成單個子任務結果的邏輯。正由於此,這個方法的實現類似於下面的虛擬碼:

if (任務足夠小或不可分) {
    順序計算該任務
} else {
    將任務分成兩個子任務
    遞迴呼叫本方法,拆分每個子任務,等待所有子任務完成
    合併每個子任務的結果
}
複製程式碼

一般來說並沒有確切的標準決定一個任務是否應該再拆分,但有幾種試探方法可以幫助你做出這一決定。

任務拆分

你可能已經注意到,這只不過是著名的分治演算法的並行版本而已。這裡舉一個用分支/合併框架的實際例子,還以前面的例子為基礎,讓我們試著用這個框架為一個數字範圍(這裡用一個long[] 陣列表示)求和。如前所述,你需要先為RecursiveTask類做一個實現,就是下面程式碼清單中的ForkJoinSumCalculator 。

用分支/合併框架執行並行求和:

public class ForkJoinSumCalculator extends RecursiveTask<Long> {

    /**
     * 不再將任務分解為子任務的陣列大小
     */
    public static final long THRESHOLD = 10_000;
    /**
     * 要求和的陣列
     */
    private final long[] numbers;
    /**
     * 子任務處理的陣列的起始和終止位置
     */
    private final int start;
    private final int end;

    public ForkJoinSumCalculator(long[] numbers) {
        this(numbers, 0, numbers.length);
    }

    private ForkJoinSumCalculator(long[] numbers, int start, int end) {
        this.numbers = numbers;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // 該任務負責求和的部分的大小
        int length = end - start;
        // 如果大小小於或等於閾值,順序計算結果
        if (length <= THRESHOLD) {
            return computeSequentially();
        }
        // 建立一個子任務來為陣列的前一半求和
        ForkJoinSumCalculator leftTask =
                new ForkJoinSumCalculator(numbers, start, start + length / 2);
        leftTask.fork();
        // 利用另一個ForkJoinPool執行緒非同步執行新建立的子任務
        ForkJoinSumCalculator rightTask =
                new ForkJoinSumCalculator(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;
    }
}
複製程式碼

現在編寫一個方法來並行對前n個自然數求和就很簡單了。你只需把想要的數字陣列傳給ForkJoinSumCalculator 的建構函式:

public static long forkJoinSum(long n) {
    long[] numbers = LongStream.rangeClosed(1, n).toArray();
    ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
    return new ForkJoinPool().invoke(task);
}
複製程式碼

這裡用了一個 LongStream 來生成包含前n個自然數的陣列,然後建立一個 ForkJoinTask( RecursiveTask 的父類),並把陣列傳遞 ForkJoinSumCalculator 的公共建構函式。最後,你建立了一個新的 ForkJoinPool ,並把任務傳給它的呼叫方法 。在ForkJoinPool 中執行時,最後一個方法返回的值就是 ForkJoinSumCalculator 類定義的任務結果。

請注意在實際應用時,使用多個 ForkJoinPool 是沒有什麼意義的。正是出於這個原因,一般來說把它例項化一次,然後把例項儲存在靜態欄位中,使之成為單例,這樣就可以在軟體中任何部分方便地重用了。這裡建立時用了其預設的無引數建構函式,這意味著想讓執行緒池使用JVM能夠使用的所有處理器。更確切地說,該建構函式將使用 Runtime.availableProcessors 的返回值來決定執行緒池使用的執行緒數。請注意 availableProcessors 方法雖然看起來是處理器,但它實際上返回的是可用核心的數量,包括超執行緒生成的虛擬核心。

執行 ForkJoinSumCalculator

當把 ForkJoinSumCalculator 任務傳給 ForkJoinPool 時,這個任務就由池中的一個執行緒執行,這個執行緒會呼叫任務的 compute 方法。該方法會檢查任務是否小到足以順序執行,如果不夠小則會把要求和的陣列分成兩半,分給兩個新的 ForkJoinSumCalculator ,而它們也由ForkJoinPool 安排執行。因此,這一過程可以遞迴重複,把原任務分為更小的任務,直到滿足不方便或不可能再進一步拆分的條件(本例中是求和的專案數小於等於10 000)。這時會順序計算每個任務的結果,然後由分支過程建立的(隱含的)任務二叉樹遍歷回到它的根。接下來會合並每個子任務的部分結果,從而得到總任務的結果。

image

你可以再用一次本章開始時寫的測試框架,來看看顯式使用分支/合併框架的求和方法的效能:

System.out.println("ForkJoin sum done in: " + measurePerf(
                ForkJoinSumCalculator::forkJoinSum, 10_000_000) + " msecs");
複製程式碼

它生成以下輸出:

ForkJoin sum done in: 41 msecs
複製程式碼

這個效能看起來比用並行流的版本要差,但這只是因為必須先要把整個數字流都放進一個long[] ,之後才能在 ForkJoinSumCalculator 任務中使用它。

使用分支/合併框架的最佳做法

雖然分支/合併框架還算簡單易用,不幸的是它也很容易被誤用。以下是幾個有效使用它的最佳做法。

  • 對一個任務呼叫 join 方法會阻塞呼叫方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再呼叫它。否則,你得到的版本會比原始的順序演算法更慢更復雜,因為每個子任務都必須等待另一個子任務完成才能啟動。
  • 不應該在 RecursiveTask 內部使用 ForkJoinPool 的 invoke 方法。相反,你應該始終直接呼叫 compute 或 fork 方法,只有順序程式碼才應該用 invoke 來啟動平行計算。
  • 對子任務呼叫 fork 方法可以把它排進 ForkJoinPool 。同時對左邊和右邊的子任務呼叫它似乎很自然,但這樣做的效率要比直接對其中一個呼叫 compute 低。這樣做你可以為其中一個子任務重用同一執行緒,從而避免線上程池中多分配一個任務造成的開銷。
  • 除錯使用分支/合併框架的平行計算可能有點棘手。特別是你平常都在你喜歡的IDE裡面看棧跟蹤(stack trace)來找問題,但放在分支合併計算上就不行了,因為呼叫 compute的執行緒並不是概念上的呼叫方,後者是呼叫 fork 的那個。
  • 和並行流一樣,你不應理所當然地認為在多核處理器上使用分支/合併框架就比順序計算快。我們已經說過,一個任務可以分解成多個獨立的子任務,才能讓效能在並行化時有所提升。所有這些子任務的執行時間都應該比分出新任務所花的時間長;一個慣用方法是把輸入/輸出放在一個子任務裡,計算放在另一個裡,這樣計算就可以和輸入/輸出同時進行。此外,在比較同一演算法的順序和並行版本的效能時還有別的因素要考慮。就像任何其他Java程式碼一樣,分支/合併框架需要“預熱”或者說要執行幾遍才會被JIT編譯器優化。這就是為什麼在測量效能之前跑幾遍程式很重要,我們的測試框架就是這麼做的。同時還要知道,編譯器內建的優化可能會為順序版本帶來一些優勢(例如執行死碼分析——刪去從未被使用的計算)。

對於分支/合併拆分策略還有最後一點補充:你必須選擇一個標準,來決定任務是要進一步拆分還是已小到可以順序求值。

工作竊取

在 ForkJoinSumCalculator 的例子中,我們決定在要求和的陣列中最多包含10 000個專案時就不再建立子任務了。這個選擇是很隨意的,但大多數情況下也很難找到一個好的啟發式方法來確定它,只能試幾個不同的值來嘗試優化它。在我們的測試案例中,我們先用了一個有1000萬專案的陣列,意味著 ForkJoinSumCalculator 至少會分出1000個子任務來。這似乎有點浪費資源,因為我們用來執行它的機器上只有四個核心。在這個特定例子中可能確實是這樣,因為所有的任務都受CPU約束,預計所花的時間也差不多。

但分出大量的小任務一般來說都是一個好的選擇。這是因為,理想情況下,劃分並行任務時,應該讓每個任務都用完全相同的時間完成,讓所有的CPU核心都同樣繁忙。不幸的是,實際中,每個子任務所花的時間可能天差地別,要麼是因為劃分策略效率低,要麼是有不可預知的原因,比如磁碟訪問慢,或是需要和外部服務協調執行。

分支/合併框架工程用一種稱為工作竊取(work stealing)的技術來解決這個問題。在實際應用中,這意味著這些任務差不多被平均分配到 ForkJoinPool 中的所有執行緒上。每個執行緒都為分配給它的任務儲存一個雙向鏈式佇列,每完成一個任務,就會從佇列頭上取出下一個任務開始執行。基於前面所述的原因,某個執行緒可能早早完成了分配給它的所有任務,也就是它的佇列已經空了,而其他的執行緒還很忙。這時,這個執行緒並沒有閒下來,而是隨機選了一個別的執行緒,從佇列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的佇列都清空。這就是為什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作執行緒之間平衡負載。

一般來說,這種工作竊取演算法用於在池中的工作執行緒之間重新分配和平衡任務。當工作執行緒佇列中有一個任務被分成兩個子任務時,一個子任務就被閒置的工作執行緒“偷走”了。如前所述,這個過程可以不斷遞迴,直到規定子任務應順序執行的條件為真。

image

現在你應該清楚流如何使用分支/合併框架來並行處理它的專案了,不過還有一點沒有講。本節中我們分析了一個例子,你明確地指定了將數字陣列拆分成多個任務的邏輯。但是,使用本章前面講的並行流時就用不著這麼做了,這就意味著,肯定有一種自動機制來為你拆分流。這種新的自動機制稱為 Spliterator ,我們會在下一節中討論。

Spliterator

Spliterator 是Java 8中加入的另一個新介面;這個名字代表“可分迭代器”(splitableiterator)。和 Iterator 一樣, Spliterator 也用於遍歷資料來源中的元素,但它是為了並行執行而設計的。雖然在實踐中可能用不著自己開發 Spliterator ,但瞭解一下它的實現方式會讓你對並行流的工作原理有更深入的瞭解。Java 8已經為集合框架中包含的所有資料結構提供了一個預設的 Spliterator 實現。集合實現了 Spliterator 介面,介面提供了一個 spliterator 方法。這個介面定義了若干方法,如下面的程式碼清單所示。

public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
}
複製程式碼

與往常一樣, T 是 Spliterator 遍歷的元素的型別。 tryAdvance 方法的行為類似於普通的Iterator ,因為它會按順序一個一個使用 Spliterator 中的元素,並且如果還有其他元素要遍歷就返回 true 。但 trySplit 是專為 Spliterator 介面設計的,因為它可以把一些元素劃出去分給第二個 Spliterator (由該方法返回),讓它們兩個並行處理。 Spliterator 還可通過estimateSize 方法估計還剩下多少元素要遍歷,因為即使不那麼確切,能快速算出來是一個值也有助於讓拆分均勻一點。

重要的是,要了解這個拆分過程在內部是如何執行的,以便在需要時能夠掌控它。因此,我們會在下一節中詳細地分析它。

拆分過程

將 Stream 拆分成多個部分的演算法是一個遞迴過程。第一步是對第一個Spliterator 呼叫 trySplit ,生成第二個 Spliterator 。第二步對這兩個 Spliterator 呼叫trysplit ,這樣總共就有了四個 Spliterator 。這個框架不斷對 Spliterator 呼叫 trySplit直到它返回 null ,表明它處理的資料結構不能再分割,如第三步所示。最後,這個遞迴拆分過程到第四步就終止了,這時所有的 Spliterator 在呼叫 trySplit 時都返回了 null 。

image

這個拆分過程也受 Spliterator 本身的特性影響,而特性是通過 characteristics 方法宣告的。

實現你自己的 Spliterator

讓我們來看一個可能需要你自己實現 Spliterator 的實際例子。我們要開發一個簡單的方法來數數一個 String 中的單詞數。這個方法的一個迭代版本可以寫成下面的樣子。

public static int countWordsIteratively(String s) {
    int counter = 0;
    boolean lastSpace = true;
    for (char c : s.toCharArray()) {
        if (Character.isWhitespace(c)) {
            lastSpace = true;
        } else {
            if (lastSpace) {
                counter++;
            }
            lastSpace = Character.isWhitespace(c);
        }
    }
    return counter;
}
複製程式碼

讓我們把這個方法用在但丁的《神曲》的《地獄篇》的第一句話上:

public static final String SENTENCE =
            " Nel   mezzo del cammin  di nostra  vita " +
                    "mi  ritrovai in una  selva oscura" +
                    " che la  dritta via era   smarrita ";

System.out.println("Found " + countWordsIteratively(SENTENCE) + " words");
複製程式碼

請注意,我們在句子裡新增了一些額外的隨機空格,以演示這個迭代實現即使在兩個詞之間存在多個空格時也能正常工作。正如我們所料,這段程式碼將列印以下內容:

Found 19 words
複製程式碼

理想情況下,你會想要用更為函式式的風格來實現它,因為就像我們前面說過的,這樣你就可以用並行 Stream 來並行化這個過程,而無需顯式地處理執行緒和同步問題。

  1. 以函式式風格重寫單詞計數器

首先你需要把 String 轉換成一個流。不幸的是,原始型別的流僅限於 int 、 long 和 double , 所以你只能用 Stream :

Stream<Character> stream = IntStream.range(0, SENTENCE.length())
                                        .mapToObj(SENTENCE::charAt);
複製程式碼

你可以對這個流做歸約來計算字數。在歸約流時,你得保留由兩個變數組成的狀態:一個 int用來計算到目前為止數過的字數,還有一個 boolean 用來記得上一個遇到的 Character 是不是空格。因為Java沒有元組(tuple,用來表示由異類元素組成的有序列表的結構,不需要包裝物件),所以你必須建立一個新類 WordCounter 來把這個狀態封裝起來,如下所示。

private static class WordCounter {
    private final int counter;
    private final boolean lastSpace;

    public WordCounter(int counter, boolean lastSpace) {
        this.counter = counter;
        this.lastSpace = lastSpace;
    }

    public WordCounter accumulate(Character c) {
        if (Character.isWhitespace(c)) {
            return lastSpace ?
                    this :
                    new WordCounter(counter, true);
        } else {
            return lastSpace ?
                    new WordCounter(counter + 1, false) :
                    this;
        }
    }

    public WordCounter combine(WordCounter wordCounter) {
        return new WordCounter(counter + wordCounter.counter,
                wordCounter.lastSpace);
    }

    public int getCounter() {
        return counter;
    }
}
複製程式碼

在這個列表中, accumulate 方法定義瞭如何更改 WordCounter 的狀態,或更確切地說是用哪個狀態來建立新的 WordCounter ,因為這個類是不可變的。每次遍歷到 Stream 中的一個新的Character 時,就會呼叫 accumulate 方法。具體來說,就像 countWordsIteratively 方法一樣,當上一個字元是空格,新字元不是空格時,計數器就加一。

呼叫第二個方法 combine 時,會對作用於 Character 流的兩個不同子部分的兩個WordCounter 的部分結果進行彙總,也就是把兩個 WordCounter 內部的計數器加起來。

private static int countWords(Stream<Character> stream) {
    WordCounter wordCounter = stream.reduce(new WordCounter(0, true),
            WordCounter::accumulate,
            WordCounter::combine);
    return wordCounter.getCounter();
}
複製程式碼

現在你就可以試一試這個方法,給它由包含但丁的《神曲》中《地獄篇》第一句的 String建立的流:

Stream<Character> stream = IntStream.range(0, SENTENCE.length())
                .mapToObj(SENTENCE::charAt);
System.out.println("Found " + countWords(stream) + " words");
複製程式碼

你可以和迭代版本比較一下輸出:

Found 19 words
複製程式碼

到現在為止都很好,但我們以函式式實現 WordCounter 的主要原因之一就是能輕鬆地並行處理,讓我們來看看具體是如何實現的。

  1. 讓 WordCounter 並行工作

你可以嘗試用並行流來加快字數統計,如下所示:

System.out.println("Found " + countWords(stream.parallel()) + " words");
複製程式碼

不幸的是,這次的輸出是:

Found 25 words
複製程式碼

顯然有什麼不對,可到底是哪裡不對呢?問題的根源並不難找。因為原始的 String 在任意位置拆分,所以有時一個詞會被分為兩個詞,然後數了兩次。這就說明,拆分流會影響結果,而把順序流換成並行流就可能使結果出錯。

如何解決這個問題呢?解決方案就是要確保 String 不是在隨機位置拆開的,而只能在詞尾拆開。要做到這一點,你必須為 Character 實現一個 Spliterator ,它只能在兩個詞之間拆開String (如下所示),然後由此建立並行流。

private static class WordCounterSpliterator implements Spliterator<Character> {
    private final String string;
    private int currentChar = 0;

    public WordCounterSpliterator(String string) {
        this.string = string;
    }

    @Override
    public boolean tryAdvance(Consumer<? super Character> action) {
        action.accept(string.charAt(currentChar++));
        return currentChar < string.length();
    }

    @Override
    public Spliterator<Character> trySplit() {
        int currentSize = string.length() - currentChar;
        if (currentSize < 10) {
            return null;
        }
        for (int splitPos = currentSize / 2 + currentChar;
                splitPos < string.length(); splitPos++) {
            if (Character.isWhitespace(string.charAt(splitPos))) {
                Spliterator<Character> spliterator =
                        new WordCounterSpliterator(string.substring(currentChar,
                                splitPos));
                currentChar = splitPos;
                return spliterator;
            }
        }
        return null;
    }

    @Override
    public long estimateSize() {
        return string.length() - currentChar;
    }

    @Override
    public int characteristics() {
        return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;
    }
}
複製程式碼

這個 Spliterator 由要解析的 String 建立,並遍歷了其中的 Character ,同時儲存了當前正在遍歷的字元位置。讓我們快速回顧一下實現了Spliterator介面的WordCounterSpliterator 中的各個函式。

  • tryAdvance 方法把 String 中當前位置的 Character 傳給了 Consumer ,並讓位置加一。作為引數傳遞的 Consumer 是一個Java內部類,在遍歷流時將要處理的 Character 傳給了一系列要對其執行的函式。這裡只有一個歸約函式,即 WordCounter 類的 accumulate方法。如果新的指標位置小於 String 的總長,且還有要遍歷的 Character ,則tryAdvance 返回 true 。
  • trySplit 方法是 Spliterator 中最重要的一個方法,因為它定義了拆分要遍歷的資料結構的邏輯。就像 RecursiveTask 的 compute 方法一樣(分支/合併框架的使用方式),首先要設定不再進一步拆分的下限。這裡用了一個非常低的下限——10個 Character ,僅僅是為了保證程式會對那個比較短的 String 做幾次拆分。在實際應用中,就像分支/合併的例子那樣,你肯定要用更高的下限來避免生成太多的任務。如果剩餘的 Character 數量低於下限,你就返回 null 表示無需進一步拆分。相反,如果你需要執行拆分,就把試探的拆分位置設在要解析的 String 塊的中間。但我們沒有直接使用這個拆分位置,因為要避免把詞在中間斷開,於是就往前找,直到找到一個空格。一旦找到了適當的拆分位置,就可以建立一個新的 Spliterator 來遍歷從當前位置到拆分位置的子串;把當前位置 this 設為拆分位置,因為之前的部分將由新Spliterator 來處理,最後返回。
  • 還需要遍歷的元素的 estimatedSize 就是這個 Spliterator 解析的 String 的總長度和當前遍歷的位置的差。
  • 最後, characteristic 方法告訴框架這個 Spliterator 是 ORDERED (順序就是 String中各個 Character 的次序)、 SIZED ( estimatedSize 方法的返回值是精確的)、SUBSIZED ( trySplit 方法建立的其他 Spliterator 也有確切大小)、 NONNULL ( String中 不 能 有 為 null 的 Character ) 和 IMMUTABLE ( 在 解 析 String 時 不 能 再 添 加Character ,因為 String 本身是一個不可變類)的。
  1. 運用 WordCounterSpliterator

現在就可以用這個新的 WordCounterSpliterator 來處理並行流了,如下所示:

Spliterator<Character> spliterator = new WordCounterSpliterator(SENTENCE);
Stream<Character> stream = StreamSupport.stream(spliterator, true);
複製程式碼

傳給 StreamSupport.stream 工廠方法的第二個布林引數意味著你想建立一個並行流。把這個並行流傳給 countWords 方法:

System.out.println("Found " + countWords(stream.parallel()) + " words");
複製程式碼

可以得到意料之中的正確輸出:

Found 19 words
複製程式碼

你已經看到了 Spliterator 如何讓你控制拆分資料結構的策略。 Spliterator 還有最後一個值得注意的功能,就是可以在第一次遍歷、第一次拆分或第一次查詢估計大小時繫結元素的資料來源,而不是在建立時就繫結。這種情況下,它稱為延遲繫結(late-binding)的 Spliterator 。

總結

  • 內部迭代讓你可以並行處理一個流,而無需在程式碼中顯式使用和協調不同的執行緒。
  • 雖然並行處理一個流很容易,卻不能保證程式在所有情況下都執行得更快。並行軟體的行為和效能有時是違反直覺的,因此一定要測量,確保你並沒有把程式拖得更慢。
  • 像並行流那樣對一個資料集並行執行操作可以提升效能,特別是要處理的元素數量龐大,或處理單個元素特別耗時的時候。
  • 從效能角度來看,使用正確的資料結構,如儘可能利用原始流而不是一般化的流,幾乎總是比嘗試並行化某些操作更為重要。
  • 分支/合併框架讓你得以用遞迴方式將可以並行的任務拆分成更小的任務,在不同的執行緒上執行,然後將各個子任務的結果合併起來生成整體結果。
  • Spliterator 定義了並行流如何拆分它要遍歷的資料。

程式碼

Github:chap7 Gitee:chap7

相關文章