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

126 阅读9分钟

像水一样吧,朋友。—— 李小龙

image.png

当我们谈论“流”时,我们在谈论什么

流普及的背景

流式编程(Stream API)是一种有别于传统的面向对象编程的思想,流的普及主要得益于以下几点因素:

  • 多核架构处理器日渐普及: 为了提高程序性能,需要利用多核架构并行处理。传统的并行代码(synchronized原子类型等)写法复杂,容易出错且难以调试,需要更加简洁完美的写法,最好能够自动对线程进行分配管理,从而将软件工程师的关注点聚焦在产品需求建模上。
  • 对大数据处理需求增加: 集合是 Java 中使用最多的 API,几乎每个 Java 应用程序都会制造和处理集合。在 Java 8 之前,JDK 提供了基本的 Collections 接口用来处理集合。随着大数据量的场景日益增多,传统的集合接口已经不足以满足业务场景,处理效率低、代码冗余多、运算速度慢、内存占用大,这些问题用已有的集合接口已经无法解决。
  • 函数式编程风格适配: Java 8 引入 Lambda 表达式后,需要一套支持 函数式风格 的集合操作 API,以替代冗长的迭代器(Iterator)和 for 循环。

流的核心特征

  • 流水线化: 链式调用处理数据,数据源中间操作(filter/map)终端操作(collect/count)
  • 惰性求值: 中间操作延迟执行,直到触发终端操作才计算。
  • 自动并行: 通过 parallel() 自动启用并行处理,无需手动管理线程。

流与集合的区别

流(Stream)和集合(Collection)都是用来批量处理数据的技术,尽管功能相似,在大部分场景下可以替换使用,但它们具有本质上的区别,如下表所示。

区别集合(Collection)流(Stream)
是否存储数据存储所有数据不存储数据
迭代方式外部迭代(手动循环)内部迭代(自动处理元素)
是否可以多次消费支持多次访问单次消费
执行计算时机立即计算惰性计算
容量大小有限容量可处理无限数据(如 Stream.generate()

流只能被遍历一次

流只是概念上固定的数据结构,无法向其中添加或删除元素,其元素是按需计算的,具备惰性特质。

和迭代器类似,流只能被遍历一次。当遍历完成后,该流就被消费掉了,一旦尝试对其遍历第二次,就会抛出 IllegalStateException

List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // ===> throws java.lang.IllegalStateException

以哲学的观点,“流”是在时间中分布的一组值,它们正在不断流逝中,某一时刻只可能触及其中的某一个。而“集合”则是空间(计算机内存)中分布的一组值,在某一时间点上全体存在。

“流”在客户端(Android)开发中的应用

Kotlin 提供了与 Stream 相近的 Flow API,因此我们可以在编写 Android 程序时,享受到流式编程带来的各种便利。这种便利主要体现在两个方面。

一是利用流,对大量数据进行函数式处理,流的函数式接口更接近“做什么”而非“如何去做”,也更符合人思考问题的逻辑。

二则是在编写 Android 应用软件时,要考虑数据流、控制流的单向传递,数据流由数据层流向 UI 层,控制流则反之。利用“流”的思想,将应用程序内部状态的流转抽象化。

image.png

流的组成元素

“流”的定义是:从支持数据处理操作的源生成的元素序列。在定义中有几个关键性的元素:

  • 源: 流一定具备一个用于提供数据的源,例如集合、数组、输入/输出资源。
  • 数据处理操作: 类似于数据库的操作,以及函数式编程语言中的常用操作,如 filtermapreducefindmatchsort 等。
  • 元素序列: 流提供了一个接口,类似集合,该接口可以访问一组有序的元素。流的目的在于表达计算,而非数据存储本身。

此外,流还具备如下核心特点:

  • 流水线: 对流进行操作后,会返回一个新的流,因此可以将多个操作级联,从而得到一个大的流水线。
  • 内部迭代: 流天生支持迭代,而无需像集合那样 for 循环。

一个例子:筛选菜品

以下这段代码,实现的需求是“选出前3个热量 >300kcal 的菜肴”。

List<String> threeHighCalorieDishNames =
        menu.stream() // ===> 建立流水线
            .filter(d -> d.getCalories() > 300) // ===> 过滤得到热量 >300kcal 菜品
            .map(Dish::getName) // ===> 获取菜名
            .limit(3) // ===> 取头3个
            .collect(toList()) // ===> 保留到 List

collect 函数调用前,每一步都是一个流,如下图所示。在 collect 函数调用后,则转换为列表。

image.png

操作分类:中间、终结

image.png

对流的操作可以分为两类:允许无限衔接的中间操作,以及只能调用一次,调用过后流就终止的终端操作。

mindmap
      流的操作
          中间操作
          终端操作

中间操作

中间操作具备如下特性:

  • 衔接:可以无限连接。
  • 惰性:除非流水线上附加一个终端操作,否则中间操作不会执行。通常它们会被合并起来一次性处理。
  • 短路:中间操作可能不会对所有元素执行,例如 limit(3) 只会执行前3个元素。
  • 循环合并:独立操作(例如filtermap)有可能被合并到一次遍历中。

终结(终端)操作

终端操作从 Stream 当中取得结果,结果可以是任何非 Stream 的值,例如 ListInteger 乃至 void

forEach就是一种返回 void 的终结操作,它用于对流当中的元素执行 Lambda 表达式。

总而言之,在使用流时需要三件事:

  • 一个 数据源(如集合) 来执行一个查询;
  • 一个 中间操作链,形成一条流的流水线;
  • 一个 终端操作,执行流水线,并能生成结果。

借助设计思想,可以用 构建器/builder 模式 来理解流的使用。通过中间操作设置流的属性,通过终端操作触发流 build()

流的操作函数速查

构建流(Stream Sources)

是创建流的源头,分为集合型、非集合型两类。

// 1. 从集合创建(最常用)
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream1 = list.stream();         // 顺序流
Stream<String> stream2 = list.parallelStream(); // 并行流

// 2. 从数组创建
String[] array = {"a", "b", "c"};
Stream<String> stream3 = Arrays.stream(array);

// 3. 静态工厂方法
Stream<String> stream4 = Stream.of("a", "b", "c"); // 显式元素
Stream<Double> stream5 = Stream.generate(Math::random); // 无限流
Stream<Integer> stream6 = Stream.iterate(0, n -> n + 2); // 迭代流 (0,2,4...)

// 4. 特殊流
IntStream intStream = IntStream.range(1, 5);    // 1,2,3,4 (整型流)
LongStream timestampStream = Files.lines(Path.of("log.txt")) // 文件流
                                 .map(line -> parseTimestamp(line));

转换流(Intermediate Operations)

它具备惰性、短路的特征,一些常见的转换操作如下:

操作示例功能说明
过滤.filter(s -> s.length() > 3)保留满足条件的元素
映射.map(String::toUpperCase)元素一对一转换
扁平化.flatMap(list -> list.stream())将流中集合"拍平"为单个元素流
去重.distinct()删除重复元素
排序.sorted(Comparator.reverseOrder())自定义排序规则
截取/跳过.limit(10).skip(5)取前10元素后跳过前5个
调试.peek(System.out::println)查看流经元素(不改动)
// 组合示例:处理字符串流
Stream<String> transformed = Stream.of("apple", "banana", "cherry")
    .filter(s -> s.length() > 5)          // 保留长度>5的 ["banana","cherry"]
    .map(String::toUpperCase)             // 转大写 → ["BANANA","CHERRY"]
    .flatMap(s -> Stream.of(s.split(""))) // 拆字母 → ["B","A","N",...]
    .distinct()                           // 去重 → ["B","A","N","C","H","E","R","Y"]
    .sorted();                            // 字母排序 → ["A","B","C",...]

类似代码的图形化描述如下。

image.png

收集流(Terminal Operations)

触发计算并关闭流,生成最终结果。

操作示例功能说明
聚合count()max()min()average()计数、最大、最小、平均值
匹配anyMatch()allMatch()nonMatch()单个、全部、无匹配,支持短路
消费forEach()forEachOrdered()后者在并行流下保证顺序
收集器代码片段如下功能强大的 Collectors
// Collectors 收集流示例

// 转为集合
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());

// 转为Map
Map<String, Integer> map = stream.collect(
    Collectors.toMap(s -> s, String::length) // key=元素, value=长度
);

// 分组(按字符串长度分组)
Map<Integer, List<String>> groups = stream.collect(
    Collectors.groupingBy(String::length)
);

// 分区(按长度>5分区)
Map<Boolean, List<String>> partitions = stream.collect(
    Collectors.partitioningBy(s -> s.length() > 5)
);

// 拼接字符串
String joined = stream.collect(Collectors.joining(", ")); 
// 输出: "apple, banana, cherry"

// 自定义收集(计算总和)
Integer sum = stream.collect(
    Collectors.reducing(0, String::length, Integer::sum)
);

高级技巧:使用 reduce 对流进行归约

归约(Reduction) 在计算机科学中,特别是在函数式编程和并行计算领域,指的是将数据集中的元素通过某种操作(通常是二元操作)组合起来,生成一个单一的汇总结果的过程。

reduce 用来对流当中的元素进行归约,以整数流 求和操作 为例,其归约过程如下图所示:

image.png

流的高级用法

以上对于流的介绍,已经足以解决大多数一般性的需求开发,接下来本节将对流的一些高级用法进行介绍。即使不完全掌握,也无伤大雅。

原始类型流特化

与前一篇文章中介绍的 Lambda 表达式对于基本类型特化相似,在使用流的时候,为了避免自动装箱/拆箱带来的性能损失,JDK 同样提供了基本类型(原始类型)流的特化操作。

Java 8 对于 intlongdouble 这三种基本类型,提供了对应的特化流,分别是 IntStreamLongStream DoubleStream。注意这三种特化流并不是对应的 Stream<T>。例如,可以如下文这样,对于团队里所有人的薪资求和:

double salaries = employees.stream() // ===> 返回 Stream<Employee>
    .mapToDouble(Employee::getSalary) // ===> 返回 DoubleStream
    .sum();

在已有一个特化流的情况下,可以使用 boxed() 函数将其转换为普通流 Stream<T>

// 将特化流转换回普通流
DoubleStream salaries = employees.stream().mapToDouble(Employee::getSalary);
Stream<Double> stream = salaries.boxed(); // ===> 将数值流转化为 Stream

数值范围

Java 8 引入了名为 rangerangeClosed 的两个静态方法,作用于 IntStreamLongStream ,可生成类似 1..100 的常量范围。

它们都接收两个整型参数,其中 range 不包含结束值,而 rangeClosed 则包含结束值。

// 一个从 1 到 100 的偶数流
IntStream evenNumbers = IntStream.rangeClosed(1, 100).filter(n -> n % 2 == 0);

参考资料

  • Java 8 in Action