本文翻譯自 winterbe.com/posts/2014/…
作者: @Winterbe
歡迎關注個人微信公眾號: 小哈學Java,即可免費無套路領取10G面試學習資料哦,文末資料截圖。
Stream
流可以說是 Java8 新特性中用起來最爽的一個功能了,有了它,從此操作集合告別繁瑣的 for
迴圈。但是還有很多小夥伴對 Stream 流不是很瞭解。今天就通過這篇 @Winterbe 的譯文,一起深入瞭解下如何使用它吧。
目錄
一、Stream 流是如何工作的?
二、不同型別的 Stream 流
三、Stream 流的處理順序
四、中間操作順序這麼重要?
五、資料流複用問題
六、高階操作
- 6.1 Collect
- 6.2 FlatMap
- 6.3 Reduce
七、並行流
八、結語
當我第一次閱讀 Java8 中的 Stream API 時,說實話,我非常困惑,因為它的名字聽起來與 Java I0 框架中的 InputStream
和 OutputStream
非常類似。但是實際上,它們完全是不同的東西。
Java8 Stream 使用的是函數語言程式設計模式,如同它的名字一樣,它可以被用來對集合進行鏈狀流式的操作。
本文就將帶著你如何使用 Java 8 不同型別的 Stream 操作。同時您還將瞭解流的處理順序,以及不同順序的流操作是如何影響執行時效能的。
我們還將學習終端操作 API reduce
,collect
以及flatMap
的詳細介紹,最後我們再來深入的探討一下 Java8 並行流。
注意:如果您還不熟悉 Java 8 lambda 表示式,函式式介面以及方法引用,您可以先閱讀一下小哈的另一篇譯文 《Java8 新特性教程》
接下來,就讓我們進入正題吧!
一、Stream 流是如何工作的?
流表示包含著一系列元素的集合,我們可以對其做不同型別的操作,用來對這些元素執行計算。聽上去可能有點拗口,讓我們用程式碼說話:
List<String> myList =
Arrays.asList("a1", "a2", "b1", "c2", "c1");
myList
.stream() // 建立流
.filter(s -> s.startsWith("c")) // 執行過濾,過濾出以 c 為字首的字串
.map(String::toUpperCase) // 轉換成大寫
.sorted() // 排序
.forEach(System.out::println); // for 迴圈列印
// C1
// C2
複製程式碼
我們可以對流進行中間操作或者終端操作。小夥伴們可能會疑問?什麼是中間操作?什麼又是終端操作?
- ①:中間操作會再次返回一個流,所以,我們可以連結多箇中間操作,注意這裡是不用加分號的。上圖中的
filter
過濾,map
物件轉換,sorted
排序,就屬於中間操作。 - ②:終端操作是對流操作的一個結束動作,一般返回
void
或者一個非流的結果。上圖中的forEach
迴圈 就是一個終止操作。
看完上面的操作,感覺是不是很像一個流水線式操作呢。
實際上,大部分流操作都支援 lambda 表示式作為引數,正確理解,應該說是接受一個函式式介面的實現作為引數。
二、不同型別的 Stream 流
我們可以從各種資料來源中建立 Stream 流,其中以 Collection 集合最為常見。如 List
和 Set
均支援 stream()
方法來建立順序流或者是並行流。
並行流是通過多執行緒的方式來執行的,它能夠充分發揮多核 CPU 的優勢來提升效能。本文在最後再來介紹並行流,我們先討論順序流:
Arrays.asList("a1", "a2", "a3")
.stream() // 建立流
.findFirst() // 找到第一個元素
.ifPresent(System.out::println); // 如果存在,即輸出
// a1
複製程式碼
在集合上呼叫stream()
方法會返回一個普通的 Stream 流。但是, 您大可不必刻意地建立一個集合,再通過集合來獲取 Stream 流,您還可以通過如下這種方式:
Stream.of("a1", "a2", "a3")
.findFirst()
.ifPresent(System.out::println); // a1
複製程式碼
例如上面這樣,我們可以通過 Stream.of()
從一堆物件中建立 Stream 流。
除了常規物件流之外,Java 8還附帶了一些特殊型別的流,用於處理原始資料型別int
,long
以及double
。說道這裡,你可能已經猜到了它們就是IntStream
,LongStream
還有DoubleStream
。
其中,IntStreams.range()
方法還可以被用來取代常規的 for
迴圈, 如下所示:
IntStream.range(1, 4)
.forEach(System.out::println); // 相當於 for (int i = 1; i < 4; i++) {}
// 1
// 2
// 3
複製程式碼
上面這些原始型別流的工作方式與常規物件流基本是一樣的,但還是略微存在一些區別:
-
原始型別流使用其獨有的函式式介面,例如
IntFunction
代替Function
,IntPredicate
代替Predicate
。 -
原始型別流支援額外的終端聚合操作,
sum()
以及average()
,如下所示:
Arrays.stream(new int[] {1, 2, 3})
.map(n -> 2 * n + 1) // 對數值中的每個物件執行 2*n + 1 操作
.average() // 求平均值
.ifPresent(System.out::println); // 如果值不為空,則輸出
// 5.0
複製程式碼
但是,偶爾我們也有這種需求,需要將常規物件流轉換為原始型別流,這個時候,中間操作 mapToInt()
,mapToLong()
以及mapToDouble
就派上用場了:
Stream.of("a1", "a2", "a3")
.map(s -> s.substring(1)) // 對每個字串元素從下標1位置開始擷取
.mapToInt(Integer::parseInt) // 轉成 int 基礎型別型別流
.max() // 取最大值
.ifPresent(System.out::println); // 不為空則輸出
// 3
複製程式碼
如果說,您需要將原始型別流裝換成物件流,您可以使用 mapToObj()
來達到目的:
IntStream.range(1, 4)
.mapToObj(i -> "a" + i) // for 迴圈 1->4, 拼接字首 a
.forEach(System.out::println); // for 迴圈列印
// a1
// a2
// a3
複製程式碼
下面是一個組合示例,我們將雙精度流首先轉換成 int
型別流,然後再將其裝換成物件流:
Stream.of(1.0, 2.0, 3.0)
.mapToInt(Double::intValue) // double 型別轉 int
.mapToObj(i -> "a" + i) // 對值拼接字首 a
.forEach(System.out::println); // for 迴圈列印
// a1
// a2
// a3
複製程式碼
三、Stream 流的處理順序
上小節中,我們已經學會了如何建立不同型別的 Stream 流,接下來我們再深入瞭解下資料流的執行順序。
在討論處理順序之前,您需要明確一點,那就是中間操作的有個重要特性 —— 延遲性。觀察下面這個沒有終端操作的示例程式碼:
Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> {
System.out.println("filter: " + s);
return true;
});
複製程式碼
執行此程式碼段時,您可能會認為,將依次列印 "d2", "a2", "b1", "b3", "c" 元素。然而當你實際去執行的時候,它不會列印任何內容。
為什麼呢?
原因是:當且僅當存在終端操作時,中間操作操作才會被執行。
是不是不信?接下來,對上面的程式碼新增 forEach
終端操作:
Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> {
System.out.println("filter: " + s);
return true;
})
.forEach(s -> System.out.println("forEach: " + s));
複製程式碼
再次執行,我們會看到輸出如下:
filter: d2
forEach: d2
filter: a2
forEach: a2
filter: b1
forEach: b1
filter: b3
forEach: b3
filter: c
forEach: c
複製程式碼
輸出的順序可能會讓你很驚訝!你腦海裡肯定會想,應該是先將所有 filter
字首的字串列印出來,接著才會列印 forEach
字首的字串。
事實上,輸出的結果卻是隨著鏈條垂直移動的。比如說,當 Stream 開始處理 d2 元素時,它實際上會在執行完 filter 操作後,再執行 forEach 操作,接著才會處理第二個元素。
是不是很神奇?為什麼要設計成這樣呢?
原因是出於效能的考慮。這樣設計可以減少對每個元素的實際運算元,看完下面程式碼你就明白了:
Stream.of("d2", "a2", "b1", "b3", "c")
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase(); // 轉大寫
})
.anyMatch(s -> {
System.out.println("anyMatch: " + s);
return s.startsWith("A"); // 過濾出以 A 為字首的元素
});
// map: d2
// anyMatch: D2
// map: a2
// anyMatch: A2
複製程式碼
終端操作 anyMatch()
表示任何一個元素以 A 為字首,返回為 true
,就停止迴圈。所以它會從 d2
開始匹配,接著迴圈到 a2
的時候,返回為 true
,於是停止迴圈。
由於資料流的鏈式呼叫是垂直執行的,map
這裡只需要執行兩次。相對於水平執行來說,map
會執行儘可能少的次數,而不是把所有元素都 map
轉換一遍。
四、中間操作順序這麼重要?
下面的例子由兩個中間操作map
和filter
,以及一個終端操作forEach
組成。讓我們再來看看這些操作是如何執行的:
Stream.of("d2", "a2", "b1", "b3", "c")
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase(); // 轉大寫
})
.filter(s -> {
System.out.println("filter: " + s);
return s.startsWith("A"); // 過濾出以 A 為字首的元素
})
.forEach(s -> System.out.println("forEach: " + s)); // for 迴圈輸出
// map: d2
// filter: D2
// map: a2
// filter: A2
// forEach: A2
// map: b1
// filter: B1
// map: b3
// filter: B3
// map: c
// filter: C
複製程式碼
學習了上面一小節,您應該已經知道了,map
和filter
會對集合中的每個字串呼叫五次,而forEach
卻只會呼叫一次,因為只有 "a2" 滿足過濾條件。
如果我們改變中間操作的順序,將filter
移動到鏈頭的最開始,就可以大大減少實際的執行次數:
Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> {
System.out.println("filter: " + s)
return s.startsWith("a"); // 過濾出以 a 為字首的元素
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase(); // 轉大寫
})
.forEach(s -> System.out.println("forEach: " + s)); // for 迴圈輸出
// filter: d2
// filter: a2
// map: a2
// forEach: A2
// filter: b1
// filter: b3
// filter: c
複製程式碼
現在,map
僅僅只需呼叫一次,效能得到了提升,這種小技巧對於流中存在大量元素來說,是非常很有用的。
接下來,讓我們對上面的程式碼再新增一箇中間操作sorted
:
Stream.of("d2", "a2", "b1", "b3", "c")
.sorted((s1, s2) -> {
System.out.printf("sort: %s; %s\n", s1, s2);
return s1.compareTo(s2); // 排序
})
.filter(s -> {
System.out.println("filter: " + s);
return s.startsWith("a"); // 過濾出以 a 為字首的元素
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase(); // 轉大寫
})
.forEach(s -> System.out.println("forEach: " + s)); // for 迴圈輸出
複製程式碼
sorted
是一個有狀態的操作,因為它需要在處理的過程中,儲存狀態以對集合中的元素進行排序。
執行上面程式碼,輸出如下:
sort: a2; d2
sort: b1; a2
sort: b1; d2
sort: b1; a2
sort: b3; b1
sort: b3; d2
sort: c; b3
sort: c; d2
filter: a2
map: a2
forEach: A2
filter: b1
filter: b3
filter: c
filter: d2
複製程式碼
咦咦咦?這次怎麼又不是垂直執行了。你需要知道的是,sorted
是水平執行的。因此,在這種情況下,sorted
會對集合中的元素組合呼叫八次。這裡,我們也可以利用上面說道的優化技巧,將 filter 過濾中間操作移動到開頭部分:
Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> {
System.out.println("filter: " + s);
return s.startsWith("a");
})
.sorted((s1, s2) -> {
System.out.printf("sort: %s; %s\n", s1, s2);
return s1.compareTo(s2);
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
})
.forEach(s -> System.out.println("forEach: " + s));
// filter: d2
// filter: a2
// filter: b1
// filter: b3
// filter: c
// map: a2
// forEach: A2
複製程式碼
從上面的輸出中,我們看到了 sorted
從未被呼叫過,因為經過filter
過後的元素已經減少到只有一個,這種情況下,是不用執行排序操作的。因此效能被大大提高了。
五、資料流複用問題
Java8 Stream 流是不能被複用的,一旦你呼叫任何終端操作,流就會關閉:
Stream<String> stream =
Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> s.startsWith("a"));
stream.anyMatch(s -> true); // ok
stream.noneMatch(s -> true); // exception
複製程式碼
當我們對 stream 呼叫了 anyMatch
終端操作以後,流即關閉了,再呼叫 noneMatch
就會丟擲異常:
java.lang.IllegalStateException: stream has already been operated upon or closed
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
at java.util.stream.ReferencePipeline.noneMatch(ReferencePipeline.java:459)
at com.winterbe.java8.Streams5.test7(Streams5.java:38)
at com.winterbe.java8.Streams5.main(Streams5.java:28)
複製程式碼
為了克服這個限制,我們必須為我們想要執行的每個終端操作建立一個新的流鏈,例如,我們可以通過 Supplier
來包裝一下流,通過 get()
方法來構建一個新的 Stream
流,如下所示:
Supplier<Stream<String>> streamSupplier =
() -> Stream.of("d2", "a2", "b1", "b3", "c")
.filter(s -> s.startsWith("a"));
streamSupplier.get().anyMatch(s -> true); // ok
streamSupplier.get().noneMatch(s -> true); // ok
複製程式碼
通過構造一個新的流,來避開流不能被複用的限制, 這也是取巧的一種方式。
六、高階操作
Streams
支援的操作很豐富,除了上面介紹的這些比較常用的中間操作,如filter
或map
(參見Stream Javadoc)外。還有一些更復雜的操作,如collect
,flatMap
以及reduce
。接下來,就讓我們學習一下:
本小節中的大多數程式碼示例均會使用以下 List<Person>
進行演示:
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name;
}
}
// 構建一個 Person 集合
List<Person> persons =
Arrays.asList(
new Person("Max", 18),
new Person("Peter", 23),
new Person("Pamela", 23),
new Person("David", 12));
複製程式碼
6.1 Collect
collect 是一個非常有用的終端操作,它可以將流中的元素轉變成另外一個不同的物件,例如一個List
,Set
或Map
。collect 接受入參為Collector
(收集器),它由四個不同的操作組成:供應器(supplier)、累加器(accumulator)、組合器(combiner)和終止器(finisher)。
這些都是個啥?別慌,看上去非常複雜的樣子,但好在大多數情況下,您並不需要自己去實現收集器。因為 Java 8通過Collectors
類內建了各種常用的收集器,你直接拿來用就行了。
讓我們先從一個非常常見的用例開始:
List<Person> filtered =
persons
.stream() // 構建流
.filter(p -> p.name.startsWith("P")) // 過濾出名字以 P 開頭的
.collect(Collectors.toList()); // 生成一個新的 List
System.out.println(filtered); // [Peter, Pamela]
複製程式碼
你也看到了,從流中構造一個 List
異常簡單。如果說你需要構造一個 Set
集合,只需要使用Collectors.toSet()
就可以了。
接下來這個示例,將會按年齡對所有人進行分組:
Map<Integer, List<Person>> personsByAge = persons
.stream()
.collect(Collectors.groupingBy(p -> p.age)); // 以年齡為 key,進行分組
personsByAge
.forEach((age, p) -> System.out.format("age %s: %s\n", age, p));
// age 18: [Max]
// age 23: [Peter, Pamela]
// age 12: [David]
複製程式碼
除了上面這些操作。您還可以在流上執行聚合操作,例如,計算所有人的平均年齡:
Double averageAge = persons
.stream()
.collect(Collectors.averagingInt(p -> p.age)); // 聚合出平均年齡
System.out.println(averageAge); // 19.0
複製程式碼
如果您還想得到一個更全面的統計資訊,摘要收集器可以返回一個特殊的內建統計物件。通過它,我們可以簡單地計算出最小年齡、最大年齡、平均年齡、總和以及總數量。
IntSummaryStatistics ageSummary =
persons
.stream()
.collect(Collectors.summarizingInt(p -> p.age)); // 生成摘要統計
System.out.println(ageSummary);
// IntSummaryStatistics{count=4, sum=76, min=12, average=19.000000, max=23}
複製程式碼
下一個這個示例,可以將所有人名連線成一個字串:
String phrase = persons
.stream()
.filter(p -> p.age >= 18) // 過濾出年齡大於等於18的
.map(p -> p.name) // 提取名字
.collect(Collectors.joining(" and ", "In Germany ", " are of legal age.")); // 以 In Germany 開頭,and 連線各元素,再以 are of legal age. 結束
System.out.println(phrase);
// In Germany Max and Peter and Pamela are of legal age.
複製程式碼
連線收集器的入參接受分隔符,以及可選的字首以及字尾。
對於如何將流轉換為 Map
集合,我們必須指定 Map
的鍵和值。這裡需要注意,Map
的鍵必須是唯一的,否則會丟擲IllegalStateException
異常。
你可以選擇傳遞一個合併函式作為額外的引數來避免發生這個異常:
Map<Integer, String> map = persons
.stream()
.collect(Collectors.toMap(
p -> p.age,
p -> p.name,
(name1, name2) -> name1 + ";" + name2)); // 對於同樣 key 的,將值拼接
System.out.println(map);
// {18=Max, 23=Peter;Pamela, 12=David}
複製程式碼
既然我們已經知道了這些強大的內建收集器,接下來就讓我們嘗試構建自定義收集器吧。
比如說,我們希望將流中的所有人轉換成一個字串,包含所有大寫的名稱,並以|
分割。為了達到這種效果,我們需要通過Collector.of()
建立一個新的收集器。同時,我們還需要傳入收集器的四個組成部分:供應器、累加器、組合器和終止器。
Collector<Person, StringJoiner, String> personNameCollector =
Collector.of(
() -> new StringJoiner(" | "), // supplier 供應器
(j, p) -> j.add(p.name.toUpperCase()), // accumulator 累加器
(j1, j2) -> j1.merge(j2), // combiner 組合器
StringJoiner::toString); // finisher 終止器
String names = persons
.stream()
.collect(personNameCollector); // 傳入自定義的收集器
System.out.println(names); // MAX | PETER | PAMELA | DAVID
複製程式碼
由於Java 中的字串是 final 型別的,我們需要藉助輔助類StringJoiner
,來幫我們構造字串。
最開始供應器使用分隔符構造了一個StringJointer
。
累加器用於將每個人的人名轉大寫,然後加到StringJointer
中。
組合器將兩個StringJointer
合併為一個。
最終,終結器從StringJointer
構造出預期的字串。
6.2 FlatMap
上面我們已經學會了如通過map
操作, 將流中的物件轉換為另一種型別。但是,Map
只能將每個物件對映到另一個物件。
如果說,我們想要將一個物件轉換為多個其他物件或者根本不做轉換操作呢?這個時候,flatMap
就派上用場了。
FlatMap
能夠將流的每個元素, 轉換為其他物件的流。因此,每個物件可以被轉換為零個,一個或多個其他物件,並以流的方式返回。之後,這些流的內容會被放入flatMap
返回的流中。
在學習如何實際操作flatMap
之前,我們先新建兩個類,用來測試:
class Foo {
String name;
List<Bar> bars = new ArrayList<>();
Foo(String name) {
this.name = name;
}
}
class Bar {
String name;
Bar(String name) {
this.name = name;
}
}
複製程式碼
接下來,通過我們上面學習到的流知識,來例項化一些物件:
List<Foo> foos = new ArrayList<>();
// 建立 foos 集合
IntStream
.range(1, 4)
.forEach(i -> foos.add(new Foo("Foo" + i)));
// 建立 bars 集合
foos.forEach(f ->
IntStream
.range(1, 4)
.forEach(i -> f.bars.add(new Bar("Bar" + i + " <- " + f.name))));
複製程式碼
我們建立了包含三個foo
的集合,每個foo
中又包含三個 bar
。
flatMap
的入參接受一個返回物件流的函式。為了處理每個foo
中的bar
,我們需要傳入相應 stream 流:
foos.stream()
.flatMap(f -> f.bars.stream())
.forEach(b -> System.out.println(b.name));
// Bar1 <- Foo1
// Bar2 <- Foo1
// Bar3 <- Foo1
// Bar1 <- Foo2
// Bar2 <- Foo2
// Bar3 <- Foo2
// Bar1 <- Foo3
// Bar2 <- Foo3
// Bar3 <- Foo3
複製程式碼
如上所示,我們已成功將三個 foo
物件的流轉換為九個bar
物件的流。
最後,上面的這段程式碼可以簡化為單一的流式操作:
IntStream.range(1, 4)
.mapToObj(i -> new Foo("Foo" + i))
.peek(f -> IntStream.range(1, 4)
.mapToObj(i -> new Bar("Bar" + i + " <- " f.name))
.forEach(f.bars::add))
.flatMap(f -> f.bars.stream())
.forEach(b -> System.out.println(b.name));
複製程式碼
flatMap
也可用於Java8引入的Optional
類。Optional
的flatMap
操作返回一個Optional
或其他型別的物件。所以它可以用於避免繁瑣的null
檢查。
接下來,讓我們建立層次更深的物件:
class Outer {
Nested nested;
}
class Nested {
Inner inner;
}
class Inner {
String foo;
}
複製程式碼
為了處理從 Outer 物件中獲取最底層的 foo 字串,你需要新增多個null
檢查來避免可能發生的NullPointerException
,如下所示:
Outer outer = new Outer();
if (outer != null && outer.nested != null && outer.nested.inner != null) {
System.out.println(outer.nested.inner.foo);
}
複製程式碼
我們還可以使用Optional
的flatMap
操作,來完成上述相同功能的判斷,且更加優雅:
Optional.of(new Outer())
.flatMap(o -> Optional.ofNullable(o.nested))
.flatMap(n -> Optional.ofNullable(n.inner))
.flatMap(i -> Optional.ofNullable(i.foo))
.ifPresent(System.out::println);
複製程式碼
如果不為空的話,每個flatMap
的呼叫都會返回預期物件的Optional
包裝,否則返回為null
的Optional
包裝類。
筆者補充:關於 Optional 可參見我另一篇譯文《Java8 新特性如何防止空指標異常》
6.3 Reduce
規約操作可以將流的所有元素組合成一個結果。Java 8 支援三種不同的reduce
方法。第一種將流中的元素規約成流中的一個元素。
讓我們看看如何使用這種方法,來篩選出年齡最大的那個人:
persons
.stream()
.reduce((p1, p2) -> p1.age > p2.age ? p1 : p2)
.ifPresent(System.out::println); // Pamela
複製程式碼
reduce
方法接受BinaryOperator
積累函式。該函式實際上是兩個運算元型別相同的BiFunction
。BiFunction
功能和Function
一樣,但是它接受兩個引數。示例程式碼中,我們比較兩個人的年齡,來返回年齡較大的人。
第二種reduce
方法接受標識值和BinaryOperator
累加器。此方法可用於構造一個新的 Person
,其中包含來自流中所有其他人的聚合名稱和年齡:
Person result =
persons
.stream()
.reduce(new Person("", 0), (p1, p2) -> {
p1.age += p2.age;
p1.name += p2.name;
return p1;
});
System.out.format("name=%s; age=%s", result.name, result.age);
// name=MaxPeterPamelaDavid; age=76
複製程式碼
第三種reduce
方法接受三個引數:標識值,BiFunction
累加器和型別的組合器函式BinaryOperator
。由於初始值的型別不一定為Person
,我們可以使用這個歸約函式來計算所有人的年齡總和:
Integer ageSum = persons
.stream()
.reduce(0, (sum, p) -> sum += p.age, (sum1, sum2) -> sum1 + sum2);
System.out.println(ageSum); // 76
複製程式碼
結果為76,但是內部究竟發生了什麼呢?讓我們再列印一些除錯日誌:
Integer ageSum = persons
.stream()
.reduce(0,
(sum, p) -> {
System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
return sum += p.age;
},
(sum1, sum2) -> {
System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
return sum1 + sum2;
});
// accumulator: sum=0; person=Max
// accumulator: sum=18; person=Peter
// accumulator: sum=41; person=Pamela
// accumulator: sum=64; person=David
複製程式碼
你可以看到,累加器函式完成了所有工作。它首先使用初始值0
和第一個人年齡相加。接下來的三步中sum
會持續增加,直到76。
等等?好像哪裡不太對!組合器從來都沒有呼叫過啊?
我們以並行流的方式執行上面的程式碼,看看日誌輸出:
Integer ageSum = persons
.parallelStream()
.reduce(0,
(sum, p) -> {
System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
return sum += p.age;
},
(sum1, sum2) -> {
System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
return sum1 + sum2;
});
// accumulator: sum=0; person=Pamela
// accumulator: sum=0; person=David
// accumulator: sum=0; person=Max
// accumulator: sum=0; person=Peter
// combiner: sum1=18; sum2=23
// combiner: sum1=23; sum2=12
// combiner: sum1=41; sum2=35
複製程式碼
並行流的執行方式完全不同。這裡組合器被呼叫了。實際上,由於累加器被並行呼叫,組合器需要被用於計算部分累加值的總和。
讓我們在下一章深入探討並行流。
七、並行流
流是可以並行執行的,當流中存在大量元素時,可以顯著提升效能。並行流底層使用的ForkJoinPool
, 它由ForkJoinPool.commonPool()
方法提供。底層執行緒池的大小最多為五個 - 具體取決於 CPU 可用核心數:
ForkJoinPool commonPool = ForkJoinPool.commonPool();
System.out.println(commonPool.getParallelism()); // 3
複製程式碼
在我的機器上,公共池初始化預設值為 3。你也可以通過設定以下JVM引數可以減小或增加此值:
-Djava.util.concurrent.ForkJoinPool.common.parallelism=5
複製程式碼
集合支援parallelStream()
方法來建立元素的並行流。或者你可以在已存在的資料流上呼叫中間方法parallel()
,將序列流轉換為並行流,這也是可以的。
為了詳細瞭解並行流的執行行為,我們在下面的示例程式碼中,列印當前執行緒的資訊:
Arrays.asList("a1", "a2", "b1", "c2", "c1")
.parallelStream()
.filter(s -> {
System.out.format("filter: %s [%s]\n",
s, Thread.currentThread().getName());
return true;
})
.map(s -> {
System.out.format("map: %s [%s]\n",
s, Thread.currentThread().getName());
return s.toUpperCase();
})
.forEach(s -> System.out.format("forEach: %s [%s]\n",
s, Thread.currentThread().getName()));
複製程式碼
通過日誌輸出,我們可以對哪個執行緒被用於執行流式操作,有個更深入的理解:
filter: b1 [main]
filter: a2 [ForkJoinPool.commonPool-worker-1]
map: a2 [ForkJoinPool.commonPool-worker-1]
filter: c2 [ForkJoinPool.commonPool-worker-3]
map: c2 [ForkJoinPool.commonPool-worker-3]
filter: c1 [ForkJoinPool.commonPool-worker-2]
map: c1 [ForkJoinPool.commonPool-worker-2]
forEach: C2 [ForkJoinPool.commonPool-worker-3]
forEach: A2 [ForkJoinPool.commonPool-worker-1]
map: b1 [main]
forEach: B1 [main]
filter: a1 [ForkJoinPool.commonPool-worker-3]
map: a1 [ForkJoinPool.commonPool-worker-3]
forEach: A1 [ForkJoinPool.commonPool-worker-3]
forEach: C1 [ForkJoinPool.commonPool-worker-2]
複製程式碼
如您所見,並行流使用了所有的ForkJoinPool
中的可用執行緒來執行流式操作。在持續的執行中,輸出結果可能有所不同,因為所使用的特定執行緒是非特定的。
讓我們通過新增中間操作sort
來擴充套件上面示例:
Arrays.asList("a1", "a2", "b1", "c2", "c1")
.parallelStream()
.filter(s -> {
System.out.format("filter: %s [%s]\n",
s, Thread.currentThread().getName());
return true;
})
.map(s -> {
System.out.format("map: %s [%s]\n",
s, Thread.currentThread().getName());
return s.toUpperCase();
})
.sorted((s1, s2) -> {
System.out.format("sort: %s <> %s [%s]\n",
s1, s2, Thread.currentThread().getName());
return s1.compareTo(s2);
})
.forEach(s -> System.out.format("forEach: %s [%s]\n",
s, Thread.currentThread().getName()));
複製程式碼
執行程式碼,輸出結果看上去有些奇怪:
filter: c2 [ForkJoinPool.commonPool-worker-3]
filter: c1 [ForkJoinPool.commonPool-worker-2]
map: c1 [ForkJoinPool.commonPool-worker-2]
filter: a2 [ForkJoinPool.commonPool-worker-1]
map: a2 [ForkJoinPool.commonPool-worker-1]
filter: b1 [main]
map: b1 [main]
filter: a1 [ForkJoinPool.commonPool-worker-2]
map: a1 [ForkJoinPool.commonPool-worker-2]
map: c2 [ForkJoinPool.commonPool-worker-3]
sort: A2 <> A1 [main]
sort: B1 <> A2 [main]
sort: C2 <> B1 [main]
sort: C1 <> C2 [main]
sort: C1 <> B1 [main]
sort: C1 <> C2 [main]
forEach: A1 [ForkJoinPool.commonPool-worker-1]
forEach: C2 [ForkJoinPool.commonPool-worker-3]
forEach: B1 [main]
forEach: A2 [ForkJoinPool.commonPool-worker-2]
forEach: C1 [ForkJoinPool.commonPool-worker-1]
複製程式碼
貌似sort
只在主執行緒上序列執行。但是實際上,並行流中的sort
在底層使用了Java8中新的方法Arrays.parallelSort()
。如 javadoc官方文件解釋的,這個方法會按照資料長度來決定以序列方式,或者以並行的方式來執行。
如果指定資料的長度小於最小數值,它則使用相應的
Arrays.sort
方法來進行排序。
回到上小節 reduce
的例子。我們已經發現了組合器函式只在並行流中呼叫,而不不會在序列流中被呼叫。
讓我們來實際觀察一下涉及到哪個執行緒:
List<Person> persons = Arrays.asList(
new Person("Max", 18),
new Person("Peter", 23),
new Person("Pamela", 23),
new Person("David", 12));
persons
.parallelStream()
.reduce(0,
(sum, p) -> {
System.out.format("accumulator: sum=%s; person=%s [%s]\n",
sum, p, Thread.currentThread().getName());
return sum += p.age;
},
(sum1, sum2) -> {
System.out.format("combiner: sum1=%s; sum2=%s [%s]\n",
sum1, sum2, Thread.currentThread().getName());
return sum1 + sum2;
});
複製程式碼
通過控制檯日誌輸出,累加器和組合器均在所有可用的執行緒上並行執行:
accumulator: sum=0; person=Pamela; [main]
accumulator: sum=0; person=Max; [ForkJoinPool.commonPool-worker-3]
accumulator: sum=0; person=David; [ForkJoinPool.commonPool-worker-2]
accumulator: sum=0; person=Peter; [ForkJoinPool.commonPool-worker-1]
combiner: sum1=18; sum2=23; [ForkJoinPool.commonPool-worker-1]
combiner: sum1=23; sum2=12; [ForkJoinPool.commonPool-worker-2]
combiner: sum1=41; sum2=35; [ForkJoinPool.commonPool-worker-2]
複製程式碼
總之,你需要記住的是,並行流對含有大量元素的資料流提升效能極大。但是你也需要記住並行流的一些操作,例如reduce
和collect
操作,需要額外的計算(如組合操作),這在序列執行時是並不需要。
此外,我們也瞭解了,所有並行流操作都共享相同的 JVM 相關的公共ForkJoinPool
。所以你可能需要避免寫出一些又慢又卡的流式操作,這很有可能會拖慢你應用中,嚴重依賴並行流的其它部分程式碼的效能。
八、結語
Java8 Stream 流程式設計指南到這裡就結束了。如果您有興趣瞭解更多有關 Java 8 Stream 流的相關資訊,我建議您使用 Stream Javadoc 閱讀官方文件。如果您想了解有關底層機制的更多資訊,您也可以閱讀 Martin Fowlers 關於 Collection Pipelines 的文章。
最後,祝您學習愉快!
贈送 10G 面試&學習福利資源
獲取方式: 關注微信公眾號: 小哈學Java, 後臺回覆"666",既可免費無套路獲取資源連結,下面是目錄以及部分截圖: