- 第 6 章 用流收集数据
- 6.1 收集器简介
- 收集 toList
- 收集 toSet
- 收集 toMap
- 简单版本
- 配合 Mybatis 使用,遇到了点问题
- 收集 toCollection
- 转数组 toArray
- 6.2 归约和汇总
- 6.2.1 查找流中的最大值和最小值
- 按最大值 maxBy 查找
- 按最小值 minBy 查找
- 6.2.2 汇总
- 求合 summingInt、summingLong、summingDouble
- 平均 averagingInt、averagingLong、averagingDouble
- 综合统计 summarizingInt、summarizingLong、summarizingDouble
- 6.2.3 连接字符串 joining
- 6.2.4 广义的归约汇总 reducing
- 6.3 分组
- 按条件分组 groupingBy
- 6.3.1 多级分组 groupingBy 嵌套
- 6.3.2 按子组收集数据
- 1. 把收集器的结果转换为另一种类型 collectingAndThen
- 2. 与groupingBy联合使用的其他收集器的例子 mapping
- 先分组再各自排序
- mapping 分组后对值进行映射,再收集
- 6.4 分区 partitioningBy
- 6.5 收集器接口
- 6.5.1 理解 Collector 接口声明的方法
- 进行自定义收集而不去实现Collector
- 6.6 开发你自己的收集器以获得更好的性能
- 小结
- 参考资料
Collectors实用类提供了很多静态工厂方法,可以方便地创建常见收集器的实例,只要拿来用就可以了。最直接和最常用的收集器是toList静态方法,它会把流中所有的元素收集到一个List中:
List收集 toListtransactions = transactionStream.collect(Collectors.toList());
java.util.stream.Collectors.toList()
把流中所有项目收集到一个 List
List收集 toSetscoreList = Hero.getList().stream() .map(Hero::getScore) .collect(Collectors.toList()); System.out.println(scoreList); // [23, 19, 20, 21, 25, 45]
java.util.stream.Collectors.toSet()
把流中所有项目收集到一个 Set,删除重复项
Set收集 toMapcollect = Hero.getList().stream() .map(Hero::getScore) .collect(Collectors.toSet()); System.out.println(collect); // [19, 20, 21, 23, 25, 45]
java.util.stream.Collectors.toMap()
简单版本只指定key和value。这里以Name为key,以对象本身为value。
所以值这里使用了恒等函数java.util.function.Function.identity()
Hero.getList().stream() .collect(toMap(Hero::getName, Function.identity()));配合 Mybatis 使用,遇到了点问题
这里是配合Mybatis使用,遇到了点问题:key 和 value 能取到,但是直接类型转换报错。
分析:
处理 Key类型转换这里估计与Mybatis的返回值类型处理有关。
- mybatis 返回类型,接口写的是List
- id字段在表中类型是int,我试着在resultMap把它定义成了Long。但到了处理key这一步,就报Error: java: 不兼容的类型: java.lang.Integer无法转换为java.lang.Long,怀疑是这个问题,但未深入验证。
- 但collection字段的转换又没问题这个字段是用count算出来的默认返回Long。
Integer与Long没继承关系不能强转,这个我能理解。但intValue()、longValue()也不好使了,我不太明白。。。
以下没有使用resultMap处理,直接使用List
先强转为Number再输出想要的目标类型,问题解决。
collectionsMapper.selectCollection().stream()
.collect(Collectors.toMap(
// 处理 key
m -> ((Number)m.get("id")).longValue(),
// 处理 value
m -> ((Number)m.get("collection")).intValue(),
// 处理重复 key 规则 : 使用新值
(oldValue, newValue) -> newvalue,
// 想转的目标类型
LinkedHashMap::new
));
- 关于类型转换,网上看到的其它解决方案:
- 用Object中转一下再转目标类型
m -> (Long)(Object)m.get("id")
- 用resultMap把所有字段返回类型都改成String转map时各字段根据需要处理。
m -> Long.parseLong(m.get("id")),
- 用String中转一下,道理一样。
m -> Long.parseLong(String.valueOf(m.get("id")))
- 还看到有人是转JSON再转回来这么实现的eeeeeeeeeeee。
把流中所有项目收集到给定的供应源创建的集合
Hero.getList().stream()
.collect(toCollection(), ArrayList::new);
利用TreeSet实现去重。
AbstractCollectioncollect = Hero.getList().stream() .collect(toCollection( () -> new TreeSet<>(comparing(Hero::getGroupTag)) )); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat));
利用collectingAndThen去重,后再处理(将结果装进ArrayList)
ArrayList转数组 toArraycollect = Hero.getList().stream().collect( collectingAndThen( toCollection(() -> new TreeSet<>(comparing(Hero::getGroupTag))), ArrayList::new ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat));
先取出年龄,再toArray装进数组。
Hero.getList().stream().map(Hero::getAge).toArray(Integer[]::new);6.2 归约和汇总 6.2.1 查找流中的最大值和最小值
接收一个 Comparator 参数来比较流中的元素。
按最大值 maxBy 查找找到年龄最大的Hero:maxBy接收 Comparator super T> comparator 参数来比较流中的元素。
Comparator按最小值 minBy 查找heroAgeComparator = Comparator.comparingInt(Hero::getAge); Optional collect = Hero.getList().stream() .collect(maxBy(heroAgeComparator)); Hero hero = collect.get(); System.out.println(hero); // Hero(name=重八, age=60, score=45, group=C)
找到年龄最小的Hero
Optional6.2.2 汇总 求合 summingInt、summingLong、summingDoublecollect = Hero.getList().stream() .collect(minBy(Comparator.comparingInt(Hero::getAge))); Hero hero = collect.get(); System.out.println(hero); // Hero(name=张三, age=10, score=23, group=A)
接受一个返回int的函数(比如Hero::getScore),并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。
同理。
CollectorheroScore = Collectors.summingInt(Hero::getScore); int total = Hero.getList().stream().collect(heroScore ); System.out.println("分数合计:" + total);// 分数合计:153
- IntStream.sum、LongStream
Listlist = IntStream.rangeClosed(1,100).boxed().collect(Collectors.toList()); int sum = list.stream().mapToInt(Integer::intValue).sum(); System.out.println(sum); // 5050 List longList = LongStream.rangeClosed(1,100).boxed().collect(Collectors.toList()); long sumLong = longList.stream().mapToLong(Long::longValue).sum(); System.out.println(sumLong); // 5050
- Stream.reduce
List平均 averagingInt、averagingLong、averagingDoubleintList = IntStream.rangeClosed(1,100).boxed().collect(Collectors.toList()); Integer sum2 = intList.stream().reduce(0, Integer::sum); System.out.println(sum2); // 5050 Integer sum1 = intList.stream().reduce(0, (a, b) -> a + b); System.out.println(sum1); // 5050
参考求合,不再赘述。
综合统计 summarizingInt、summarizingLong、summarizingDouble一次同时进行总和、平均值、最大值和最小值
Collector6.2.3 连接字符串 joiningheroScoreCollector = Collectors.summarizingInt(Hero::getScore); IntSummaryStatistics collect = Hero.getList().stream() .collect(heroScoreCollector); System.out.println("合计:" + collect.getSum()); // 合计:153 System.out.println("平均数:" + collect.getAverage()); // 平均数:25.5 System.out.println("最大值:" + collect.getMax()); // 最大值:45 System.out.println("最小值:" + collect.getMin()); // 最小值:19 System.out.println("计数:" + collect.getCount()); // 计数:6
String str = Hero.getList().stream()
.map(Hero::getName)
.collect(joining("|"));
System.out.println(str); // 张三|李四|王五|赵六|洪七|重八
6.2.4 广义的归约汇总 reducing
从一个作为累加器的初始值开始,利用 BinaryOperator 与流中的元素逐个结合,从而将流归约为单个值。
6.3 分组事实上,我们已经讨论的所有收集器,都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。Collectors.reducing工厂方法是所有这些特殊情况的一般化。可以说,先前讨论的案例仅仅是为了方便程序员而已。(但是,请记得方便程序员和可读性是头等大事!)例如,可以用reducing方法创建的收集器来计算你菜单的总热量,如下所示:
int totalCalories = menu.stream().collect( reducing(0, Dish::getCalories, (i, j) -> i + j) );它需要三个参数。
- 第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值和而言0是一个合适的值。
- 第二个参数就是你在6.2.2节中使用的函数,将菜肴转换成一个表示其所含热量的int。
- 第三个参数是一个BinaryOperator,将两个项目累积成一个同类型的值。这里它就是对两个int求和。
同样,你可以使用下面这样单参数形式的reducing来找到热量最高的菜,如下所示:OptionalmostCalorieDish = menu.stream().collect( reducing( (d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2) ); 你可以把单参数reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing收集器传递给空流的collect方法,收集器就没有起点;正如我们在6.2.1节中所解释的,它将因此而返回一个Optional对象。
收集与归约
在上一章和本章中讨论了很多有关归约的内容。你可能想知道,Stream接口的collect和reduce方法有何不同,因为两种方法通常会获得相同的结果。例如,你可以像下面这样使用reduce方法来实现toListCollector所做的工作:Streamstream = Arrays.asList(1, 2, 3, 4, 5, 6).stream(); List numbers = stream.reduce( new ArrayList (), (List l, Integer e) -> { l.add(e); return l; }, (List l1, List l2) -> { l1.addAll(l2); return l1; } ); 这个解决方案有两个问题:一个语义问题和一个实际问题。语义问题在于,reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变的归约。与此相反,collect方法的设计就是要改变容器,从而累积要输出的结果。这意味着,上面的代码片段是在滥用reduce方法,因为它在原地改变了作为累加器的List。你在下一章中会更详细地看到,以错误的语义使用reduce方法还会造成一个实际问题:这个归约过程不能并行工作,因为由多个线程并发修改同一个数据结构可能会破坏List本身。在这种情况下,如果你想要线程安全,就需要每次分配一个新的List,而对象分配又会影响性能。这就是collect方法特别适合表达可变容器上的归约的原因,更关键的是它适合并行操作,本章后面会谈到这一点。
| groupingBy | 说明 | 参数 |
|---|---|---|
| 单参数版本 | groupingBy(分组条件) | 收集器,默认Collectors.toList() 结果容器,默认HashMap::new |
| 两参数版本 | groupingBy(分组条件, 收集器) | 收集器:分组后每组的内容如何收集。 |
| 单参数版本 | groupingBy(分组条件, 结果容器工厂, 收集器) | 容器工厂:分组后整个结果怎么存放。 |
groupingBy 根据项目的一个属性的值对流中的项目进行组,并将属性值作为结果 Map 的键。
注意:单参数groupingBy(f)(其中f是分类函数)是groupingBy(f, toList())的简写。
按groupTag分组,结果收集到List。
Map6.3.1 多级分组 groupingBy 嵌套> collect = Hero.getList().stream().collect( groupingBy(Hero::getGroupTag, toList()) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ // "A":[ // { "age":10, "groupTag":"A", "name":"张三", "score":23 }, // { "age":20, "groupTag":"A", "name":"李四", "score":19 } // ], // "B":[ // { "age":30, "groupTag":"B", "name":"王五", "score":20 }, // { "age":40, "groupTag":"B", "name":"赵六", "score":21 } // ], // "C":[ // { "age":50, "groupTag":"C", "name":"洪七", "score":25 }, // { "age":60, "groupTag":"C", "name":"重八", "score":45 } // ] //}
groupingBy(条件, groupingBy(条件, 收集))。
先按照Age分组,再按照GroupTag分组
Map6.3.2 按子组收集数据>> collect = Hero.getList().stream().collect( groupingBy( h -> h.getAge() > 30 ? "老兵" : "新兵", groupingBy(Hero::getGroupTag, toList()) ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ // "新兵":{ // "A":[ // { "age":10, "groupTag":"A", "name":"张三", "score":23 }, // { "age":20, "groupTag":"A", "name":"李四", "score":19 } // ], // "B":[ // { "age":30, "groupTag":"B", "name":"王五", "score":20 } // ] // }, // "老兵":{ // "B":[ // { "age":40, "groupTag":"B", "name":"赵六", "score":21 }], // "C":[ // { "age":50, "groupTag":"C", "name":"洪七", "score":25 }, // { "age":60, "groupTag":"C", "name":"重八", "score":45 } // ] // } //}
先按照GroupTag分组,再计数。
Mapcollect = Hero.getList().stream().collect( groupingBy(Hero::getGroupTag, counting()) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); // { "A":2, "B":2, "C":2 }
先按照Age分组区分"新兵", "老兵",再计数。
Mapcollect1 = Hero.getList().stream().collect( groupingBy(h -> h.getAge() <= 20 ? "新兵" : "老兵", counting()) ); System.out.println(JSONObject.toJSONString(collect1, SerializerFeature.PrettyFormat)); // { "新兵":2, "老兵":4 }
先按照GroupTag分组,再找出年龄最大的Hero。
Map> collect = Hero.getList().stream().collect( groupingBy( Hero::getGroupTag, maxBy(comparingInt(Hero::getAge)) ) ); Optional a = collect.get("A"); Hero hero = a.orElse(new Hero("无名", 9999, 0, "X")); System.out.println(hero); // Hero(name=李四, age=20, score=19, groupTag=A)
1. 把收集器的结果转换为另一种类型 collectingAndThen注意 这个返回Map中的值是Optional (Map
),因为这是maxBy工厂方法生成的收集器的类型,但实际上,如果HeroList中没有某一类型的Hero,那么它根本不会出现在Map的键中。groupingBy收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map中。这意味着Optional包装器在这里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。
(对原文的引用,但是结合上下文调整了一下)
collectingAndThen 包裹另一个收集器,对其结果应用转换函数。
上面的例子可以看出返回Optional有点多余。那么我们转换一下,最终得到Map
Map2. 与groupingBy联合使用的其他收集器的例子 mappingcollect = Hero.getList().stream().collect( groupingBy( Hero::getGroupTag, collectingAndThen( maxBy(comparingInt(Hero::getAge)), Optional::get ) ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ // "A":{ "age":20, "groupTag":"A", "name":"李四", "score":19 }, // "B":{ "age":40, "groupTag":"B", "name":"赵六", "score":21 }, // "C":{ "age":60, "groupTag":"C", "name":"重八", "score":45 } //}
public static
通过在累加之前对每个输入元素应用映射函数,将一个接受U类型元素的收集器适配为一个接受T类型元素。
Map> collect = Hero.getList().stream().collect( groupingBy( h -> h.getAge() <= 20 ? "新兵" : "老兵", mapping( h -> h.getName()+":"+h.getAge()+"岁", toList() ) ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ // "新兵":["张三:10岁", "李四:20岁"], // "老兵":["王五:30岁", "赵六:40岁", "洪七:50岁", "重八:60岁"] //}
再看一特殊的例子:(模拟一个toMap)等同于:Hero.getList().stream().collect(toMap(Hero::getName, Hero::getAge))
Map先分组再各自排序> collect = Hero.getList().stream().collect( groupingBy( Hero::getName, mapping(Hero::getAge, reducing((a, c) -> a.intValue())) ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ "李四":20, "张三":10, "洪七":50, "王五":30, "赵六":40, "重八":60 }
这里演示的是“排序”,当然也可以做其它的操作。
并不是在一个流中实现的,分组后先收集了得到一个Map,再取出 Map.Entry 生成流进一步处理的。
// 1. 按年龄分组 age > 20 ? 老兵 : 新兵。 // 2. 新兵按年龄升序,老兵按年龄降序。 // 3. map 返回 Map.Entrymapping 分组后对值进行映射,再收集> // 4. 收集器得到结果 HashMap Map > collect = Hero.getList() .stream() // 按年龄 >20 分组。收集得到一个 Map .collect(groupingBy(h -> h.getAge() > 20 ? KEY1 : KEY2)) // 获取 Map.Entry .entrySet() // 获取流 .stream() // 对分组后的内容各自排序 .map( entry -> { // 判断 Key 默认升序,如果是“老兵”改为降序 String key = entry.getKey(); Comparator comparator = Comparator.naturalOrder(); if(KEY1.equals(key)){ comparator = Comparator.reverseOrder(); } // 对当前组的值(List )按年龄排序。 List list = entry.getValue().stream() .sorted(Comparator.comparing(Hero::getAge, comparator)) .collect(Collectors.toList()); entry.setValue(list); // 返回 Map.Entry > return entry; }).collect(toMap( m -> m.getKey(), // 处理 key m -> m.getValue() // 处理 value )); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat));
mapping通常是配合分组,分区等场景,嵌套使用的。
先按GroupTag分组,然后将结果Hero对象映射为 name 收集进List
Map6.4 分区 partitioningBy> collect = Hero.getList().stream().collect( groupingBy( Hero::getGroupTag, mapping(Hero::getName, toList()) ) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat));
partitioningBy 根据对流中每个项目应用谓词的结果来对项目进行分区。
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组。
按年龄>20分区:true,false为key。
Map6.5 收集器接口> collect = Hero.getList().stream().collect( partitioningBy(h -> h.getAge() > 20) ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); //{ // false:[ // { "age":10, "groupTag":"A", "name":"张三", "score":23 }, // { "age":20, "groupTag":"A", "name":"李四", "score":19 } // ], // true:[ // { "age":30, "groupTag":"B", "name":"王五", "score":20 }, // { "age":40, "groupTag":"B", "name":"赵六", "score":21 }, // { "age":50, "groupTag":"C", "name":"洪七", "score":25 }, // { "age":60, "groupTag":"C", "name":"重八", "score":45 } // ] //}
Collector接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。我们已经看过了Collector接口实现的许多收集器,例如toList或groupingBy。这也意味着,你可以为Collector接口提供自己的实现,从而自由地创建自定义归约操作。
public interface Collector{ Supplier supplier(); BiConsumer accumulator(); Function finisher(); BinaryOperator combiner(); Set characteristics(); }
| 泛型 | 说明 |
|---|---|
| T | 流中要收集的项目的泛型。 |
| A | 累加器的类型,累加器是在收集过程中用于累积部分结果的对象。 |
| R | 收集操作得到的对象(通常但并不一定是集合)的类型。 |
现在我们可以一个个来分析 Collector 接口声明的五个方法了。通过分析,你会注意到,前四个方法都会返回一个会被collect方法调用的函数,而第五个方法characteristics则提供了一系列特征,也就是一个提示列表,告诉collect方法在执行归约操作的时候可以应用哪些优化(比如并行化)。
- 1、建立新的结果容器:supplier 方法。此方法返回一个Supplier 生产者,用来创建收集所需的累加器。
public Supplier> supplier() { return ArrayList::new; }
- 2、将元素添加到结果容器:accumulator方法。此方法返回一个执行归约操作的函数。fn(累加器, 当前值){ ... }
public BiConsumer, T> accumulator() { return List::add; // (累加器, 当前值) -> 累加器.add(当前值); }
- 3、对结果容器应用最终转换:finisher方法。流遍历完后,进行收尾工作,将累加器转换为所需的最终结果。
public Function, List
> finisher() { return Function.identity(); // 最简单的情况就是:直接返回累加器。 }
- 4、合并两个结果容器:combiner方法。此方法返回一个函数,用于在多线程场景中,对各子流归约所得的累加器进行合并。
public BinaryOperator> combiner() { return (list1, list2) -> { list1.addAll(list2); return list1; } }
- 5、characteristics方法。定义收集器的行为。
最后一个方法——characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。Characteristics是一个包含三个项目的枚举。
- UNORDERED——归约结果不受流中项目的遍历和累积顺序的影响。
- CONCURRENT——accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可以并行归约。
- IDENTITY_FINISH——这表明完成器方法返回的函数是一个恒等函数,可以跳过。这种情况下,累加器对象将会直接用作归约过程的最
终结果。这也意味着,将累加器A不加检查地转换为结果R是安全的。我们迄今开发的ToListCollector是IDENTITY_FINISH的,因为用来累积流中元素的List已经是我们要的最终结果,用不着进一步转换了,但它并不是UNORDERED,因为用在有序流上的时候,我们还是希望顺序能够保留在得到的List中。最后,它是CONCURRENT的,但我们刚才说过了,仅仅在背后的数据源无序时才会并行处理。
public Set进行自定义收集而不去实现Collectorcharacteristics() { return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT)); }
java.util.streamcollect有如下3参数重载。它永远都是一个IDENTITY_FINISH和CONCURRENT但并非UNORDERED的收集器。
6.6 开发你自己的收集器以获得更好的性能R collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner); // 使用示例: HashMap collect = Hero.getList().stream() .collect( HashMap ::new, (a, c) -> a.put(c.getName(), c.getAge()), HashMap::putAll ); System.out.println(JSONObject.toJSONString(collect, SerializerFeature.PrettyFormat)); // { "李四":20, "张三":10, "洪七":50, "王五":30, "赵六":40, "重八":60 }
模拟实现一个Collectors.joining()字符串拼接。
@AllArgsConstructor public class ToStringCollectorimplements Collector { private CharSequence delimiter = ","; @Override public Supplier supplier() { return StringBuffer::new; } @Override public BiConsumer accumulator() { return (sb,h) -> { if(sb.length() != 0) sb.append(delimiter); sb.append(h); }; } @Override public Function finisher() { return sb -> sb.toString(); } @Override public BinaryOperator combiner() { return StringBuffer::append; // (sb, sb2) -> { sb.append(sb2); return sb; }; } @Override public Set characteristics() { return Collections.unmodifiableSet(EnumSet.of(Characteristics.CONCURRENT, Characteristics.UNORDERED)); } }
String str = Hero.getList().stream()
.map(Hero::getName)
.collect(new ToStringCollector<>("*"));
System.out.println(str); // 张三*李四*王五*赵六*洪七*重八
小结
- 选择流类型的原则:
1.1. 能具体就具体,能用IntStream就不用Stream。
1.2. 能用收集器Stream.collect(Collectors.reducing(…))就不用Stream.reduce(…)
- collect是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器)。
- 预定义收集器包括将流元素归约和汇总到一个值,例如计算最小值、最大值或平均值。这些收集器总结在表6-1中。
- 预定义收集器可以用groupingBy对流中元素进行分组,或用partitioningBy进行分区。
- 收集器可以高效地复合起来,进行多级分组、分区和归约。
- 你可以实现Collector接口中定义的方法来开发你自己的收集器。
笑虾 :《Java8实战》 - 读书笔记 - Stream流操作1:流的基本用法
笑虾 :Java 数组类型互转
java.util.stream.Stream
java.util.stream.Collector 接口定义了实现归约操作(即收集器)的规范
java.util.stream.Collectors 工具类提供了大量常用的收集器



