灵活,无形,如水一般。—— 李小龙
收集器的高级用法
前文介绍了流的组成部分,并重点列举了中间操作的常用函数,在本文中,将从流的收集器开始讲起。
收集器对应流的终端操作,是流计算最终的生成物,它的功能不仅仅限于将结果以集合方式呈现,更可以包含分组、分区的操作。我们以一个需求用例来说明。
已知一个大前端团队里面,包含3种技术栈的成员,分别是 Android、iOS、H5,请你依据技术类型,将团队成员进行归类。
这是一个常见的对列表结果进行分组的行为,首先是传统的 Java 代码实现:
// 用传统 Java 方式进行分组
Map<TechType, List<Employee>> techMap = new HashMap();
for (Employees e: employees) {
TechType techType = e.getTechType();
List<Employee> list = techMap.get(techType):
if (list == null) {
list = new ArrayList<Employee>();
techMap.put(techType, list);
}
list.add(e);
}
return techMap;
接下来是运用流的收集器进行分组。
// 用流进行分组
Map<TechType, List<Employee>> techMap = employees.stream().collect(groupingBy(Employee::getTechType));
return techMap;
仅仅用1行代码,实现了前面10行的功能,代码量缩减90%!这里面可以看出函数式编程相比于指令式编程的一个主要优势,你只需指出希望的结果—— “做什么”,而不用操心执行的步骤—— “如何做”。
要是做多级分组,指令式和函数式之间的区别就会更加明显:由于需要好多层嵌套循环和条件,指令式代码很快就变得更难阅读、更难维护、更难修改。相比之下,函数式版本只要再加上一个收集器就可以轻松地增强功能了。
收集器可以分为 预定义
、自定义
两种。
预定义收集器
在 JDK 中有预定义的三类收集器,覆盖了使用流过程中的主要场景,这三种预定义收集器分别是:
- 元素归约和汇总 —— 例如对元素求和、求极值。
- 元素分组 —— 单层次和多层次分组,以及分组归约。
- 元素分区 —— 分组的特殊情况,true/false 分组。
归约
收集器目的 | 代码片段 | 说明 |
---|---|---|
求极值 | maxBy ,minBy | 接收Comparator 类型的参数,例如找出薪水最高的员工 employees.stream().collect(maxBy(salaryComparator)) |
汇总 | summingInt ,summingDouble ,averagingInt ,averagingLong | 对基本类型特化后求和、求平均等 |
拼接字符串 | joinint ,joinint(拼接符) | 所有人的姓名employees.stream().map(Employee::getName).collect(joining(", ")); |
归约的本质
事实上,我们已经讨论的所有收集器,都是一个可以用 reduce
工厂方法定义的归约过程的特殊情况而已。 Collectors.reduce
工厂方法是所有这些特殊情况的一般化。可以理解成,预定义收集器仅仅是为了方便开发者,而预先设计好的特例。
以计算最高薪资为例。
// Optional因为列表可能为空
Optional<Integer> highestSalary = employees.stream().map(Employee::getSalary).reduce((s1,s2) -> s1>s2 ? s1 : s2);
尽管可以实现同样的需求,但使用 reduce
进行归约,与使用 collect
得到集合,还有些许的不同之处。考虑 toList
的收集器,如果使用 reduce
改写后,代码如下。
// 使用 reduce 实现得 toList 收集器
Stream<Integer> stream = Arrays.asList(1,2,3,4,5).stream();
List<Integer> numbers = stream.reduce(
new ArrayList<Integer>(),
(List<Integer> l, Integer e) -> {
l.add(e);
return l; },
(List<Integer> l1, List<Integer> l2) ->{
l1.addAll(l2);
return l1; });
上述实现方案会带来两个问题,一个语义问题和一个实际问题,这意味着上述实现是在滥用 reduce
方法。
- 语义问题: reduce 原则上是一个 不可变 的归约,而 collect 方法会不断改变容器,两者语义不匹配。
- 实际问题: 对 reduce 错误的语义理解,会阻止上述归约 并行化。
最佳实践建议:尽可能为手头的问题探索不同的解决方案,但在通用的方案里面,始终选择最专门化的一个。无论是从可读性还是性能上看,这一般都是最好的决定。
分组
根据一个或者多个属性,对数据集进行分组,也是常见的操作之一。
分组的基本语法
向 groupingBy
方法传递一个 Function
,也可使用方法引用。
// 使用方法引用
Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
// 自定义分组规则
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}
)
);
高级技巧:多级分组
所谓多级分组,是在原有 groupingBy
单一参数的基础上,增加新一级分组所使用的参数,也就是增加第二个参数。使用一级参数进行一级分组,使用二级参数进行二级分组,以此类推。
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType, // 一级分组参数
groupingBy(dish -> { // 二级分组参数
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
})
)
);
上述代码生成两级 Map,如下问题。外层 Map 的 Key 是第一级分类函数生成的值 fish, meat, other
,而 Value 则是二级分类函数生成的值 normal, diet, fat
。二级 Map 的 Value 是流当中元素构成的 List。
这种多级分组操作可以扩展到任意层级。
// 分组结果
{
MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}
}
按子组收集数据
考虑这种场景,对于上文中的菜品分类meat, fish, other
,分别计算该品类下菜品的总个数。
此时作为二级分组的运算不再是 groupingBy
,而应当替换为 统计元素数量
的运算符 counting()
。
// 统计每个分类下的菜品总个数
Map<Dish.Type Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
// 统计得到 {MEST=3, FISH=2, OTHER=4}
再举一个例子,找出每个分类下热量最高的菜品。
// 每个分类下热量最高的菜品
Map<DishType, Optional<Dish>> mostCaloricByType =
menu.stream().collect(groupingBy(Dish::getType,
maxBy(comparingInt(Dish::getCalories))));
// 返回
{
FISH=Optional[salmon],
OTHER=Optional[pizza],
MEAT=Optional[pork]
}
分区
分区是分组的特例,是指分成两组,其 Key 为 Boolean 类型的 True、False
,其 Value 是 Map
。
分区的关键字是 groupingBy
。
以下代码将菜品分为素食、非素食两组。
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));
// 返回如下 Map
{
false=[pork, beef, chicken, prawns, salmon],
true=[french fries, rice, season fruit, pizza]
}
分区的优势
分区是分组的特化,理论上分区 partioningBy
能做的事,都可以用分组 groupingBy
来实现。
分区的好处在于省略了定义枚举的步骤,而是直接通过 True
、False
进行分组。
利用分区,找出素食、非素食中各自 热量>500kcal 的菜品。
menu.stream().collect(partioningBy(Dish::isVegetarian, partitioningBy(d -> d.getCalories() > 500)));
利用分区,统计素食、非素食菜品数量。
menu.stream().collect(partioningBy(Dish::isVegetarian, counting()));
自定义收集器
这部分内容技术难度高、应用场景小,建议作为参考资料,在确定要使用时再查询。
流的并行化
在Java 7之前,并行处理数据集合非常麻烦。第一,你得明确地把包含数据的数据结构分成若干子部分。第二,你要给每个子部分分配一个独立的线程。第三,你需要在恰当的时候对它们进行同步来避免不希望出现的竞争条件,等待所有线程完成,最后把这些部分结果合并起来。
Java 8 对于并行化的一大推进,是在很大程度上简化了流计算的并行化,只需要将 stream()
调用替换为 parallelStream()
即可。也可以在已有的中间运算中插入 parallel()
操作进行转化。
// 单线程计算前n个数字之和
public static long sequentialSum(long n) {
return Stream.iterate(1L, i->i+1)
.limit(n)
.reduce(0L, Long::sum);
}
// 并行版本
public static long sequentialSum(long n) {
return Stream.iterate(1L, i->i+1)
.limit(n)
.parallel() // ===> 将流转换成并行流
.reduce(0L, Long::sum);
}
并行/顺序执行动态转换
在流的执行过程中,顺序/并行流是可以互相转换的:
- 顺序流 -> 并行流: 在 Stream 上调用
parallel()
- 并行流 -> 顺序流: 在 Stream 上调用
sequential()
对顺序流调用 parallel
函数并不意味着流本身发生任何实际的变化,它在内部实际上就是设了一个 boolean
标志,表示你想让调用 parallel
之后进行的所有操作都并行执行。类似地,你只需要对并行流调用 sequential
方法就可以把它变成顺序流。请注意,你可能以为把这两个方法结合起来,就可以更细化地控制在遍历流时哪些操作要并行执行,哪些要顺序执行。例如,你可以这样做:
stream.parallel()
.filter(...)
.sequential()
.map(...)
.parallel()
.reduce();
但最后一次 parallel 或 sequential 调用会影响整个流水线。在本例中,流水线会并行执行,因为最后调用的是它。
并行流底层实现
Q:并行流用的线程是从哪儿来的?线程池大小是多少?能否定制线程数量?
A:并行流内部使用默认的 ForkJoinPool
,其默认线程数量是处理器的数量,即 Runtime.getRuntime().availableProcessors()
。可以通过系统属性 java.util.concurrent.ForkJoinPool.common.parallelism
来改变其线程池大小,如下代码所示 System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "12");
将线程池大小改为12 —— 这是一个全局设置,意味着它将影响 JVM 内部全体并行流。通常来说,默认的大小即可,无需进行特别设置。
并行流的代价
流的并行化虽然实现简单,但这并不意味着没有代价 —— 并行化过程本身需要对流做递归划分,把每个子流的归纳操作分配到不同的线程,然后把这些操作的结果合并成一个值。但在多个内核之间移动数据的代价可能比想象的要大得多,所以很重要的一点是要保证 内核中并行执行工作的时间比在内核之间传输数据的时间长。
并行流的常见误区:操作共享变量
如果流使用的函数操作了 外部的共享变量,不仅无法通过并行化对其进行效率提升,更有可能产生因并发导致的数据不一致问题。
// 对 1..n 数字进行累加,但是有副作用,操作了共享变量 accumulator
public static long sideEffectSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).forEach(accumulator::add);
return accumulator.total;
}
public class Accumulator {
public long total = 0;
public void add(long value) { total += value; }
}
// 通过 parallel 变为并行流
LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
// 并行流输出,每次都不一样 —— 并发问题
Result: 5959989000692
Result: 7425264100768
Result: 6827235020033
Result: 7192970417739
Result: 6714157975331
Result: 7497810541907
Result: 6435348440385
Result: 6999349840672
Result: 7435914379978
这已经不仅仅是性能问题了,而是更严重的结果不正确。这是由于多个线程在同时访问 accumulator
,执行 total += value
,这不是一个原子操作。由于 forEach
中的函数有副作用,改变了多个线程共享对象的可变状态,因此导致了预期之外结果的产生。
对是否拆分并行流的一些建议
- 测量,测量,再测量。
- 留意自动装箱拆箱,这会大大降低性能。
- 依赖元素顺序的操作,例如 limit、findFirst 等,在并行流上执行效率更差。
- 单个元素通过流水线进行处理的成本越高,对其进行并行化的收益越大。
- 对于较小数据量,使用并行流的好处要低于并行化造成的额外开销。
- 要考虑流背后的数据结构是否易于分解,LinkedList 的分解效率不高,因为要对其进行平均拆分,必须要进行遍历。
- 还要考虑终结操作中合并步骤的代价大小。
参考资料
- Java 8 in Action