Effective Java 第三版——48. 謹慎使用流並行

Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必不少人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到如今已經將近8年的時間,但隨着Java 6,7,8,甚至9的發佈,Java語言發生了深入的變化。
在這裏第一時間翻譯成中文版。供你們學習分享之用。
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼裏方法是基於Java 9 API中的,因此JDK 最好下載 JDK 9以上的版本。可是Java 9 只是一個過渡版本,因此建議安裝JDK 10。java

Effective Java, Third Edition

48.謹慎使用流並行

在主流語言中,Java一直處於提供簡化併發編程任務的工具的最前沿。 當Java於1996年發佈時,它內置了對線程的支持,包括同步和wait / notify機制。 Java 5引入了java.util.concurrent類庫,帶有併發集合和執行器框架。 Java 7引入了fork-join包,這是一個用於並行分解的高性能框架。 Java 8引入了流,能夠經過對parallel方法的單個調用來並行化。 用Java編寫併發程序變得愈來愈容易,但編寫正確快速的併發程序還像之前同樣困難。 安全和活躍度違規(liveness violation)是併發編程中的事實,並行流管道也不例外。git

考慮條目 45中的程序:程序員

// Stream-based program to generate the first 20 Mersenne primes

public static void main(String[] args) {

    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))

        .filter(mersenne -> mersenne.isProbablePrime(50))

        .limit(20)

        .forEach(System.out::println);

}

static Stream<BigInteger> primes() {

    return Stream.iterate(TWO, BigInteger::nextProbablePrime);

}

在個人機器上,這個程序當即開始打印素數,運行到完成須要12.5秒。假設我天真地嘗試經過向流管道中添加一個到parallel()的調用來加快速度。你認爲它的表現會怎樣?它會快幾個百分點嗎?慢幾個百分點?遺憾的是,它不會打印任何東西,可是CPU使用率會飆升到90%,而且會無限期地停留在那裏(liveness failure:活性失敗)。這個程序可能最終會終止,但我不肯意去等待;半小時後我強行阻止了它。github

這裏發生了什麼?簡而言之,流類庫不知道如何並行化此管道而且啓發式失敗(heuristics fail.)。即便在最好的狀況下,若是源來自Stream.iterate方法,或者使用中間操做limit方法,並行化管道也不太可能提升其性能。這個管道必須應對這兩個問題。更糟糕的是,默認的並行策略處理不可預測性的limit方法,假設在處理一些額外的元素和丟棄任何沒必要要的結果時沒有害處。在這種狀況下,找到每一個梅森素數的時間大約是找到上一個素數的兩倍。所以,計算單個額外元素的成本大體等於計算全部先前元素組合的成本,而且這種無害的管道使自動並行化算法癱瘓。這個故事的寓意很簡單:不要無差異地並行化流管道(stream pipelines)。性能後果多是災難性的。算法

一般,並行性帶來的性能收益在ArrayList、HashMap、HashSet和ConcurrentHashMap實例、數組、int類型範圍和long類型的範圍的流上最好。這些數據結構的共同之處在於,它們均可以精確而廉價地分割成任意大小的子程序,這使得在並行線程之間劃分工做變得很容易。用於執行此任務的流淚庫使用的抽象是spliterator,它由spliterator方法在Stream和Iterable上返回。編程

全部這些數據結構的共同點的另外一個重要因素是它們在順序處理時提供了從良好到極好的引用位置( locality of reference):順序元素引用在存儲器中存儲在一塊。 這些引用所引用的對象在存儲器中可能彼此不接近,這下降了引用局部性。 對於並行化批量操做而言,引用位置很是重要:沒有它,線程大部分時間都處於空閒狀態,等待數據從內存傳輸處處理器的緩存中。 具備最佳引用位置的數據結構是基本類型的數組,由於數據自己連續存儲在存儲器中。數組

流管道終端操做的性質也會影響並行執行的有效性。 若是與管道的總體工做相比,在終端操做中完成了大量的工做,而且這種操做本質上是連續的,那麼並行化管道的有效性將是有限的。 並行性的最佳終操做是縮減(reductions),即便用流的reduce方法組合管道中出現的全部元素,或者預先打包的reduce(如min、max、count和sum)。短路操做anyMatchallMatchnoneMatch也能夠支持並行性。由Stream的collect方法執行的操做,稱爲可變縮減(mutable reductions),不適合並行性,由於組合集合的開銷很是大。緩存

若是編寫本身的Stream,Iterable或Collection實現,而且但願得到良好的並行性能,則必須重寫spliterator方法並普遍測試生成的流的並行性能。 編寫高質量的spliterator很困難,超出了本書的範圍。安全

並行化一個流不只會致使糟糕的性能,包括活性失敗(liveness failures);它會致使不正確的結果和不可預知的行爲(安全故障)。使用映射器(mappers),過濾器(filters)和其餘程序員提供的不符合其規範的功能對象的管道並行化可能會致使安全故障。 Stream規範對這些功能對象提出了嚴格的要求。 例如,傳遞給Stream的reduce方法操做的累加器(accumulator)和組合器(combiner)函數必須是關聯的,非干擾的和無狀態的。 若是違反了這些要求(其中一些在第46項中討論過),但按順序運行你的管道,則可能會產生正確的結果; 若是將它並行化,它可能會失敗,也許是災難性的。性能優化

沿着這些思路,值得注意的是,即便並行的梅森素數程序已經運行完成,它也不會以正確的(升序的)順序打印素數。爲了保持順序版本顯示的順序,必須將forEach終端操做替換爲forEachOrdered操做,它保證以遇出現順序(encounter order)遍歷並行流。

即便假設正在使用一個高效的可拆分的源流、一個可並行化的或廉價的終端操做以及非干擾的函數對象,也沒法從並行化中得到良好的加速效果,除非管道作了足夠的實際工做來抵消與並行性相關的成本。做爲一個很是粗略的估計,流中的元素數量乘以每一個元素執行的代碼行數應該至少是100,000 [Lea14]。

重要的是要記住並行化流是嚴格的性能優化。 與任何優化同樣,必須在更改以前和以後測試性能,以確保它值得作(第67項)。 理想狀況下,應該在實際的系統設置中執行測試。 一般,程序中的全部並行流管道都在公共fork-join池中運行。 單個行爲不當的管道可能會損害系統中不相關部分的其餘行爲。

若是在並行化流管道時,這種可能性對你不利,那是由於它們確實存在。一個認識的人,他維護一個數百萬行代碼庫,大量使用流,他發現只有少數幾個地方並行流是有效的。這並不意味着應該避免並行化流。在適當的狀況下,只需向流管道添加一個parallel方法調用,就能夠實現處理器內核數量的近似線性加速。某些領域,如機器學習和數據處理,特別適合這些加速。

做爲並行性有效的流管道的簡單示例,請考慮此函數來計算π(n),素數小於或等於n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

在個人機器上,使用此功能計算π(108)須要31秒。 只需添加parallel()方法調用便可將時間縮短爲9.2秒:

// Prime-counting stream pipeline - parallel version
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .parallel()
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

換句話說,在個人四核計算機上,並行計算速度提升了3.7倍。值得注意的是,這不是你在實踐中如何計算π(n)爲n的值。還有更有效的算法,特別是Lehmer’s formula。

若是要並行化隨機數流,請從SplittableRandom實例開始,而不是ThreadLocalRandom(或基本上過期的Random)。 SplittableRandom專爲此用途而設計,具備線性加速的潛力。ThreadLocalRandom設計用於單個線程,並將自身適應做爲並行流源,但不會像SplittableRandom同樣快。Random實例在每一個操做上進行同步,所以會致使過分的並行殺死爭用( parallelism-killing contention)。

總之,甚至不要嘗試並行化流管道,除非你有充分的理由相信它將保持計算的正確性並提升其速度。不恰當地並行化流的代價多是程序失敗或性能災難。若是您認爲並行性是合理的,那麼請確保您的代碼在並行運行時保持正確,並在實際狀況下進行仔細的性能度量。若是您的代碼是正確的,而且這些實驗證明了您對性能提升的懷疑,那麼而且只有這樣才能在生產代碼中並行化流。

相關文章
相關標籤/搜索