在前面的章節中,我們簡要地提到了 Stream 接口可以讓你非常方便地處理它的元素:可以通過對收集源調用 parallelStream 方法來把集合轉換爲並行流。並行流就是一個把內容分成多個數據塊,並用不同的線程分別處理每個數據塊的流。這樣一來,你就可以自動把給定操作的工作負荷分配給多核處理器的所有內核。
在現實中,對順序流調用 parallel 方法並不意味着流本身有任何實際的變化。它在內部實際上就是設了一個 boolean 標誌,表示你想讓調用 parallel 之後進行的所有操作都並行執行。類似地,你只需要對並行流調用 sequential 方法就可以把它變成順序流。
一、性能測試
在瞭解其具體內容之前,我們先來做一個測試。我們寫一個對指定數值的自然數流進行求和操作,針對一個求和操作,執行10次,取出使用時間最短的操作。分別做傳統for循環方式,使用Stream方式,以及並行Stream方式,代碼如下所示:
import java.util.function.Function;
import java.util.stream.Stream;
/**
* @description: 測試並行的性能
* @author:weirx
* @date:2021/10/22 15:20
* @version:3.0
*/
public class TestStreamParallel {
/**
* description: 傳入一個函數和一個數值,此方法會對傳入的方法執行10次,取出最短執行時間
*
* @param adder
* @param n
* @return: long
* @author: weirx
* @time: 2021/10/22 15:29
*/
public static long measureSumPerf(Function<Long, Long> adder, long n) {
long fastest = Long.MAX_VALUE;
for (int i = 0; i < 10; i++) {
long start = System.nanoTime();
adder.apply(n);
long duration = (System.nanoTime() - start) / 1_000_000;
if (duration < fastest) {
fastest = duration;
}
}
return fastest;
}
/**
* description: 對輸入數值求和
*
* @param aLong
* @return: java.lang.Long
* @author: weirx
* @time: 2021/10/25 10:02
*/
private static Long testFor(Long aLong) {
// jdk1.7求和
long result = 0;
for (long i = 1L; i <= aLong; i++) {
result += i;
}
return result;
}
/**
* description: 對輸入數值求和
*
* @param aLong
* @return: java.lang.Long
* @author: weirx
* @time: 2021/10/25 10:02
*/
private static Long testStreamParallel(Long aLong) {
// jdk1.8求和 - 並行
return Stream.iterate(0L, i -> i + 1).limit(aLong).parallel().reduce(0L, Long::sum);
}
/**
* description: 對輸入數值求和
*
* @param aLong
* @return: java.lang.Long
* @author: weirx
* @time: 2021/10/25 10:02
*/
private static Long testStream(Long aLong) {
// jdk1.8求和 - 非並行
return Stream.iterate(0L, i -> i + 1).limit(aLong).reduce(0L, Long::sum);
}
}
傳統for循環結果如下:
public static void main(String[] args) {
System.out.println("最短耗時:" + measureSumPerf(TestStreamParallel::testFor, 10000000) + "ms");
}
------------------------------------------
最短耗時:3ms
Stream結果如下:
public static void main(String[] args) {
System.out.println("最短耗時:" + measureSumPerf(TestStreamParallel::testStream, 10000000) + "ms");
}
------------------------------------------
最短耗時:106ms
StreamParallel結果如下:
public static void main(String[] args) {
System.out.println("最短耗時:" + measureSumPerf(TestStreamParallel::testStreamParallel, 10000000) + "ms");
}
------------------------------------------
最短耗時:131ms
結果分析:
1)用傳統 for 循環的迭代版本執行起來應該會快很多,因爲它更爲底層,更重要的是不需要對原始類型做任何裝箱或拆箱操作。
2)使用Stream的方式要比傳統for慢不少。
3)使用並行Stream的方式,反而效率是最低的。
那麼產生上述問題的原因是什麼?
1) iterate 生成的是裝箱的對象,必須拆箱成數字才能求和;
2)很難把 iterate 分成多個獨立塊來並行執行,因爲每次應用這個函數都要依賴前一次應用的結果,如下圖所示:
整張數字列表在歸納的過程中並沒有準備好,因而無法有效的把流劃分爲小塊進行並行處理。當把流標記位並行的時候,其實是增加了開銷,要把每次的求和操作分配到不同的線程上處理。
綜上所述:iterate是一個不易並行化的操作。甚至會使整個流操作的效率下降。
如何合理的高效的解決上述問題呢?
我們可以使用LongStream.rangeClosed這個操作,相比於iterate有兩點優點:
1)直接生產原始數據類型,沒有拆箱和裝箱的開銷。
2)生成數據範圍,容易拆分成小塊,便於並行。
下面直接看結果:
/**
* description: 使用LongStream.rangeClosed求和
* @param aLong
* @return: java.lang.Long
* @author: weirx
* @time: 2021/10/25 10:53
*/
private static Long testRangeClosed(Long aLong) {
return LongStream.rangeClosed(0, aLong).reduce(0L, Long::sum);
}
public static void main(String[] args) {
System.out.println("最短耗時:" + measureSumPerf(TestStreamParallel::testRangeClosed, 10000000) + "ms");
}
---------------------------
最短耗時:4ms
如果對其使用並行方式呢?結果如下:
/**
* description: 使用LongStream.rangeClosed求和
* @param aLong
* @return: java.lang.Long
* @author: weirx
* @time: 2021/10/25 10:53
*/
private static Long testRangeClosedParallel(Long aLong) {
return LongStream.rangeClosed(0, aLong).parallel().reduce(0L, Long::sum);
}
public static void main(String[] args) {
System.out.println("最短耗時:" + measureSumPerf(TestStreamParallel::testRangeClosedParallel, 10000000) + "ms");
}
--------------------------------------
最短耗時:1ms
二、正確並高效的使用並行流
首先看下下面的一個錯誤用法,有如下的求和代碼:
import java.util.stream.LongStream;
/**
* @description: 併發情況下的流
* @author:weirx
* @date:2021/10/25 11:02
* @version:3.0
*/
public class ConcurrentForStreamParallel {
/**
* description: 調用Accumulator的add方法求和
* @param n
* @return: long
* @author: weirx
* @time: 2021/10/25 11:03
*/
public static long sideEffectSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).forEach(accumulator::add);
return accumulator.total;
}
public static class Accumulator {
public long total = 0;
public void add(long value) {
total += value;
}
}
public static void main(String[] args) {
for (int i = 0; i< 10 ;i++){
System.out.println(sideEffectSum(10000000));
}
}
-----------------------------------
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
50000005000000
如果對這個代碼使用並行操作,會有如下結果:
public static long sideEffectSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
return accumulator.total;
}
------------------------------------
11448626323551
10712400489958
4825469864081
5760309604570
8135917300720
13477296726050
10068084182814
7911075623394
11626955826086
8579850729746
如上所示,直接出現了線程間的資源競爭問題,暫不說效率問題,連數據的正確性都無法保證了。
下面總結一些關於使用並行流的一些建議:
1)測試,最直觀的方式,如果無法確定使用並行流能帶來性能的提升,那麼就如本文一樣,找到最合適的方式。
2)留意裝箱。自動裝箱和自動拆箱會使得性能大大的降低。應當使用 IntStream 、LongStream 、 DoubleStream 等原始流來避免這些操作。
3) 有些操作本身在並行流上的性能就比順序流差。特別是 limit 和 findFirst 等依賴於元素順序的操作,它們在並行流上執行的代價非常大。例如, findAny 會比 findFirst 性能好,因爲它不一定要按順序來執行。如果你需要流中的n個元素而不是專門要前n個的話,對無序並行流調用limit 可能會比單個有序流更高效。
4)考慮流水線的計算成本。假設處理元素個數N,Q是一個元素通過流水線的成本,則N*Q就是整個流水線的計算成本。Q的值越高,則使用並行流的效率可能越高。
5)較小數據量使用並行流並不是一個好的選擇。並行化本身也是有一定的開銷的。
6)考慮流背後的數據結構是否易於拆分。ArrayList比LinkedList的拆分效率高的多。前者不需要遍歷,後者需要遍歷。
7)流本省的操作導致最終整個流水線的不確定性。比如filter操作過濾大量的元素,導致流的大小未知。
8) 還要考慮終端操作中合併步驟的代價是大是小。(例如 Collector 中的 combiner 方法)。如果這一步代價很大,那麼組合每個子流產生的部分結果所付出的代價就可能會超出通過並行流得到的性能提升。
下面給出幾種數據源的並行效率:
源 | 可分解性 |
---|---|
ArrayList | 極佳 |
LinkedList | 差 |
IntStream.range | 極佳 |
Stream.iterate | 差 |
HashSet | 好 |
TreeSet | 好 |