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。
48.謹慎使用流並行
在主流語言中,Java一直處於提供簡化並發編程任務的工具的最前沿。 當Java於1996年發布時,它內置了對線程的支持,包括同步和wait / notify機制。 Java 5引入了java.util.concurrent
類庫,帶有並發集合和執行器框架。 Java 7引入了fork-join包,這是一個用於並行分解的高性能框架。 Java 8引入了流,可以通過對parallel
方法的單個調用來並行化。 用Java編寫並發程序變得越來越容易,但編寫正確快速的並發程序還像以前一樣困難。 安全和活躍度違規(liveness violation)是並發編程中的事實,並行流管道也不例外。
考慮條目 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:活性失敗)。這個程序可能最終會終止,但我不願意去等待;半小時后我強行阻止了它。
這里發生了什么?簡而言之,流類庫不知道如何並行化此管道並且啟發式失敗(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)。短路操作anyMatch
、allMatch
和noneMatch
也可以支持並行性。由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)。
總之,甚至不要嘗試並行化流管道,除非你有充分的理由相信它將保持計算的正確性並提高其速度。不恰當地並行化流的代價可能是程序失敗或性能災難。如果您認為並行性是合理的,那么請確保您的代碼在並行運行時保持正確,並在實際情況下進行仔細的性能度量。如果您的代碼是正確的,並且這些實驗證實了您對性能提高的懷疑,那么並且只有這樣才能在生產代碼中並行化流。