小小 Stream,一篇文章拿捏它
在之前的 Java 中的 Lambda文章中,我簡(jiǎn)要提到了 Stream 的使用。在這篇文章中將深入探討它。首先,我們以一個(gè)熟悉的Student類為例。假設(shè)有一組學(xué)生:
public class Student {
private String name;
private Integer age;
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public Integer getAge() {
return age;
}
// toString 方法
@Override
public String toString() {
return"Student{" + "name='" + name + '\'' + ", age=" + age + '}';
}
}
List<Student> students = new ArrayList<>();
students.add(new Student("Bob", 18));
students.add(new Student("Ted", 17));
students.add(new Student("Zeka", 19));
現(xiàn)在有這樣一個(gè)需求:從給定的學(xué)生列表中返回年齡大于等于 18 歲的學(xué)生,按年齡降序排列,最多返回 2 個(gè)。
在Java7 及更早的代碼中,我們會(huì)這樣實(shí)現(xiàn):
public static List<Student> getTwoOldestStudents(List<Student> students) {
List<Student> result = new ArrayList<>();
// 1. 遍歷學(xué)生列表,篩選出符合年齡條件的學(xué)生
for (Student student : students) {
if (student.getAge() >= 18) {
result.add(student);
}
}
// 2. 對(duì)符合條件的學(xué)生按年齡排序
result.sort((s1, s2) -> s2.getAge() - s1.getAge());
// 3. 如果結(jié)果大于 2 個(gè),截取前兩個(gè)數(shù)據(jù)并返回
if (result.size() > 2) {
result = result.subList(0, 2);
}
return result;
}
在Java8 及以后的版本中,借助 Stream,我們可以更優(yōu)雅地寫(xiě)出以下代碼:
public static List<Student> getTwoOldestStudentsByStream(List<Student> students) {
return students.stream()
.filter(s -> s.getAge() >= 18)
.sorted((s1, s2) -> s2.getAge() - s1.getAge())
.limit(2)
.collect(Collectors.toList());
}
兩種方法的區(qū)別:
- 從功能角度來(lái)看,過(guò)程式代碼實(shí)現(xiàn)將集合元素、循環(huán)迭代和各種邏輯判斷耦合在一起,暴露了太多細(xì)節(jié)。隨著需求的變化和復(fù)雜化,過(guò)程式代碼將變得難以理解和維護(hù)。
- 函數(shù)式解決方案將代碼細(xì)節(jié)和業(yè)務(wù)邏輯解耦。類似于 SQL 語(yǔ)句,它表達(dá)的是“做什么”而不是“怎么做”,讓程序員更專注于業(yè)務(wù)邏輯,寫(xiě)出更簡(jiǎn)潔、易理解和維護(hù)的代碼。
基于我日常項(xiàng)目的實(shí)踐經(jīng)驗(yàn),我對(duì) Stream 的核心點(diǎn)、易混淆的用法、典型使用場(chǎng)景等做了詳細(xì)總結(jié)。希望能幫助大家更全面地理解 Stream,并在項(xiàng)目開(kāi)發(fā)中更高效地應(yīng)用它。
一、初識(shí) Stream
Java 8 新增了 Stream 特性,它使用戶能夠以函數(shù)式且更簡(jiǎn)單的方式操作 List、Collection 等數(shù)據(jù)結(jié)構(gòu),并在用戶無(wú)感知的情況下實(shí)現(xiàn)并行計(jì)算。
簡(jiǎn)而言之,Stream 操作被組合成一個(gè) Stream 管道。Stream 管道由以下三部分組成:
- 創(chuàng)建 Stream(從源數(shù)據(jù)創(chuàng)建,源數(shù)據(jù)可以是數(shù)組、集合、生成器函數(shù)、I/O 通道等);
- 中間操作(可能有零個(gè)或多個(gè),它們將一個(gè) Stream 轉(zhuǎn)換為另一個(gè) Stream,例如filter(Predicate));
- 終止操作(產(chǎn)生結(jié)果從而終止 Stream,例如count()或forEach(Consumer))。
下圖展示了這些過(guò)程:
每個(gè)階段里的 Stream 操作都包含多個(gè)方法。我們先來(lái)簡(jiǎn)單了解下每個(gè)方法的功能。
1. 創(chuàng)建 Stream
主要負(fù)責(zé)直接創(chuàng)建一個(gè)新的 Stream,或基于現(xiàn)有的數(shù)組、List、Set、Map 等集合類型對(duì)象創(chuàng)建新的 Stream。
API | 解釋 |
stream() | 創(chuàng)建一個(gè)新的串行流對(duì)象 |
parallelStream() | 創(chuàng)建一個(gè)可以并行執(zhí)行的流對(duì)象 |
Stream.of() | 從給定的元素序列創(chuàng)建一個(gè)新的串行流對(duì)象 |
除了Stream,還有IntStream、LongStream和DoubleStream等基本類型的流,它們都稱為“流”。
2. 中間操作
這一步負(fù)責(zé)處理 Stream 并返回一個(gè)新的 Stream 對(duì)象。中間操作可以疊加。
API | 解釋 |
filter() | 過(guò)濾符合條件的元素并返回一個(gè)新的流 |
sorted() | 按指定規(guī)則對(duì)所有元素排序并返回一個(gè)新的流 |
skip() | 跳過(guò)集合前面的指定數(shù)量的元素并返回一個(gè)新的流 |
distinct() | 去重并返回一個(gè)新的流 |
limit() | 只保留集合前面的指定數(shù)量的元素并返回一個(gè)新的流 |
concat() | 將兩個(gè)流的數(shù)據(jù)合并為一個(gè)新的流并返回 |
peek() | 遍歷并處理流中的每個(gè)元素并返回處理后的流 |
map() | 將現(xiàn)有元素轉(zhuǎn)換為另一種對(duì)象類型(一對(duì)一)并返回一個(gè)新的流 |
flatMap() | 將現(xiàn)有元素轉(zhuǎn)換為另一種對(duì)象類型(一對(duì)多),即一個(gè)原始元素對(duì)象可能轉(zhuǎn)換為一個(gè)或多個(gè)新類型的元素,然后返回一個(gè)新的流 |
3. 終止操作
顧名思義,終止操作后 Stream 將結(jié)束,最后可能會(huì)執(zhí)行一些邏輯處理,或根據(jù)需求返回一些執(zhí)行結(jié)果。
API | 解釋 |
findFirst() | 找到第一個(gè)符合條件的元素時(shí)終止流處理 |
findAny() | 找到任意一個(gè)符合條件的元素時(shí)終止流處理 |
anyMatch() | 返回布爾值,類似于isContains(),用于判斷是否有符合條件的元素 |
allMatch() | 返回布爾值,用于判斷是否所有元素都符合條件 |
noneMatch() | 返回布爾值,用于判斷是否所有元素都不符合條件 |
min() | 返回流處理后的最小值 |
max() | 返回流處理后的最大值 |
count() | 返回流處理后的元素?cái)?shù)量 |
collect() | 將流轉(zhuǎn)換為指定類型,通過(guò)Collectors指定 |
toArray() | 將流轉(zhuǎn)換為數(shù)組 |
iterator() | 將流轉(zhuǎn)換為迭代器對(duì)象 |
forEach() | 無(wú)返回值,遍歷元素并執(zhí)行給定的處理邏輯 |
二、代碼實(shí)戰(zhàn)
1. 創(chuàng)建 Stream
// Stream.of, IntStream.of...
Stream<String> nameStream = Stream.of("Bob", "Ted", "Zeka");
IntStream ageStream = IntStream.of(18, 17, 19);
// stream, parallelStream
Stream<Student> studentStream = students.stream();
Stream<Student> studentParallelStream = students.parallelStream();
在大多數(shù)情況下,我們基于現(xiàn)有的集合創(chuàng)建 Stream。
2. 中間操作
(1) map
map和flatMap都用于將現(xiàn)有元素轉(zhuǎn)換為其他類型。區(qū)別在于:
- map必須是一對(duì)一的,即每個(gè)元素只能轉(zhuǎn)換為一個(gè)新元素;
- flatMap可以是一對(duì)多的,即每個(gè)元素可以轉(zhuǎn)換為一個(gè)或多個(gè)新元素。
我們先來(lái)看map方法。當(dāng)前需求如下:將之前的學(xué)生對(duì)象列表轉(zhuǎn)換為學(xué)生姓名列表并輸出:
public static List<String> objectToString(List<Student> students) {
return students.stream()
.map(Student::getName)
.collect(Collectors.toList());
}
輸出:
[Bob, Ted, Zeka]
可以看到,輸入中有三個(gè)學(xué)生,輸出也是三個(gè)學(xué)生姓名。
(2) flatMap
學(xué)校要求每個(gè)學(xué)生加入一個(gè)團(tuán)隊(duì)。假設(shè) Bob、Ted 和 Zeka 加入了籃球隊(duì),Alan、Anne 和 Davis 加入了足球隊(duì)。
public class Team {
private String type;
private List<Student> students;
public Team(String type, List<Student> students) {
this.type = type;
this.students = students;
}
public String getType() {
return type;
}
public List<Student> getStudents() {
return students;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Team{");
sb.append("type='").append(type).append('\'');
sb.append(", students=[");
for (int i = 0; i < students.size(); i++) {
Student student = students.get(i);
sb.append("{name='").append(student.getName()).append("', age=").append(student.getAge()).append('}');
if (i < students.size() - 1) {
sb.append(", ");
}
}
sb.append("]}");
return sb.toString();
}
}
List<Student> basketballStudents = new ArrayList<>();
basketballStudents.add(new Student("Bob", 18));
basketballStudents.add(new Student("Ted", 17));
basketballStudents.add(new Student("Zeka", 19));
List<Student> footballStudents = new ArrayList<>();
footballStudents.add(new Student("Alan", 19));
footballStudents.add(new Student("Anne", 21));
footballStudents.add(new Student("Davis", 21));
Team basketballTeam = new Team("basketball", basketballStudents);
Team footballTeam = new Team("football", footballStudents);
List<Team> teams = new ArrayList<>();
teams.add(basketballTeam);
teams.add(footballTeam);
現(xiàn)在我們需要統(tǒng)計(jì)所有團(tuán)隊(duì)中的學(xué)生,并將他們合并到一個(gè)列表中。你會(huì)如何實(shí)現(xiàn)這個(gè)需求?
在 Java7 及更早的版本中可以通過(guò)以下方式解決:
List<Student> allStudents = new ArrayList<>();
for (Team team : teams) {
for (Student student : team.getStudents()) {
allStudents.add(student);
}
}
但這段代碼有兩個(gè)嵌套的 for 循環(huán),不夠優(yōu)雅。面對(duì)這個(gè)需求,flatMap可以派上用場(chǎng)。
List<Student> allStudents = teams.stream()
.flatMap(t -> t.getStudents().stream())
.collect(Collectors.toList());
一行代碼就搞定了。flatMap方法接受一個(gè) lambda 表達(dá)式函數(shù),函數(shù)的返回值必須是一個(gè) Stream 類型。flatMap方法最終會(huì)將所有返回的 Stream 合并生成一個(gè)新的 Stream,而map方法無(wú)法做到。
下圖清晰地展示了flatMap的處理邏輯:
(3) filter, distinct, sorted, limit
關(guān)于剛才所有團(tuán)隊(duì)中的學(xué)生列表,我們現(xiàn)在需要知道這些學(xué)生中第二和第三大的年齡。他們必須至少 18 歲。此外,如果有重復(fù)的年齡,只能算一個(gè)。
List<Integer> topTwoAges = allStudents.stream()
.map(Student::getAge) // [18, 17, 19, 19, 21, 21]
.filter(a -> a >= 18) // [18, 19, 19, 21, 21]
.distinct() // [18, 19, 21]
.sorted((a1, a2) -> a2 - a1) // [21, 19, 18]
.skip(1) // [19, 18]
.limit(2) // [19, 18]
.collect(Collectors.toList());
System.out.println(topTwoAges);
輸出:
[19, 18]
注意:由于在skip方法操作后只剩下兩個(gè)元素,limit步驟實(shí)際上可以省略。
(4) peek, foreach
peek方法和foreach方法都可以用于遍歷元素并逐個(gè)處理,因此我們將它們放在一起進(jìn)行比較和講解。但值得注意的是,peek是一個(gè)中間操作方法,而foreach是一個(gè)終止操作方法。
中間操作只能作為 Stream 管道中間的處理步驟,不能直接執(zhí)行以獲取結(jié)果,必須與終止操作配合執(zhí)行。而foreach作為一個(gè)沒(méi)有返回值的終止方法,可以直接執(zhí)行相應(yīng)的操作。
比如,我們分別使用peek和foreach對(duì)籃球隊(duì)的每個(gè)學(xué)生說(shuō)“Hello, xxx…”。
// peek
System.out.println("---start peek---");
basketballTeam.getStudents().stream().peek(s -> System.out.println("Hello, " + s.getName()));
System.out.println("---end peek---");
// foreach
System.out.println("---start foreach---");
basketballTeam.getStudents().stream().forEach(s -> System.out.println("Hello, " + s.getName()));
System.out.println("---end foreach---");
從輸出中可以看出,peek在單獨(dú)調(diào)用時(shí)不會(huì)執(zhí)行,而foreach可以直接執(zhí)行:
---start peek---
---end peek---
---start foreach---
Hello, Bob
Hello, Ted
Hello, Zeka
---end foreach---
如果在peek后面加上終止操作,它就可以執(zhí)行。
System.out.println("---start peek---");
basketballTeam.getStudents().stream().peek(s -> System.out.println("Hello, " + s.getName())).count();
System.out.println("---end peek---");
// 輸出
---start peek---
Hello, Bob
Hello, Ted
Hello, Zeka
---end peek---
peek應(yīng)謹(jǐn)慎用于業(yè)務(wù)處理邏輯。因?yàn)閜eek方法是否執(zhí)行在各個(gè)版本并不一致。
例如,在 Java8 版本中,剛才的peek方法會(huì)正常執(zhí)行,但在 Java17 中,它會(huì)被自動(dòng)優(yōu)化,peek中的邏輯不會(huì)執(zhí)行。至于原因,你可以查看 JDK17 的官方 API 文檔。
三、終止操作
根據(jù)終止操作返回的結(jié)果類型大概分為兩類。
一類返回的是簡(jiǎn)單類型,主要包括max、min、count、findAny、findFirst、anyMatch、allMatch等方法。
另一類是返回的是集合類型。大多數(shù)場(chǎng)景是獲取集合類的結(jié)果對(duì)象,如 List、Set 或 HashMap 等,主要通過(guò)collect方法實(shí)現(xiàn)。
1. 簡(jiǎn)單結(jié)果類型
(1) max, min
max()和min()主要用于返回流處理后元素的最大值/最小值。返回結(jié)果由Optional包裝。關(guān)于Optional的使用,請(qǐng)參考之前的Java 中如何優(yōu)雅地處理 null 值文章 。
我們直接看例子:
找到足球隊(duì)中年齡最大和最小的是誰(shuí)?
// max
footballTeam.getStudents().stream()
.map(Student::getAge)
.max(Comparator.comparing(a -> a))
.ifPresent(a -> System.out.println("足球隊(duì)中最大的年齡是:" + a));
// min
footballTeam.getStudents().stream()
.map(Student::getAge)
.min(Comparator.comparing(a -> a))
.ifPresent(a -> System.out.println("足球隊(duì)中最小的年齡是:" + a));
輸出:
足球隊(duì)中最大的年齡是:21
足球隊(duì)中最小的年齡是:19
(2) findAny, findFirst
findAny()和findFirst()主要用于在找到符合條件的元素。對(duì)于串行 Stream,findAny()和findFirst()功能相同;對(duì)于并行 Stream,findAny()更高效。
假設(shè)籃球隊(duì)新增了一個(gè)學(xué)生 Tom,年齡為 19 歲。
List<Student> basketballStudents = new ArrayList<>();
basketballStudents.add(new Student("Bob", 18));
basketballStudents.add(new Student("Ted", 17));
basketballStudents.add(new Student("Zeka", 19));
basketballStudents.add(new Student("Tom", 19));
現(xiàn)在需要查找到:
- 籃球隊(duì)中第一個(gè)年齡為 19 歲的學(xué)生姓名;
- 籃球隊(duì)中任意一個(gè)年齡為 19 歲的學(xué)生姓名。
// findFirst
basketballStudents.stream()
.filter(s -> s.getAge() == 19)
.findFirst()
.map(Student::getName)
.ifPresent(name -> System.out.println("findFirst: " + name));
// findAny
basketballStudents.stream()
.filter(s -> s.getAge() == 19)
.findAny()
.map(Student::getName)
.ifPresent(name -> System.out.println("findAny: " + name));
輸出:
findFirst: Zeka
findAny: Zeka
可以看到,在串行 Stream 下,這兩個(gè)功能沒(méi)有區(qū)別。并行處理的區(qū)別將在后面介紹。
(3) count
籃球隊(duì)新增了一個(gè)學(xué)生,現(xiàn)在籃球隊(duì)有多少學(xué)生?
System.out.println("籃球隊(duì)的學(xué)生人數(shù):" + basketballStudents.stream().count());
輸出:
籃球隊(duì)的學(xué)生人數(shù):4
(4) anyMatch, allMatch, noneMatch
顧名思義,這三個(gè)方法用于判斷元素是否符合條件,并返回布爾值??匆韵氯齻€(gè)例子:
- 足球隊(duì)中是否有名為 Alan 的學(xué)生?
- 足球隊(duì)中的所有學(xué)生是否都小于 22 歲?
- 足球隊(duì)中是否沒(méi)有年齡超過(guò) 20 歲的學(xué)生?
// anyMatch
System.out.println("anyMatch: " + footballStudents.stream().anyMatch(s -> s.getName().equals("Alan")));
// allMatch
System.out.println("allMatch: " + footballStudents.stream().allMatch(s -> s.getAge() < 22));
// noneMatch
System.out.println("noneMatch: " + footballStudents.stream().noneMatch(s -> s.getAge() > 20));
輸出:
anyMatch: true
allMatch: true
noneMatch: false
2. 結(jié)果集合類型
(1) 生成集合
生成集合應(yīng)該是collect最常用的場(chǎng)景。除了之前提到的 List,還可以生成 Set、Map 等,如下:
// 獲取籃球隊(duì)中學(xué)生年齡的分布,不允許重復(fù)
Set<Integer> ageSet = basketballStudents.stream()
.map(Student::getAge)
.collect(Collectors.toSet());
System.out.println("set: " + ageSet);
// 獲取籃球隊(duì)中所有學(xué)生的姓名和年齡的 Map
Map<String, Integer> nameAndAgeMap = basketballStudents.stream()
.collect(Collectors.toMap(Student::getName, Student::getAge));
System.out.println("map: " + nameAndAgeMap);
輸出:
set: [17, 18, 19]
map: {Ted=17, Tom=19, Bob=18, Zeka=19}
(2) 生成字符串
除了生成集合,collect還可以用于拼接字符串。
例如,我們獲取籃球隊(duì)中所有學(xué)生的姓名后,希望用“,”將所有姓名拼接成一個(gè)字符串并返回。
System.out.println(basketballStudents.stream()
.map(Student::getName)
.collect(Collectors.joining(",")));
輸出:
Bob,Ted,Zeka,Tom
也許你會(huì)說(shuō),用String.join()不也能實(shí)現(xiàn)這個(gè)功能嗎?確實(shí),如果只是單純的字符串拼接,確實(shí)沒(méi)有必要使用Stream來(lái)實(shí)現(xiàn)。畢竟,殺雞焉用牛刀!
此外,Collectors.joining()還支持定義前綴和后綴,功能更強(qiáng)大。
System.out.println(basketballStudents.stream()
.map(Student::getName)
.collect(Collectors.joining(",", "(", ")")));
輸出:
(Bob,Ted,Zeka)
(3) 生成統(tǒng)計(jì)結(jié)果
還有一個(gè)在實(shí)際中可能很少用到的場(chǎng)景,就是使用collect生成數(shù)字?jǐn)?shù)據(jù)的統(tǒng)計(jì)結(jié)果。我們簡(jiǎn)單看一下。
// 計(jì)算平均年齡
System.out.println("平均年齡:" + basketballStudents.stream()
.map(Student::getAge)
.collect(Collectors.averagingInt(a -> a)));
// 統(tǒng)計(jì)匯總
IntSummaryStatistics summary = basketballStudents.stream()
.map(Student::getAge)
.collect(Collectors.summarizingInt(a -> a));
System.out.println("summary: " + summary);
在上面的例子中,使用collect對(duì)年齡進(jìn)行了一些數(shù)學(xué)運(yùn)算,結(jié)果如下:
平均年齡:18.0
summary: IntSummaryStatistics{count=3, sum=54, min=17, average=18.000000, max=19}
四、并行 Stream
使用并行流可以有效利用計(jì)算機(jī)性能,提高執(zhí)行速度。并行 Stream 將整個(gè)流分成多個(gè)片段,然后并行處理每個(gè)片段的流,最后將每個(gè)片段的執(zhí)行結(jié)果匯總成一個(gè)完整的 Stream。
如下圖所示,篩選出大于等于 18 的數(shù)字:
將原始任務(wù)拆分為多個(gè)任務(wù)。
[7, 18, 18]
每個(gè)任務(wù)并行執(zhí)行操作。
stream.filter(a -> a >= 18)
單個(gè)任務(wù)處理并匯總為單個(gè)結(jié)果。
[18, 18]
高效使用 findAny()
如上所述,findAny()在并行 Stream 中更高效,從 API 文檔中可以看出,每次執(zhí)行該方法的結(jié)果可能不同。
使用parallelStream執(zhí)行findAny()10 次,以找出任何滿足條件(名字是 Bob、Tom 或 Zeka)的學(xué)生名字。
for (int i = 0; i < 10; i++) {
basketballStudents.parallelStream()
.filter(s -> s.getAge() >= 18)
.findAny()
.map(Student::getName)
.ifPresent(name -> System.out.println("并行流中的 findAny: " + name));
}
輸出:
并行流中的findAny: Zeka
并行流中的findAny: Zeka
并行流中的findAny: Tom
并行流中的findAny: Zeka
并行流中的findAny: Zeka
并行流中的findAny: Bob
并行流中的findAny: Zeka
并行流中的findAny: Zeka
并行流中的findAny: Zeka
這個(gè)輸出證實(shí)了findAny()的不穩(wěn)定性。
關(guān)于并行流的更多知識(shí),我將在后續(xù)文章中進(jìn)一步分析和討論。
五、注意事項(xiàng)
1. 延遲執(zhí)行
Stream 是惰性的;只有在啟動(dòng)終止操作時(shí)才會(huì)對(duì)源數(shù)據(jù)執(zhí)行計(jì)算,并且只在需要時(shí)才會(huì)消耗源元素。前面提到的peek方法就是一個(gè)很好的例子。
2. 避免執(zhí)行兩次終止操作
一旦 Stream 被終止,就不能再用于執(zhí)行其他操作,否則會(huì)報(bào)錯(cuò)??聪旅娴睦樱?/p>
Stream<Student> stream = students.stream();
stream.filter(s -> s.getAge() >= 18).count();
stream.filter(s -> s.getAge() >= 18).forEach(System.out::println); // 這里會(huì)報(bào)錯(cuò)
輸出:
java.lang.IllegalStateException: stream has already been operated upon or closed
因?yàn)橐坏?Stream 被終止,就不能再重復(fù)使用。