Java 的 lambda 表达式把一小段行为变成值,流再把它们串成惰性的流水线。看看流水线到底怎样运行、该用哪些收集器,以及什么时候普通循环更好读。
lambda 表达式是一小段代码,你可以像传值一样把它传来传去。流(stream)是一条流水线,让一串值依次经过由这些 lambda 组成的步骤。两者合起来,能取代老式 Java 代码里满眼的循环。
本文讲 lambda 表达式和它背后的函数式接口、方法引用、lambda 能捕获什么、流水线、惰性求值、收集器、基本类型流、reduce、gatherer,以及循环依然更合适的场景。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
从匿名类到 lambda
lambda 是一种更短的写法,用来写出一个实现了“只有一个抽象方法的接口”的对象。按长度给单词排序,就能看清这条演变路线。下面把同一个 Comparator 写了三遍:
List<String> sortedBy(List<String> words, Comparator<String> order) {
var copy = new ArrayList<>(words);
copy.sort(order);
return copy;
}
void main() {
var words = List.of("banana", "fig", "cherry", "kiwi");
var anonymous = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
Comparator<String> lambda = (a, b) -> Integer.compare(a.length(), b.length());
Comparator<String> byKey = Comparator.comparing(String::length);
IO.println(sortedBy(words, anonymous));
IO.println(sortedBy(words, lambda));
IO.println(sortedBy(words, byKey));
}
输出:
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
匿名类要把接口、方法名和参数类型全写出来。lambda 只留下参数和方法体,因为编译器已经知道目标类型是 Comparator<String>,而 Comparator 只有一个方法要填。第三行干脆不写比较逻辑:Comparator.comparing 根据一个键(这里是长度)替你造出比较器。
每一行里 banana 都排在 cherry 前面,虽然两者都是六个字母。List.sort 是稳定排序,相等的元素保持原来的顺序。
函数式接口
函数式接口是恰好只有一个抽象方法的接口,任何一个这样的接口都可以用 lambda 来实现。JDK 在 java.util.function 里提供了一组通用的函数式接口,所以你很少需要自己定义:
@FunctionalInterface
interface PriceRule {
int apply(int cents);
}
void main() {
Function<String, Integer> length = s -> s.length();
Predicate<String> isLong = s -> s.length() > 4;
Supplier<List<String>> fresh = () -> new ArrayList<>();
Consumer<String> shout = s -> IO.println(s.toUpperCase() + "!");
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
UnaryOperator<String> tidy = s -> s.strip();
PriceRule discount = cents -> cents * 90 / 100;
IO.println(length.apply("coffee"));
IO.println(isLong.test("tea"));
var list = fresh.get();
list.add("new");
IO.println(list);
shout.accept("hello");
IO.println(add.apply(2, 3));
IO.println("[" + tidy.apply(" milk ") + "]");
IO.println(discount.apply(500));
}
输出:
6
false
[new]
HELLO!
5
[milk]
450
每个接口都按它的形状命名:
Function<T, R>接收一个T,返回一个R。调用它用apply。Predicate<T>接收一个T,返回boolean,用test调用。Supplier<T>不接收参数,返回一个T,用get调用。Consumer<T>接收一个T,什么都不返回,用accept调用。BiFunction<T, U, R>接收两个参数,返回一个R。UnaryOperator<T>就是Function<T, T>:输入和输出类型相同。
PriceRule 是我们自己定义的。@FunctionalInterface 可写可不写,但写上后编译器会检查“恰好一个”这条规则。我们加了第二个抽象方法 int undo(int cents),构建就失败了,报 Unexpected @FunctionalInterface annotation,后面跟着 multiple non-overriding abstract methods found in interface PriceRule。
方法引用的四种形式
方法引用用 :: 来写,它是一个方法体只有一次方法调用的 lambda。方法引用有四种,区别在于对象从哪里来:
record Point(int x, int y) {}
void main() {
// static method: s -> Integer.parseInt(s)
Function<String, Integer> parse = Integer::parseInt;
// method on one object you already have: s -> prefix.concat(s)
String prefix = "id-";
Function<String, String> tag = prefix::concat;
// method on whatever object arrives: s -> s.toUpperCase()
Function<String, String> upper = String::toUpperCase;
// constructor: (x, y) -> new Point(x, y)
BiFunction<Integer, Integer, Point> make = Point::new;
IO.println(parse.apply("42") + 1);
IO.println(tag.apply("7"));
IO.println(upper.apply("tea"));
IO.println(make.apply(3, 4));
}
输出:
43
id-7
TEA
Point[x=3, y=4]
每行上方的注释就是它所替代的 lambda。最容易让人犯迷糊的是 String::toUpperCase。它看起来像静态调用,但 toUpperCase 是实例方法,所以第一个参数会成为调用这个方法的对象。
lambda 能捕获什么
lambda 可以读取外层方法里的局部变量,但前提是这些变量是 final 或 effectively final 的,也就是赋值之后再也没有被赋过值。用 lambda 计数就违反了这条规则:
void main() {
int count = 0;
List.of("a", "b", "c").forEach(s -> count++);
IO.println(count);
}
构建失败,报错:
Main.java:3: error: local variables referenced from a lambda expression must be final or effectively final
List.of("a", "b", "c").forEach(s -> count++);
^
lambda 的寿命可能比创建它的方法更长,比如被存进字段,或者在另一个线程上运行。所以 Java 会把值复制进 lambda,而不是共享这个变量。如果变量之后还能改,副本和原变量就会对不上,Java 在编译期就把这种情况排除了。字段不受这个限制,因为 lambda 是通过对象访问字段的。
和匿名类还有一点不同:在 lambda 里,this 的含义和外层代码里一样。
void main() {
Runnable lambda = () -> IO.println("lambda this: " + this.getClass().getName());
Runnable anonymous = new Runnable() {
@Override
public void run() {
IO.println("anonymous this: " + this.getClass().getName());
}
};
lambda.run();
anonymous.run();
}
输出:
lambda this: Main
anonymous this: Main$1
lambda 里的 this 就是运行 main 的那个 Main 对象。匿名类是一个新类 Main$1,所以它的 this 是它自己。
流水线:源、步骤、结果
一条流水线分三部分:一个源、任意多个中间操作,以及一个产生结果的终止操作。下面一次就把一份订单列表整理好:
void main() {
var orders = List.of("tea", "coffee", "tea", "juice", "water", "coffee", "milk");
var result = orders.stream()
.filter(o -> !o.equals("water"))
.distinct()
.map(String::toUpperCase)
.sorted()
.limit(3)
.toList();
IO.println(result);
IO.println(orders);
}
输出:
[COFFEE, JUICE, MILK]
[tea, coffee, tea, juice, water, coffee, milk]
- 源:
orders.stream()。集合、数组、Stream.of(...)和文件都可以做源。 - 中间操作:
filter保留通过Predicate的元素,distinct去掉重复,map把每个元素变成别的东西,sorted排序,limit保留前几个。每个中间操作都返回一个新的流。 - 终止操作:
toList()把剩下的元素收集起来。
最后一行说明源列表没被动过。流只读取它的源,不会修改它。
toList() 还是 collect(Collectors.toList())
Stream.toList() 是 Java 16 才加入的。我们验证过:用 javac --release 15 编译,这个调用会报 cannot find symbol。在那之前,你得写 collect(Collectors.toList()),很多代码至今还这么写。两者并不完全一样:
void main() {
var viaCollect = Stream.of("b", "a").collect(Collectors.toList());
viaCollect.add("c");
IO.println(viaCollect);
var viaToList = Stream.of("b", "a").toList();
IO.println(viaToList);
viaToList.add("c");
}
输出后停止:
[b, a, c]
[b, a]
Exception in thread "main" java.lang.UnsupportedOperationException
toList() 返回的是不可修改的列表,所以 add 会抛异常。Collectors.toList() 对此不做任何保证,只是目前它碰巧返回 ArrayList。除非你之后真的要改这个列表,否则就用 toList()。如果确实要改,就用 Collectors.toCollection(ArrayList::new) 把意图写明。
惰性:你不要,就什么都不运行
调用中间操作时,它们并不干活,只是描述要干的活。等终止操作要结果时,活才开始干:
void main() {
var pipeline = Stream.of(1, 2, 3).map(n -> {
IO.println("map saw " + n);
return n * 10;
});
IO.println("pipeline built");
var result = pipeline.toList();
IO.println(result);
}
输出:
pipeline built
map saw 1
map saw 2
map saw 3
[10, 20, 30]
pipeline built 最先出现。执行 map 那一行时,lambda 一次都没被调用。它返回的是一个记住了“乘以十”的流。直到 toList() 把元素拉过流水线,lambda 才真正运行。
一次一个元素
流不会先对整个列表跑一遍 filter,再对结果跑一遍 map。每个元素都会走完整条流水线,下一个元素才开始:
void main() {
var result = Stream.of("apple", "fig", "cherry", "kiwi")
.filter(w -> {
IO.println("filter " + w);
return w.length() > 3;
})
.map(w -> {
IO.println("map " + w);
return w.toUpperCase();
})
.toList();
IO.println(result);
}
输出:
filter apple
map apple
filter fig
filter cherry
map cherry
filter kiwi
map kiwi
[APPLE, CHERRY, KIWI]
从上往下读。apple 通过过滤,直接进入 map。fig 没通过过滤,所以 map 根本见不到它。接着 cherry 走完全程,然后是 kiwi。在循环里套一个 if 做的也正是这件事,而这正是关键:流就是一个你描述出来、而不是亲手写出来的循环。
sorted 是例外。它必须看过所有元素才能往下传,所以会先把元素全部收齐:
void main() {
var result = Stream.of("kiwi", "apple", "fig")
.peek(w -> IO.println("before sorted " + w))
.sorted()
.peek(w -> IO.println("after sorted " + w))
.toList();
IO.println(result);
}
输出:
before sorted kiwi
before sorted apple
before sorted fig
after sorted apple
after sorted fig
after sorted kiwi
[apple, fig, kiwi]
peek 会在每个元素经过时对它执行一个 Consumer,用来观察数据流动很方便。sorted 和 distinct 叫作有状态操作,因为它们需要记住元素。filter 和 map 是无状态的。
短路:提前停下,无限流也不怕
有些操作一拿到答案就让流水线停下。findFirst 在第一个到达它的元素处停止,limit(n) 在 n 个元素之后停止。正因如此,无限的源才是安全的:
void main() {
var firstBig = Stream.iterate(1, n -> n * 2)
.peek(n -> IO.println("looked at " + n))
.filter(n -> n > 20)
.findFirst();
IO.println(firstBig);
var firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();
IO.println(firstFive);
}
输出:
looked at 1
looked at 2
looked at 4
looked at 8
looked at 16
looked at 32
Optional[32]
[1, 2, 4, 8, 16]
Stream.iterate(1, n -> n * 2) 描述的是 1、2、4、8,一直到无穷。流水线看了六个值,找到 32,就停了。findFirst 返回 Optional,因为流可能根本没有第一个元素。讲 Optional 的那一篇会细说。把 findFirst() 换成 toList(),程序就永远不会结束。
惰性可能跳过你的 lambda
惰性还有一个我们没料到的意外。终止操作可能判断自己根本不需要运行流水线:
void main() {
long quick = Stream.of(1, 2, 3)
.peek(n -> IO.println("peek " + n))
.count();
IO.println("count " + quick);
long slow = Stream.of(1, 2, 3)
.filter(n -> n > 1)
.peek(n -> IO.println("peek " + n))
.count();
IO.println("count " + slow);
}
输出:
count 3
peek 2
peek 3
count 2
第一个 peek 从没运行过。Stream.of(1, 2, 3) 知道自己有三个元素,peek 改变不了这一点,所以 count 不拉任何元素就答出了 3。一旦中间隔着一个 filter,元素个数就不确定了,每个元素都得流过去。JDK 从 Java 9 起就这么做,Stream 的文档也允许这样。所以别把你依赖的工作放进 peek 或 map。流里的 lambda 应该计算一个值,而不是制造副作用。
用十岁孩子能懂的话说
流就像玩具工厂里的一条流水线。流水线旁站着几个工人:一个把坏玩具扔掉,一个给好玩具上色,一个把玩具装进盒子。流水线的最末端站着一位顾客。
把流水线搭好,并不会造出任何玩具。直到末端的顾客说“我要一个做好的玩具”,东西才开始动。
然后第一个玩具走完整条流水线:检查、上色、装盒、交给顾客。这之后第二个玩具才出发。如果顾客只要一个玩具,流水线就停了,其他玩具永远不会离开那一堆。
准确的说法
流水线是由一串阶段对象组成的链。每个中间操作,比如 filter 或 map,都会添加一个阶段并立即返回。终止操作启动求值。它向源逐个索取元素,每个元素依次经过每个阶段的 lambda,然后才向源索取下一个。
filter 和 map 这样的无状态阶段处理完一个元素就马上往下传。sorted 这样的有状态阶段会把元素扣下,直到源被取空。findFirst、anyMatch、limit 这类短路操作会发出“已完成”的信号,源就不再产生元素。因为终止操作在开始之前就能看到整条流水线,它还可以跳过结果用不上的阶段,就像 count 那样。
这个比喻的局限:真实的流水线如果有个慢吞吞的上色工,工人之间就会堆起排队的玩具。顺序流永远不会这样。它没有队列,也不会有第二个玩具同时在途,唯一的例外是 sorted 这样的有状态阶段把流水线停下来收集全部元素。另外,用了 parallel() 之后,好几条流水线会同时运行,元素也不再按顺序到达。
看 findFirst 怎样让流停下
下面的动画演示 Stream.of(1, 2, 3, 4, 5).filter(n -> n % 2 == 1).map(n -> n * 10).findFirst(),它返回 Optional[10]:
一个五元素的流被拉着经过 filter、map 和 findFirst。findFirst 索取之前,什么都不动。元素 1 通过过滤,变成 10,到达 findFirst,后者返回 Optional[10]。流就停在那里,2 到 5 从未被读取。
如果动画没有播放,下面用文字说明这几步:
Stream.of、filter和map都立即返回。它们搭好了一条流水线,还没有任何元素移动。findFirst()是终止操作。它向流水线索取一个元素,这个请求一路传到源。- 源交出
1。filter 检查1 % 2 == 1,结果为真,于是1通过。 1进入map,出来变成10。10到达findFirst()。它要的就这么多,于是把值包装成Optional[10]。findFirst()告诉流水线自己已经完成。源再也不交出 2、3、4、5,也没有 lambda 为它们运行。
流只能用一次
流是对源的一次单程遍历,所以在同一个流对象上执行第二个终止操作会失败:
void main() {
var words = Stream.of("tea", "coffee", "milk");
IO.println(words.count());
IO.println(words.count());
}
输出后停止:
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
解决办法是每次需要新的流水线时,都回到源头调用 list.stream()。如果要让别的代码自己创建新的流,就把集合传给它,而不是传一个流。
收集器:分组、分区、拼接
collect 是一个根据元素构建出某种结果的终止操作,Collectors 里放着现成的构建器。下面把销售记录分组、拆分、拼接:
record Sale(String city, String product, int amount) {}
void main() {
var sales = List.of(
new Sale("Lisbon", "tea", 12),
new Sale("Porto", "coffee", 30),
new Sale("Lisbon", "coffee", 25),
new Sale("Braga", "tea", 8),
new Sale("Porto", "tea", 15));
Map<String, Long> perCity = sales.stream()
.collect(Collectors.groupingBy(Sale::city, TreeMap::new, Collectors.counting()));
IO.println(perCity);
Map<Boolean, List<Integer>> bigOrSmall = sales.stream()
.map(Sale::amount)
.collect(Collectors.partitioningBy(a -> a >= 20));
IO.println(bigOrSmall);
String cities = sales.stream()
.map(Sale::city)
.distinct()
.sorted()
.collect(Collectors.joining(", ", "<", ">"));
IO.println(cities);
}
输出:
{Braga=1, Lisbon=2, Porto=2}
{false=[12, 8, 15], true=[30, 25]}
<Braga, Lisbon, Porto>
groupingBy(key, mapFactory, downstream)按键把元素分到不同的桶里,再对每个桶运行第二个收集器。counting()把每个桶变成它的元素个数。去掉TreeMap::new,你得到的是HashMap,打印出来的顺序没什么用。TreeMap让城市保持有序。partitioningBy(predicate)总是恰好分成两个桶,false和true,即使其中一个是空的。joining(separator, prefix, suffix)把字符串拼到一起。它只能用在字符串流上,所以前面要先map。
toMap 与重复的键
Collectors.toMap 用一个键函数和一个值函数构建 map。当两个元素产生相同的键时,你必须说明怎么处理,否则流会抛异常:
record Sale(String city, int amount) {}
void main() {
var sales = List.of(new Sale("Lisbon", 12), new Sale("Porto", 30), new Sale("Lisbon", 25));
Map<String, Integer> totals = sales.stream()
.collect(Collectors.toMap(Sale::city, Sale::amount, Integer::sum, TreeMap::new));
IO.println(totals);
Map<String, Integer> broken = sales.stream()
.collect(Collectors.toMap(Sale::city, Sale::amount));
IO.println(broken);
}
输出后停止:
{Lisbon=37, Porto=30}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Lisbon (attempted merging values 12 and 25)
第三个参数 Integer::sum 是合并函数。它拿到旧值和新值,返回要保留的值,所以 Lisbon 的销售额加起来是 37。没有它,第二个 Lisbon 就会抛出 IllegalStateException。异常信息写明了键和两个值。如果要做从键到计数或求和的 map,用 groupingBy 搭配 counting() 或 summingInt,表达得更直白。
基本类型流
IntStream、LongStream 和 DoubleStream 存放的是普通的 int、long 和 double 值,并且带有 Stream<Integer> 没有的数值方法:
void main() {
IO.println(IntStream.range(0, 5).sum());
IO.println(IntStream.rangeClosed(1, 5).boxed().toList());
var words = List.of("tea", "coffee", "milk");
int letters = words.stream().mapToInt(String::length).sum();
OptionalDouble average = words.stream().mapToInt(String::length).average();
IO.println(letters);
IO.println(average);
IO.println(average.orElse(0));
IO.println(IntStream.empty().average());
}
输出:
10
[1, 2, 3, 4, 5]
13
OptionalDouble[4.333333333333333]
4.333333333333333
OptionalDouble.empty
range(0, 5) 是 0 到 4,rangeClosed(1, 5) 包含 5。需要 List 时,用 boxed() 回到 Stream<Integer>。
average() 返回的是 OptionalDouble,不是 double,因为空流没有平均值。最后一行展示的就是这种情况。
最重要的是 mapToInt。words.stream().map(String::length) 给你的是 Stream<Integer>:每个长度都被装箱成一个 Integer 对象,而且没有 sum() 方法可调。mapToInt 给你的是由普通 int 组成的 IntStream。不会创建包装对象,sum、average、min 和 max 也都现成可用。
reduce 简介
reduce 把一个函数两两应用到元素上,把所有元素合并成一个值:
void main() {
int total = Stream.of(3, 4, 5).reduce(0, Integer::sum);
Optional<Integer> product = Stream.of(3, 4, 5).reduce((a, b) -> a * b);
Optional<Integer> nothing = Stream.<Integer>empty().reduce((a, b) -> a * b);
IO.println(total);
IO.println(product);
IO.println(nothing);
IO.println(IntStream.of(3, 4, 5).sum());
}
输出:
12
Optional[60]
Optional.empty
12
给了初始值(这里是 0),你得到的是普通结果。不给初始值,你得到的是 Optional,因为可能没有东西可合并。reduce 很通用,所以读代码的人得自己琢磨它算的是什么。求和、计数、最小值、最大值和拼接都有专门命名的方法(sum()、count()、max(...)、Collectors.joining),直接把意思说出来,就像最后一行那样。
gatherer:自定义中间步骤
gatherer 是一种自定义的中间操作,配合 Stream.gather 使用。gatherer 在 Java 24 中正式定稿。我们验证过:用 javac --release 23 编译,构建会失败,报 Gatherers is a preview API and is disabled by default,而在 Java 25 上不需要任何标志。Gatherers 自带几个现成的 gatherer,比如固定窗口和滑动窗口:
void main() {
var readings = List.of(3, 5, 4, 8, 9, 2, 7);
IO.println(readings.stream().gather(Gatherers.windowFixed(3)).toList());
IO.println(readings.stream().gather(Gatherers.windowSliding(3)).toList());
var movingAverage = readings.stream()
.gather(Gatherers.windowSliding(3))
.map(w -> w.stream().mapToInt(Integer::intValue).average().orElseThrow())
.toList();
IO.println(movingAverage);
}
输出:
[[3, 5, 4], [8, 9, 2], [7]]
[[3, 5, 4], [5, 4, 8], [4, 8, 9], [8, 9, 2], [9, 2, 7]]
[4.0, 5.666666666666667, 7.0, 6.333333333333333, 6.0]
windowFixed(3) 把流切成每组三个,最后一组拿剩下的元素。windowSliding(3) 每次向前移动一个元素,移动平均需要的正是这个。有 gatherer 之前,要把相邻元素分组,就得写一个带索引的循环。你也可以用 Gatherer.of 写自己的 gatherer。
什么时候循环更清楚
流擅长“拿来这些元素,留下一部分,变换它们,收集起来”。超出这个形状,有四种情况会反复出现。
受检异常。 java.util.function 里的函数式接口没有一个声明了受检异常,所以 lambda 不能抛出受检异常:
int parsePort(String s) throws IOException {
if (s.isBlank()) {
throw new IOException("blank port");
}
return Integer.parseInt(s);
}
void main() {
var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
IO.println(ports);
}
构建失败,报错:
Main.java:9: error: unreported exception IOException; must be caught or declared to be thrown
var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
^
绕过去的办法有两种:在 lambda 里捕获异常,把它包装成非受检异常;或者写一个辅助方法替你做这件事。两种都会把异常从方法签名里藏起来。而 for 循环可以直接让异常往外传。
修改局部状态,以及提前退出。 下面是“按顺序买东西,直到下一件会超出预算为止”,先用流写一遍,再用循环写一遍:
void main() {
var prices = List.of(40, 25, 30, 50, 10);
int budget = 100;
int[] spentBox = {0};
long boughtByStream = prices.stream()
.takeWhile(p -> {
if (spentBox[0] + p > budget) {
return false;
}
spentBox[0] += p;
return true;
})
.count();
IO.println("stream: bought " + boughtByStream + ", spent " + spentBox[0]);
int spent = 0;
int bought = 0;
for (int price : prices) {
if (spent + price > budget) {
break;
}
spent += price;
bought++;
}
IO.println("loop: bought " + bought + ", spent " + spent);
}
输出:
stream: bought 3, spent 95
loop: bought 3, spent 95
两者答案相同。流的版本需要一个单元素数组,因为 lambda 不能给局部变量赋值,只好改数组里面的内容。它的 takeWhile 谓词带有副作用,而前面 count 的意外恰恰警告过这一点。循环按事情发生的顺序把它们写出来,break 就是提前退出,不需要任何花招。
调试。 在循环里,你可以在某一行设断点,查看每个变量。在流水线里,你写的代码是在流库内部运行的。栈跟踪里塞满了 java.util.stream 的栈帧和 lambda$main$0 这类生成的名字,断点停在 lambda 里时,周围也没有循环变量可看。如果你需要好几个 peek 才能看懂一条流水线,它多半应该改成循环。
一条简短的规则:流水线读起来像一句话时,比如过滤这个、映射那个、收集起来,就用流。需要 break、受检异常、几个一起变化的变量,或者要用调试器时,就用循环。
要点
- lambda 实现的是只有一个抽象方法的接口。
Function、Predicate、Supplier、Consumer、BiFunction和UnaryOperator能满足大多数需要。 - 方法引用,比如
String::length或Point::new,就是只调用一个方法的 lambda。 - lambda 只能捕获 effectively final 的局部变量。
- 终止操作开口之前,流什么都不运行;之后它让一个元素走完所有步骤,下一个才开始。
findFirst和limit能让它提前停下,即使源是无限的。 - 别依赖
peek或map里的副作用。终止操作可能会跳过它们,就像count那样。 - 流只能用一次。
toList()返回的列表不可修改。顺序重要时给groupingBy传一个TreeMap,键可能重复时给toMap传一个合并函数。 - 数值计算用
mapToInt;遇到受检异常、提前退出和变化的状态,就用普通循环。
流描述的是要做的工作,而终止操作决定其中究竟有多少会真正运行。