Java 8 效率精进指南(5)“流”的哲学(下)

60 阅读10分钟

灵活,无形,如水一般。—— 李小龙

image.png

收集器的高级用法

前文介绍了流的组成部分,并重点列举了中间操作的常用函数,在本文中,将从流的收集器开始讲起。

收集器对应流的终端操作,是流计算最终的生成物,它的功能不仅仅限于将结果以集合方式呈现,更可以包含分组、分区的操作。我们以一个需求用例来说明。

已知一个大前端团队里面,包含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 分组。

归约

收集器目的代码片段说明
求极值maxByminBy接收Comparator类型的参数,例如找出薪水最高的员工 employees.stream().collect(maxBy(salaryComparator))
汇总summingIntsummingDoubleaveragingIntaveragingLong对基本类型特化后求和、求平均等
拼接字符串joinintjoinint(拼接符)所有人的姓名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;
        }
    )
);

image.png

高级技巧:多级分组

所谓多级分组,是在原有 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]}
}

image.png

按子组收集数据

考虑这种场景,对于上文中的菜品分类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 来实现。

分区的好处在于省略了定义枚举的步骤,而是直接通过 TrueFalse 进行分组。

利用分区,找出素食、非素食中各自 热量>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